Implement chat UX gaps and container detection

This commit is contained in:
Claude 2026-04-14 15:47:07 +01:00
parent a85d086bbd
commit c8490ccf8d
5 changed files with 811 additions and 47 deletions

101
pkg/container/detect.go Normal file
View 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))
}

View 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(""))
}

View file

@ -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,
/(`[^`]*`|&quot;(?:\\.|[\s\S])*?&quot;|&#39;(?:\\.|[\s\S])*?&#39;)/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(/(&quot;.*?&quot;)(?=\s*:)/g, '<span class="token-keyword">$1</span>')
.replace(/(:\s*)(&quot;.*?&quot;)/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`;
}

View file

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

View file

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