From c8490ccf8da7181171c22f650e806160afb8d3f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 15:47:07 +0100 Subject: [PATCH] Implement chat UX gaps and container detection --- pkg/container/detect.go | 101 ++++++ pkg/container/detect_test.go | 86 +++++ ui/src/app/dashboard.component.ts | 331 ++++++++++++++++++- ui/src/frame/application-frame.component.ts | 1 + ui/src/services/chat.service.ts | 339 +++++++++++++++++--- 5 files changed, 811 insertions(+), 47 deletions(-) create mode 100644 pkg/container/detect.go create mode 100644 pkg/container/detect_test.go diff --git a/pkg/container/detect.go b/pkg/container/detect.go new file mode 100644 index 00000000..e6a96bd1 --- /dev/null +++ b/pkg/container/detect.go @@ -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)) +} diff --git a/pkg/container/detect_test.go b/pkg/container/detect_test.go new file mode 100644 index 00000000..cc4a2b7a --- /dev/null +++ b/pkg/container/detect_test.go @@ -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("")) +} diff --git a/ui/src/app/dashboard.component.ts b/ui/src/app/dashboard.component.ts index 283a07d8..27eda923 100644 --- a/ui/src/app/dashboard.component.ts +++ b/ui/src/app/dashboard.component.ts @@ -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 + } @@ -154,6 +162,14 @@ interface ConversationGroup { > Export + @if (thinkingExpanded(message.id)) { @@ -224,7 +245,7 @@ interface ConversationGroup { {{ segment.language || 'text' }} -
{{ segment.content }}
+
} } @@ -238,12 +259,25 @@ interface ConversationGroup { (click)="toggleTool(tool.id)" > {{ tool.name }} - Tool call + + @if (toolStatus(tool) === 'pending') { + + } + {{ toolStatusLabel(tool) }} + @if (toolExpanded(tool.id)) {
{{ tool.arguments | json }}
-

{{ tool.result }}

+ @if (tool.result) { +

{{ tool.result }}

+ } @else if (toolStatus(tool) === 'pending') { +

Waiting for the local MCP tool result...

+ } @if (tool.error) { {{ tool.error }} } @@ -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 { 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 { 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, '$1') .replace(/`([^`]+)`/g, '$1'); } + +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(`${match}`); + 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'), + '$1', + ); + highlighted = highlighted.replace(/\b(\d+(?:\.\d+)?)\b/g, '$1'); + + return highlighted.replace(/@@(\d+)@@/g, (_, index) => stashedTokens[Number(index)] ?? ''); +} + +function highlightJsonCode(escapedCode: string): string { + return escapedCode + .replace(/(".*?")(?=\s*:)/g, '$1') + .replace(/(:\s*)(".*?")/g, '$1$2') + .replace(/\b(true|false|null)\b/g, '$1') + .replace(/\b(\d+(?:\.\d+)?)\b/g, '$1'); +} + +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`; +} diff --git a/ui/src/frame/application-frame.component.ts b/ui/src/frame/application-frame.component.ts index c13c6792..54664eff 100644 --- a/ui/src/frame/application-frame.component.ts +++ b/ui/src/frame/application-frame.component.ts @@ -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); diff --git a/ui/src/services/chat.service.ts b/ui/src/services/chat.service.ts index 9ec70499..09c70d5e 100644 --- a/ui/src/services/chat.service.ts +++ b/ui/src/services/chat.service.ts @@ -42,6 +42,9 @@ export interface ToolInvocation { arguments: Record; 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 { + 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, + ): 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) => { + 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 { 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 { + 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 { return new Promise((resolve, reject) => { const reader = new FileReader();