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 (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();