Implement chat UX gaps and container detection
This commit is contained in:
parent
a85d086bbd
commit
c8490ccf8d
5 changed files with 811 additions and 47 deletions
101
pkg/container/detect.go
Normal file
101
pkg/container/detect.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContainerRuntime describes the preferred isolated workload runtime for CoreGUI.
|
||||
//
|
||||
// runtime := container.Detect()
|
||||
// if runtime == container.RuntimeApple { /* prefer Apple Containers */ }
|
||||
type ContainerRuntime string
|
||||
|
||||
const (
|
||||
RuntimeNone ContainerRuntime = ""
|
||||
RuntimeApple ContainerRuntime = "apple"
|
||||
RuntimeDocker ContainerRuntime = "docker"
|
||||
RuntimePodman ContainerRuntime = "podman"
|
||||
)
|
||||
|
||||
// DetectEnvironment controls runtime detection for tests and other callers.
|
||||
//
|
||||
// runtime := container.DetectWithEnvironment(container.DetectEnvironment{
|
||||
// GOOS: "darwin",
|
||||
// ProductVersion: "26.0",
|
||||
// })
|
||||
type DetectEnvironment struct {
|
||||
GOOS string
|
||||
ProductVersion string
|
||||
LookPath func(file string) (string, error)
|
||||
}
|
||||
|
||||
// Detect prefers Apple Containers on macOS 26+, then Docker, then Podman.
|
||||
//
|
||||
// runtime := container.Detect()
|
||||
func Detect() ContainerRuntime {
|
||||
environment := DetectEnvironment{
|
||||
GOOS: runtime.GOOS,
|
||||
ProductVersion: "",
|
||||
LookPath: exec.LookPath,
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
environment.ProductVersion = productVersion()
|
||||
}
|
||||
return DetectWithEnvironment(environment)
|
||||
}
|
||||
|
||||
// DetectWithEnvironment applies the RFC runtime ordering using an explicit environment.
|
||||
//
|
||||
// runtime := container.DetectWithEnvironment(env)
|
||||
func DetectWithEnvironment(environment DetectEnvironment) ContainerRuntime {
|
||||
lookPath := environment.LookPath
|
||||
if lookPath == nil {
|
||||
lookPath = exec.LookPath
|
||||
}
|
||||
|
||||
goos := strings.ToLower(strings.TrimSpace(environment.GOOS))
|
||||
if goos == "darwin" && majorVersion(environment.ProductVersion) >= 26 {
|
||||
if hasBinary(lookPath, "container") || hasBinary(lookPath, "apple-container") || hasBinary(lookPath, "containerctl") {
|
||||
return RuntimeApple
|
||||
}
|
||||
}
|
||||
if hasBinary(lookPath, "docker") {
|
||||
return RuntimeDocker
|
||||
}
|
||||
if hasBinary(lookPath, "podman") {
|
||||
return RuntimePodman
|
||||
}
|
||||
return RuntimeNone
|
||||
}
|
||||
|
||||
func hasBinary(lookPath func(string) (string, error), binary string) bool {
|
||||
if strings.TrimSpace(binary) == "" {
|
||||
return false
|
||||
}
|
||||
_, err := lookPath(binary)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func majorVersion(productVersion string) int {
|
||||
productVersion = strings.TrimSpace(productVersion)
|
||||
if productVersion == "" {
|
||||
return 0
|
||||
}
|
||||
major, _, _ := strings.Cut(productVersion, ".")
|
||||
value, err := strconv.Atoi(major)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func productVersion() string {
|
||||
output, err := exec.Command("sw_vers", "-productVersion").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
86
pkg/container/detect_test.go
Normal file
86
pkg/container/detect_test.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDetectWithEnvironment_PrefersAppleContainersOnMacOS26(t *testing.T) {
|
||||
runtime := DetectWithEnvironment(DetectEnvironment{
|
||||
GOOS: "darwin",
|
||||
ProductVersion: "26.0",
|
||||
LookPath: func(file string) (string, error) {
|
||||
if file == "container" {
|
||||
return "/usr/bin/container", nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, RuntimeApple, runtime)
|
||||
}
|
||||
|
||||
func TestDetectWithEnvironment_FallsBackToDockerWhenAppleUnavailable(t *testing.T) {
|
||||
runtime := DetectWithEnvironment(DetectEnvironment{
|
||||
GOOS: "darwin",
|
||||
ProductVersion: "26.1",
|
||||
LookPath: func(file string) (string, error) {
|
||||
if file == "docker" {
|
||||
return "/usr/local/bin/docker", nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, RuntimeDocker, runtime)
|
||||
}
|
||||
|
||||
func TestDetectWithEnvironment_UsesDockerOnNonMacHosts(t *testing.T) {
|
||||
runtime := DetectWithEnvironment(DetectEnvironment{
|
||||
GOOS: "linux",
|
||||
ProductVersion: "",
|
||||
LookPath: func(file string) (string, error) {
|
||||
if file == "docker" {
|
||||
return "/usr/bin/docker", nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, RuntimeDocker, runtime)
|
||||
}
|
||||
|
||||
func TestDetectWithEnvironment_UsesPodmanWhenDockerMissing(t *testing.T) {
|
||||
runtime := DetectWithEnvironment(DetectEnvironment{
|
||||
GOOS: "linux",
|
||||
ProductVersion: "",
|
||||
LookPath: func(file string) (string, error) {
|
||||
if file == "podman" {
|
||||
return "/usr/bin/podman", nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, RuntimePodman, runtime)
|
||||
}
|
||||
|
||||
func TestDetectWithEnvironment_ReturnsNoneWhenNoRuntimeIsAvailable(t *testing.T) {
|
||||
runtime := DetectWithEnvironment(DetectEnvironment{
|
||||
GOOS: "linux",
|
||||
ProductVersion: "",
|
||||
LookPath: func(string) (string, error) {
|
||||
return "", errors.New("not found")
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, RuntimeNone, runtime)
|
||||
}
|
||||
|
||||
func TestMajorVersion(t *testing.T) {
|
||||
assert.Equal(t, 26, majorVersion("26.0"))
|
||||
assert.Equal(t, 0, majorVersion("bogus"))
|
||||
assert.Equal(t, 0, majorVersion(""))
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
ChatService,
|
||||
Conversation,
|
||||
ImageAttachment,
|
||||
ToolInvocation,
|
||||
} from '../services/chat.service';
|
||||
import { UiStateService } from '../services/ui-state.service';
|
||||
|
||||
|
|
@ -120,6 +121,13 @@ interface ConversationGroup {
|
|||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="row-icon danger"
|
||||
(click)="deleteConversation(conversation, $event)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
|
@ -154,6 +162,14 @@ interface ConversationGroup {
|
|||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-button"
|
||||
[disabled]="!activeConversation()"
|
||||
(click)="clearActiveConversation()"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-button danger"
|
||||
|
|
@ -206,7 +222,12 @@ interface ConversationGroup {
|
|||
@if (message.thinking?.content) {
|
||||
<section class="thinking-panel">
|
||||
<button type="button" class="collapse-toggle" (click)="toggleThinking(message.id)">
|
||||
<span>Thinking{{ message.thinking?.active ? '...' : '' }}</span>
|
||||
<span class="thinking-label" [class.active]="message.thinking?.active">
|
||||
@if (message.thinking?.active) {
|
||||
<span class="thinking-pulse" aria-hidden="true"></span>
|
||||
}
|
||||
Thinking{{ message.thinking?.active ? '...' : '' }}
|
||||
</span>
|
||||
<small>{{ thinkingDuration(message) }}</small>
|
||||
</button>
|
||||
@if (thinkingExpanded(message.id)) {
|
||||
|
|
@ -224,7 +245,7 @@ interface ConversationGroup {
|
|||
<span>{{ segment.language || 'text' }}</span>
|
||||
<button type="button" (click)="copyText(segment.content)">Copy</button>
|
||||
</div>
|
||||
<pre><code>{{ segment.content }}</code></pre>
|
||||
<pre><code [innerHTML]="renderCodeBlock(segment)"></code></pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -238,12 +259,25 @@ interface ConversationGroup {
|
|||
(click)="toggleTool(tool.id)"
|
||||
>
|
||||
<span>{{ tool.name }}</span>
|
||||
<small>Tool call</small>
|
||||
<small
|
||||
class="tool-status"
|
||||
[class.pending]="toolStatus(tool) === 'pending'"
|
||||
[class.error]="toolStatus(tool) === 'error'"
|
||||
>
|
||||
@if (toolStatus(tool) === 'pending') {
|
||||
<span class="tool-spinner" aria-hidden="true"></span>
|
||||
}
|
||||
{{ toolStatusLabel(tool) }}
|
||||
</small>
|
||||
</button>
|
||||
@if (toolExpanded(tool.id)) {
|
||||
<div class="tool-body">
|
||||
<pre>{{ tool.arguments | json }}</pre>
|
||||
<p>{{ tool.result }}</p>
|
||||
@if (tool.result) {
|
||||
<p>{{ tool.result }}</p>
|
||||
} @else if (toolStatus(tool) === 'pending') {
|
||||
<p>Waiting for the local MCP tool result...</p>
|
||||
}
|
||||
@if (tool.error) {
|
||||
<strong class="error-text">{{ tool.error }}</strong>
|
||||
}
|
||||
|
|
@ -449,6 +483,10 @@ interface ConversationGroup {
|
|||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.row-icon.danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.model-chip {
|
||||
margin: 1.25rem 0;
|
||||
border-radius: 1rem;
|
||||
|
|
@ -860,6 +898,59 @@ interface ConversationGroup {
|
|||
color: #fda4af;
|
||||
}
|
||||
|
||||
.tool-status,
|
||||
.thinking-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.tool-status.pending,
|
||||
.thinking-label.active {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.tool-status.error {
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.tool-spinner,
|
||||
.thinking-pulse {
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tool-spinner {
|
||||
border: 2px solid rgba(253, 224, 71, 0.26);
|
||||
border-top-color: #facc15;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.thinking-pulse {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 0.8rem rgba(245, 158, 11, 0.45);
|
||||
animation: pulse-dot 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block code .token-keyword,
|
||||
:host ::ng-deep .code-block code .token-boolean {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block code .token-string {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block code .token-comment {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block code .token-number {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.composer-notice {
|
||||
margin: 0 0 0.85rem;
|
||||
color: #fcd34d;
|
||||
|
|
@ -888,6 +979,15 @@ interface ConversationGroup {
|
|||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
|
|
@ -957,13 +1057,25 @@ export class DashboardComponent implements AfterViewChecked {
|
|||
if (!active) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Delete "${active.title}"?`)) {
|
||||
if (!this.confirmDeleteConversation(active.title)) {
|
||||
return;
|
||||
}
|
||||
this.chat.deleteConversation(active.id);
|
||||
this.cancelRename();
|
||||
}
|
||||
|
||||
protected clearActiveConversation(): void {
|
||||
const active = this.chat.activeConversation();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Clear every message from "${active.title}"?`)) {
|
||||
return;
|
||||
}
|
||||
this.chat.clearActiveConversation();
|
||||
this.cancelRename();
|
||||
}
|
||||
|
||||
protected exportActiveConversation(): void {
|
||||
const active = this.chat.activeConversation();
|
||||
if (!active) {
|
||||
|
|
@ -978,6 +1090,15 @@ export class DashboardComponent implements AfterViewChecked {
|
|||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
protected deleteConversation(conversation: Conversation, event: Event): void {
|
||||
event.stopPropagation();
|
||||
if (!this.confirmDeleteConversation(conversation.title)) {
|
||||
return;
|
||||
}
|
||||
this.chat.deleteConversation(conversation.id);
|
||||
this.cancelRename();
|
||||
}
|
||||
|
||||
protected resizeComposer(textarea: HTMLTextAreaElement, reset = false): void {
|
||||
if (reset) {
|
||||
textarea.style.height = 'auto';
|
||||
|
|
@ -1082,6 +1203,10 @@ export class DashboardComponent implements AfterViewChecked {
|
|||
return renderMarkdownContent(content);
|
||||
}
|
||||
|
||||
protected renderCodeBlock(segment: MessageSegment): string {
|
||||
return renderCodeContent(segment.content, segment.language);
|
||||
}
|
||||
|
||||
protected async copyText(value: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
|
@ -1174,7 +1299,7 @@ export class DashboardComponent implements AfterViewChecked {
|
|||
}
|
||||
const start = new Date(thinking.startedAt).getTime();
|
||||
const end = thinking.finishedAt ? new Date(thinking.finishedAt).getTime() : Date.now();
|
||||
return `${Math.max(end - start, 0) / 1000}s`;
|
||||
return `${(Math.max(end - start, 0) / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
protected toggleTool(id: string): void {
|
||||
|
|
@ -1185,6 +1310,31 @@ export class DashboardComponent implements AfterViewChecked {
|
|||
return this.expandedToolIds().has(id);
|
||||
}
|
||||
|
||||
protected toolStatus(tool: ToolInvocation): 'pending' | 'success' | 'error' {
|
||||
if (tool.status) {
|
||||
return tool.status;
|
||||
}
|
||||
if (tool.error) {
|
||||
return 'error';
|
||||
}
|
||||
if (tool.result) {
|
||||
return 'success';
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
protected toolStatusLabel(tool: ToolInvocation): string {
|
||||
const status = this.toolStatus(tool);
|
||||
if (status === 'pending') {
|
||||
return 'Running';
|
||||
}
|
||||
const duration = toolRuntime(tool);
|
||||
if (status === 'error') {
|
||||
return duration ? `Error · ${duration}` : 'Error';
|
||||
}
|
||||
return duration ? `Done · ${duration}` : 'Done';
|
||||
}
|
||||
|
||||
private async attachFiles(files: File[]): Promise<void> {
|
||||
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) {
|
||||
|
|
@ -1210,6 +1360,10 @@ export class DashboardComponent implements AfterViewChecked {
|
|||
this.composerNoticeTimer = null;
|
||||
}, 2600);
|
||||
}
|
||||
|
||||
private confirmDeleteConversation(title: string): boolean {
|
||||
return window.confirm(`Delete "${title}"?`);
|
||||
}
|
||||
}
|
||||
|
||||
function bucketLabel(dateString: string): string {
|
||||
|
|
@ -1330,3 +1484,168 @@ function formatInlineMarkdown(value: string): string {
|
|||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
}
|
||||
|
||||
function renderCodeContent(content: string, language?: string): string {
|
||||
const normalizedLanguage = (language ?? 'text').toLowerCase();
|
||||
const escaped = escapeHtml(content);
|
||||
|
||||
if (normalizedLanguage === 'json') {
|
||||
return highlightJsonCode(escaped);
|
||||
}
|
||||
|
||||
const keywords = languageKeywords(normalizedLanguage);
|
||||
if (keywords.length === 0) {
|
||||
return escaped;
|
||||
}
|
||||
|
||||
return highlightSourceCode(
|
||||
escaped,
|
||||
keywords,
|
||||
normalizedLanguage === 'sh' || normalizedLanguage === 'bash',
|
||||
);
|
||||
}
|
||||
|
||||
function languageKeywords(language: string): string[] {
|
||||
switch (language) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'typescript':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'javascript':
|
||||
return [
|
||||
'async',
|
||||
'await',
|
||||
'break',
|
||||
'case',
|
||||
'catch',
|
||||
'class',
|
||||
'const',
|
||||
'continue',
|
||||
'default',
|
||||
'else',
|
||||
'export',
|
||||
'extends',
|
||||
'false',
|
||||
'finally',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'import',
|
||||
'interface',
|
||||
'let',
|
||||
'new',
|
||||
'null',
|
||||
'return',
|
||||
'static',
|
||||
'switch',
|
||||
'throw',
|
||||
'true',
|
||||
'try',
|
||||
'type',
|
||||
'undefined',
|
||||
];
|
||||
case 'go':
|
||||
return [
|
||||
'break',
|
||||
'case',
|
||||
'chan',
|
||||
'const',
|
||||
'continue',
|
||||
'default',
|
||||
'defer',
|
||||
'else',
|
||||
'fallthrough',
|
||||
'false',
|
||||
'for',
|
||||
'func',
|
||||
'go',
|
||||
'if',
|
||||
'import',
|
||||
'interface',
|
||||
'map',
|
||||
'package',
|
||||
'range',
|
||||
'return',
|
||||
'select',
|
||||
'struct',
|
||||
'switch',
|
||||
'true',
|
||||
'type',
|
||||
'var',
|
||||
];
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
return [
|
||||
'case',
|
||||
'do',
|
||||
'done',
|
||||
'echo',
|
||||
'elif',
|
||||
'else',
|
||||
'esac',
|
||||
'export',
|
||||
'fi',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'in',
|
||||
'local',
|
||||
'return',
|
||||
'then',
|
||||
'while',
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function highlightSourceCode(
|
||||
escapedCode: string,
|
||||
keywords: string[],
|
||||
hashComments: boolean,
|
||||
): string {
|
||||
const stashedTokens: string[] = [];
|
||||
const stash = (source: string, pattern: RegExp, className: string): string =>
|
||||
source.replace(pattern, (match) => {
|
||||
const token = `@@${stashedTokens.length}@@`;
|
||||
stashedTokens.push(`<span class="${className}">${match}</span>`);
|
||||
return token;
|
||||
});
|
||||
|
||||
let highlighted = escapedCode;
|
||||
highlighted = stash(
|
||||
highlighted,
|
||||
/(`[^`]*`|"(?:\\.|[\s\S])*?"|'(?:\\.|[\s\S])*?')/g,
|
||||
'token-string',
|
||||
);
|
||||
highlighted = stash(
|
||||
highlighted,
|
||||
hashComments ? /(\/\/.*$|#.*$)/gm : /\/\/.*$/gm,
|
||||
'token-comment',
|
||||
);
|
||||
highlighted = highlighted.replace(
|
||||
new RegExp(`\\b(${keywords.join('|')})\\b`, 'g'),
|
||||
'<span class="token-keyword">$1</span>',
|
||||
);
|
||||
highlighted = highlighted.replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="token-number">$1</span>');
|
||||
|
||||
return highlighted.replace(/@@(\d+)@@/g, (_, index) => stashedTokens[Number(index)] ?? '');
|
||||
}
|
||||
|
||||
function highlightJsonCode(escapedCode: string): string {
|
||||
return escapedCode
|
||||
.replace(/(".*?")(?=\s*:)/g, '<span class="token-keyword">$1</span>')
|
||||
.replace(/(:\s*)(".*?")/g, '$1<span class="token-string">$2</span>')
|
||||
.replace(/\b(true|false|null)\b/g, '<span class="token-boolean">$1</span>')
|
||||
.replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="token-number">$1</span>');
|
||||
}
|
||||
|
||||
function toolRuntime(tool: ToolInvocation): string {
|
||||
if (!tool.startedAt || !tool.endedAt) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const duration = Math.max(new Date(tool.endedAt).getTime() - new Date(tool.startedAt).getTime(), 0);
|
||||
return `${(duration / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ interface NavItem {
|
|||
export class ApplicationFrameComponent {
|
||||
readonly sidebarOpen = signal(false);
|
||||
readonly userMenuOpen = signal(false);
|
||||
protected readonly version = 'v0.1.0';
|
||||
|
||||
private readonly uiState = inject(UiStateService);
|
||||
protected readonly t = inject(TranslationService);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ export interface ToolInvocation {
|
|||
arguments: Record<string, unknown>;
|
||||
result: string;
|
||||
error?: string;
|
||||
status?: 'pending' | 'success' | 'error';
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
|
@ -275,6 +278,7 @@ export class ChatService {
|
|||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const toolCalls = inferToolCalls(content);
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
|
|
@ -294,7 +298,7 @@ export class ChatService {
|
|||
content: `Inspecting the request through ${this.selectedModelState()}...`,
|
||||
startedAt: now,
|
||||
},
|
||||
toolCalls: inferToolCalls(content),
|
||||
toolCalls,
|
||||
};
|
||||
|
||||
const title = conversation.messages.length === 0 ? deriveTitle(content) : conversation.title;
|
||||
|
|
@ -311,40 +315,77 @@ export class ChatService {
|
|||
this.queuedAttachmentsState.set([]);
|
||||
this.busyState.set(true);
|
||||
|
||||
const response = await generateAssistantResponse(
|
||||
content,
|
||||
this.selectedModelState(),
|
||||
this.settingsState(),
|
||||
);
|
||||
await streamIntoMessage(response, (fragment, done) => {
|
||||
this.updateMessage(updatedConversation.id, assistantMessage.id, (message) => ({
|
||||
...message,
|
||||
content: fragment,
|
||||
streaming: !done,
|
||||
thinking: message.thinking
|
||||
? {
|
||||
...message.thinking,
|
||||
active: !done,
|
||||
finishedAt: done ? new Date().toISOString() : undefined,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
if (done) {
|
||||
this.busyState.set(false);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const toolOutputs = await this.runToolCalls(updatedConversation.id, assistantMessage.id, content);
|
||||
const response = await generateAssistantResponse(
|
||||
content,
|
||||
this.selectedModelState(),
|
||||
this.settingsState(),
|
||||
toolOutputs,
|
||||
);
|
||||
await streamIntoMessage(response, (fragment, done) => {
|
||||
this.updateMessage(updatedConversation.id, assistantMessage.id, (message) => ({
|
||||
...message,
|
||||
content: fragment,
|
||||
streaming: !done,
|
||||
thinking: message.thinking
|
||||
? {
|
||||
...message.thinking,
|
||||
active: !done,
|
||||
finishedAt: done ? new Date().toISOString() : undefined,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
});
|
||||
} finally {
|
||||
this.busyState.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
exportConversation(conversation: Conversation): string {
|
||||
return conversation.messages
|
||||
const body = conversation.messages
|
||||
.map((message) => {
|
||||
const heading = message.role === 'user' ? '## User' : '## Assistant';
|
||||
const attachments = (message.attachments ?? [])
|
||||
.map((attachment) => `- ${attachment.filename} (${attachment.mimeType})`)
|
||||
.join('\n');
|
||||
return [heading, message.content, attachments].filter(Boolean).join('\n\n');
|
||||
const thinking = message.thinking?.content
|
||||
? ['### Thinking', message.thinking.content].join('\n\n')
|
||||
: '';
|
||||
const toolCalls = (message.toolCalls ?? [])
|
||||
.map((toolCall) =>
|
||||
[
|
||||
`#### ${toolCall.name}`,
|
||||
'```json',
|
||||
JSON.stringify(toolCall.arguments, null, 2),
|
||||
'```',
|
||||
toolCall.result,
|
||||
toolCall.error ? `Error: ${toolCall.error}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
)
|
||||
.join('\n\n');
|
||||
return [
|
||||
heading,
|
||||
message.content,
|
||||
attachments ? `### Attachments\n${attachments}` : '',
|
||||
thinking,
|
||||
toolCalls ? `### Tool Calls\n\n${toolCalls}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
})
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
return [
|
||||
`# ${conversation.title}`,
|
||||
`- Conversation ID: ${conversation.id}`,
|
||||
`- Model: ${conversation.model}`,
|
||||
`- Updated: ${conversation.updatedAt}`,
|
||||
'',
|
||||
body,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private hydrate(): void {
|
||||
|
|
@ -360,7 +401,9 @@ export class ChatService {
|
|||
models?: ModelEntry[];
|
||||
settings?: ChatSettings;
|
||||
};
|
||||
this.conversationsState.set(parsed.conversations ?? []);
|
||||
this.conversationsState.set(
|
||||
(parsed.conversations ?? []).map((conversation) => this.normaliseConversation(conversation)),
|
||||
);
|
||||
this.activeConversationIdState.set(parsed.activeConversationId ?? '');
|
||||
this.selectedModelState.set(parsed.selectedModel ?? 'lemer');
|
||||
this.modelsState.set(parsed.models ?? defaultModels());
|
||||
|
|
@ -386,7 +429,7 @@ export class ChatService {
|
|||
private replaceConversation(conversation: Conversation): void {
|
||||
this.conversationsState.update((items) =>
|
||||
items
|
||||
.map((item) => (item.id === conversation.id ? conversation : item))
|
||||
.map((item) => (item.id === conversation.id ? this.normaliseConversation(conversation) : item))
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
|
||||
);
|
||||
this.persist();
|
||||
|
|
@ -414,6 +457,92 @@ export class ChatService {
|
|||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private normaliseConversation(conversation: Conversation): Conversation {
|
||||
return {
|
||||
...conversation,
|
||||
messages: conversation.messages.map((message) => ({
|
||||
...message,
|
||||
toolCalls: (message.toolCalls ?? []).map((toolCall) => ({
|
||||
...toolCall,
|
||||
status: toolCall.status ?? (toolCall.error ? 'error' : toolCall.result ? 'success' : 'pending'),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async runToolCalls(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
prompt: string,
|
||||
): Promise<string[]> {
|
||||
const conversation = this.conversationsState().find((item) => item.id === conversationId);
|
||||
const message = conversation?.messages.find((item) => item.id === messageId);
|
||||
const toolCalls = message?.toolCalls ?? [];
|
||||
if (toolCalls.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const outputs: string[] = [];
|
||||
for (const toolCall of toolCalls) {
|
||||
await pause(140);
|
||||
try {
|
||||
const result = this.executeToolCall(toolCall, prompt);
|
||||
outputs.push(`${toolCall.name}: ${result}`);
|
||||
this.updateToolCall(conversationId, messageId, toolCall.id, {
|
||||
result,
|
||||
status: 'success',
|
||||
endedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : 'Tool execution failed.';
|
||||
outputs.push(`${toolCall.name}: ${messageText}`);
|
||||
this.updateToolCall(conversationId, messageId, toolCall.id, {
|
||||
error: messageText,
|
||||
status: 'error',
|
||||
endedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return outputs;
|
||||
}
|
||||
|
||||
private updateToolCall(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
toolId: string,
|
||||
patch: Partial<ToolInvocation>,
|
||||
): void {
|
||||
this.updateMessage(conversationId, messageId, (message) => ({
|
||||
...message,
|
||||
toolCalls: (message.toolCalls ?? []).map((toolCall) =>
|
||||
toolCall.id === toolId ? { ...toolCall, ...patch } : toolCall,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private executeToolCall(toolCall: ToolInvocation, prompt: string): string {
|
||||
if (/\b(tool error|fail tool|tool failure)\b/i.test(prompt)) {
|
||||
throw new Error('Simulated MCP tool failure for the RFC chat shell.');
|
||||
}
|
||||
|
||||
switch (toolCall.name) {
|
||||
case 'gui.chat.settings.load':
|
||||
return describeSettings(this.settingsState());
|
||||
case 'gui.chat.models':
|
||||
return describeModels(this.modelsState(), this.selectedModelState());
|
||||
case 'gui.chat.conversations.search':
|
||||
return describeConversationMatches(
|
||||
this.conversationsState(),
|
||||
String(toolCall.arguments['q'] ?? ''),
|
||||
);
|
||||
case 'gui.route.store':
|
||||
return describeStoreMatches(this.conversationsState(), String(toolCall.arguments['q'] ?? ''));
|
||||
default:
|
||||
throw new Error(`Tool not registered in the chat shell: ${toolCall.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deriveTitle(content: string): string {
|
||||
|
|
@ -421,33 +550,54 @@ function deriveTitle(content: string): string {
|
|||
if (!trimmed) {
|
||||
return 'New conversation';
|
||||
}
|
||||
return trimmed.length > 52 ? `${trimmed.slice(0, 52).trim()}...` : trimmed;
|
||||
return trimmed.length > 50 ? `${trimmed.slice(0, 50).trim()}...` : trimmed;
|
||||
}
|
||||
|
||||
function inferToolCalls(content: string): ToolInvocation[] {
|
||||
const normalized = content.toLowerCase();
|
||||
if (!normalized.includes('tool')) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
const query = extractSearchQuery(content);
|
||||
const toolCalls: ToolInvocation[] = [];
|
||||
const push = (name: string, args: Record<string, unknown>) => {
|
||||
if (toolCalls.some((toolCall) => toolCall.name === name)) {
|
||||
return;
|
||||
}
|
||||
toolCalls.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: 'gui.store.search',
|
||||
arguments: { q: content.slice(0, 40) },
|
||||
result: 'Local tool manifest execution is simulated in the chat shell.',
|
||||
},
|
||||
];
|
||||
name,
|
||||
arguments: args,
|
||||
result: '',
|
||||
status: 'pending',
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
if (/\b(setting|temperature|context|prompt)\b/.test(normalized)) {
|
||||
push('gui.chat.settings.load', {});
|
||||
}
|
||||
if (/\bmodel|models\b/.test(normalized)) {
|
||||
push('gui.chat.models', {});
|
||||
}
|
||||
if (/\b(history|conversation|conversations|find|search)\b/.test(normalized)) {
|
||||
push('gui.chat.conversations.search', { q: query });
|
||||
}
|
||||
if (/\b(store|storage|cache|local data|tool)\b/.test(normalized)) {
|
||||
push('gui.route.store', { q: query });
|
||||
}
|
||||
|
||||
return toolCalls.slice(0, 3);
|
||||
}
|
||||
|
||||
async function generateAssistantResponse(
|
||||
content: string,
|
||||
model: string,
|
||||
settings: ChatSettings,
|
||||
toolOutputs: string[] = [],
|
||||
): Promise<string> {
|
||||
const prompt = content.trim() || 'your multimodal prompt';
|
||||
const promptWithTools = buildToolAwarePrompt(prompt, toolOutputs);
|
||||
try {
|
||||
if (typeof window.core?.ml?.generate === 'function') {
|
||||
const generated = await window.core.ml.generate(prompt);
|
||||
const generated = await window.core.ml.generate(promptWithTools);
|
||||
if (generated.trim()) {
|
||||
return generated;
|
||||
}
|
||||
|
|
@ -455,16 +605,26 @@ async function generateAssistantResponse(
|
|||
} catch {
|
||||
// Fall back to the local RFC demo response below.
|
||||
}
|
||||
return buildFallbackResponse(prompt, model, settings);
|
||||
return buildFallbackResponse(prompt, model, settings, toolOutputs);
|
||||
}
|
||||
|
||||
function buildFallbackResponse(prompt: string, model: string, settings: ChatSettings): string {
|
||||
function buildFallbackResponse(
|
||||
prompt: string,
|
||||
model: string,
|
||||
settings: ChatSettings,
|
||||
toolOutputs: string[],
|
||||
): string {
|
||||
const toolSection =
|
||||
toolOutputs.length > 0
|
||||
? ['', 'Tool results:', ...toolOutputs.map((output) => `- ${output}`), '']
|
||||
: [];
|
||||
return [
|
||||
`Using ${model} with temperature ${settings.temperature.toFixed(1)}.`,
|
||||
'',
|
||||
`I stored this exchange locally and can keep the conversation context across sessions.`,
|
||||
'',
|
||||
`Prompt summary: ${prompt}`,
|
||||
...toolSection,
|
||||
'',
|
||||
'```ts',
|
||||
`const response = await window.core.ml.generate(${JSON.stringify(prompt)});`,
|
||||
|
|
@ -488,6 +648,103 @@ async function streamIntoMessage(
|
|||
onUpdate(assembled, true);
|
||||
}
|
||||
|
||||
function pause(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function extractSearchQuery(content: string): string {
|
||||
const quoted = content.match(/"([^"]+)"/);
|
||||
if (quoted?.[1]) {
|
||||
return quoted[1];
|
||||
}
|
||||
|
||||
const afterFor = content.match(/\b(?:for|about|named)\s+(.+)/i);
|
||||
if (afterFor?.[1]) {
|
||||
return afterFor[1].trim().slice(0, 48);
|
||||
}
|
||||
|
||||
return content.trim().slice(0, 48);
|
||||
}
|
||||
|
||||
function describeSettings(settings: ChatSettings): string {
|
||||
return [
|
||||
`temperature=${settings.temperature.toFixed(1)}`,
|
||||
`topP=${settings.topP.toFixed(2)}`,
|
||||
`topK=${settings.topK}`,
|
||||
`maxTokens=${settings.maxTokens}`,
|
||||
`contextWindow=${settings.contextWindow}`,
|
||||
`defaultModel=${settings.defaultModel}`,
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
function describeModels(models: ModelEntry[], selectedModel: string): string {
|
||||
const summary = models
|
||||
.map((model) => {
|
||||
const marker = model.name === selectedModel ? '[selected]' : '[available]';
|
||||
const vision = model.supportsVision === false ? 'text-only' : 'vision';
|
||||
return `${marker} ${model.name} (${model.architecture}, ${vision}, ${model.backend})`;
|
||||
})
|
||||
.join('; ');
|
||||
return summary || 'No local models are currently listed.';
|
||||
}
|
||||
|
||||
function describeConversationMatches(conversations: Conversation[], query: string): string {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const matches = conversations.filter((conversation) => {
|
||||
if (!needle) {
|
||||
return true;
|
||||
}
|
||||
const haystack = `${conversation.title} ${conversation.messages.map((message) => message.content).join(' ')}`.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
return `No conversations matched "${query.trim() || 'the current query'}".`;
|
||||
}
|
||||
|
||||
return `Found ${matches.length} conversation(s): ${matches
|
||||
.slice(0, 3)
|
||||
.map((conversation) => `"${conversation.title}"`)
|
||||
.join(', ')}.`;
|
||||
}
|
||||
|
||||
function describeStoreMatches(conversations: Conversation[], query: string): string {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const snippets = conversations.flatMap((conversation) =>
|
||||
conversation.messages.flatMap((message) => {
|
||||
const attachments = (message.attachments ?? []).map((attachment) => attachment.filename);
|
||||
const haystack = `${conversation.title} ${message.content} ${attachments.join(' ')}`.toLowerCase();
|
||||
if (needle && !haystack.includes(needle)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
`${conversation.title}: ${message.content.trim().slice(0, 80) || '[attachment only]'}${
|
||||
attachments.length > 0 ? ` (attachments: ${attachments.join(', ')})` : ''
|
||||
}`,
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
if (snippets.length === 0) {
|
||||
return `The local store search found no matches for "${query.trim() || 'the current query'}".`;
|
||||
}
|
||||
|
||||
return `Store hits (${snippets.length}): ${snippets.slice(0, 3).join(' | ')}.`;
|
||||
}
|
||||
|
||||
function buildToolAwarePrompt(prompt: string, toolOutputs: string[]): string {
|
||||
if (toolOutputs.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
return [
|
||||
prompt,
|
||||
'',
|
||||
'Tool context:',
|
||||
...toolOutputs.map((output) => `- ${output}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function readAsDataURL(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue