diff --git a/tools/server/webui/package-lock.json b/tools/server/webui/package-lock.json index 4f37b308b1..423dea6d28 100644 --- a/tools/server/webui/package-lock.json +++ b/tools/server/webui/package-lock.json @@ -39,6 +39,7 @@ "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.0.0", + "@testing-library/svelte": "^5.2.9", "@types/node": "^22", "@vitest/browser": "^3.2.3", "bits-ui": "^2.14.4", @@ -2624,6 +2625,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/svelte": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.9.tgz", + "integrity": "sha512-p0Lg/vL1iEsEasXKSipvW9nBCtItQGhYvxL8OZ4w7/IDdC+LGoSJw4mMS5bndVFON/gWryitEhMr29AlO4FvBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "14.6.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", diff --git a/tools/server/webui/package.json b/tools/server/webui/package.json index 1c970ae7a8..31393aaa92 100644 --- a/tools/server/webui/package.json +++ b/tools/server/webui/package.json @@ -40,6 +40,7 @@ "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.0.0", + "@testing-library/svelte": "^5.2.9", "@types/node": "^22", "@vitest/browser": "^3.2.3", "bits-ui": "^2.14.4", diff --git a/tools/server/webui/playwright.config.ts b/tools/server/webui/playwright.config.ts index 26d3be535d..9035d0e3bb 100644 --- a/tools/server/webui/playwright.config.ts +++ b/tools/server/webui/playwright.config.ts @@ -2,7 +2,8 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ webServer: { - command: 'npm run build && http-server ../public -p 8181', + command: + 'npm run build && gzip -dc ../public/index.html.gz > ../public/index.html && http-server ../public -p 8181', port: 8181, timeout: 120000, reuseExistingServer: false diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte index 0969a937ed..7597b20c7c 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte @@ -5,6 +5,7 @@ import ChatMessageAssistant from './ChatMessageAssistant.svelte'; import ChatMessageUser from './ChatMessageUser.svelte'; import ChatMessageSystem from './ChatMessageSystem.svelte'; + import { conversationsStore } from '$lib/stores/conversations.svelte'; interface Props { class?: string; @@ -22,6 +23,7 @@ onNavigateToSibling?: (siblingId: string) => void; onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void; siblingInfo?: ChatMessageSiblingInfo | null; + toolParentIds?: string[]; } let { @@ -35,9 +37,21 @@ onEditUserMessagePreserveResponses, onNavigateToSibling, onRegenerateWithBranching, - siblingInfo = null + siblingInfo = null, + toolParentIds }: Props = $props(); + type MessageWithToolExtras = DatabaseMessage & { + _actionTargetId?: string; + _toolMessagesCollected?: { toolCallId?: string | null; parsed: unknown }[]; + }; + + const actionTargetId = $derived((message as MessageWithToolExtras)._actionTargetId ?? message.id); + + function getActionTarget(): DatabaseMessage { + return conversationsStore.activeMessages.find((m) => m.id === actionTargetId) ?? message; + } + let deletionInfo = $state<{ totalCount: number; userMessages: number; @@ -95,12 +109,14 @@ } function handleConfirmDelete() { - onDelete?.(message); + const target = getActionTarget(); + onDelete?.(target); showDeleteDialog = false; } async function handleDelete() { - deletionInfo = await chatStore.getDeletionInfo(message.id); + const target = getActionTarget(); + deletionInfo = await chatStore.getDeletionInfo(target.id); showDeleteDialog = true; } @@ -136,11 +152,13 @@ } function handleRegenerate(modelOverride?: string) { - onRegenerateWithBranching?.(message, modelOverride); + const target = getActionTarget(); + onRegenerateWithBranching?.(target, modelOverride); } function handleContinue() { - onContinueAssistantMessage?.(message); + const target = getActionTarget(); + onContinueAssistantMessage?.(target); } function handleSaveEdit() { @@ -213,7 +231,7 @@ {showDeleteDialog} {siblingInfo} /> -{:else} +{:else if message.role === 'assistant'} +{:else if message.role === 'tool'} + + {/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte index 2c9a012eff..340c4e8fe2 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte @@ -4,16 +4,17 @@ ChatMessageActions, ChatMessageStatistics, ChatMessageThinkingBlock, - CopyToClipboardIcon, MarkdownContent, - ModelsSelector + ModelsSelector, + BadgeChatStatistic } from '$lib/components/app'; + import type { DatabaseMessage, ApiChatCompletionToolCall } from '$lib/types'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte'; import { isLoading } from '$lib/stores/chat.svelte'; import { autoResizeTextarea, copyToClipboard } from '$lib/utils'; import { fade } from 'svelte/transition'; - import { Check, X, Wrench } from '@lucide/svelte'; + import { Check, Clock, X, Wrench } from '@lucide/svelte'; import { Button } from '$lib/components/ui/button'; import { Checkbox } from '$lib/components/ui/checkbox'; import { INPUT_CLASSES } from '$lib/constants/input-classes'; @@ -21,6 +22,24 @@ import { config } from '$lib/stores/settings.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte'; import { isRouterMode } from '$lib/stores/server.svelte'; + import { SvelteSet } from 'svelte/reactivity'; + + type ToolSegment = + | { kind: 'thinking'; content: string } + | { kind: 'tool'; toolCalls: ApiChatCompletionToolCall[]; parentId: string }; + type ToolParsed = { expression?: string; result?: string; duration_ms?: number }; + type CollectedToolMessage = { + toolCallId?: string | null; + parsed: ToolParsed; + }; + type MessageWithToolExtras = DatabaseMessage & { + _segments?: ToolSegment[]; + _toolMessagesCollected?: CollectedToolMessage[]; + }; + type ToolMessageLike = Pick & { + toolCallId?: string | null; + parent?: string; + }; interface Props { class?: string; @@ -53,6 +72,9 @@ textareaElement?: HTMLTextAreaElement; thinkingContent: string | null; toolCallContent: ApiChatCompletionToolCall[] | string | null; + toolParentIds?: string[]; + segments?: ToolSegment[] | null; + toolMessagesCollected?: CollectedToolMessage[] | null; } let { @@ -80,17 +102,86 @@ siblingInfo = null, textareaElement = $bindable(), thinkingContent, - toolCallContent = null + toolCallContent = null, + toolParentIds = [message.id], + segments: segmentsProp = null, + toolMessagesCollected: toolMessagesCollectedProp = (message as MessageWithToolExtras) + ._toolMessagesCollected ?? null }: Props = $props(); + // Keep segments/tool messages in sync with the merged assistant produced upstream. + let segments = $derived(segmentsProp ?? (message as MessageWithToolExtras)._segments ?? null); + let toolMessagesCollected = $derived( + toolMessagesCollectedProp ?? (message as MessageWithToolExtras)._toolMessagesCollected ?? null + ); + const toolCalls = $derived( Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null ); - const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null); const processingState = useProcessingState(); let currentConfig = $derived(config()); let isRouter = $derived(isRouterMode()); + const toolMessages = $derived( + (() => { + const ids = new SvelteSet(); + if (toolCalls) { + for (const tc of toolCalls) { + if (tc.id) ids.add(tc.id); + } + } + const collected = toolMessagesCollected ?? []; + return conversationsStore.activeMessages + .filter( + (m) => + m.role === 'tool' && + (toolParentIds.includes(m.parent) || (m.toolCallId && ids.has(m.toolCallId))) + ) + .concat( + collected.map((c) => ({ + role: 'tool', + content: JSON.stringify(c.parsed), + toolCallId: c.toolCallId ?? undefined, + parent: toolParentIds[0] + })) + ); + })() + ); + const toolMessagesById = $derived>( + (() => { + const map: Record = {}; + for (const t of toolMessages) { + const parsed = parseToolMessage(t); + if (parsed && t.toolCallId) { + map[t.toolCallId] = parsed; + } + } + return map; + })() + ); + + const collectedById = $derived>( + (() => { + const map: Record = {}; + (toolMessagesCollected ?? []).forEach((c) => { + if (c.toolCallId) { + map[c.toolCallId] = c.parsed; + } + }); + return map; + })() + ); + + function getToolResult(toolCall: ApiChatCompletionToolCall): ToolParsed | null { + const idSetMatch = toolCall.id ? toolMessagesById[toolCall.id] : null; + if (idSetMatch) return idSetMatch; + if (toolCall.id && collectedById[toolCall.id]) return collectedById[toolCall.id]; + return null; + } + + function advanceToolResult(toolCall: ApiChatCompletionToolCall) { + return getToolResult(toolCall) ?? null; + } let displayedModel = $derived((): string | null => { if (message.model) { return message.model; @@ -116,56 +207,63 @@ } }); - function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) { - const callNumber = index + 1; - const functionName = toolCall.function?.name?.trim(); - const label = functionName || `Call #${callNumber}`; - - const payload: Record = {}; - - const id = toolCall.id?.trim(); - if (id) { - payload.id = id; - } - - const type = toolCall.type?.trim(); - if (type) { - payload.type = type; - } - - if (toolCall.function) { - const fnPayload: Record = {}; - - const name = toolCall.function.name?.trim(); - if (name) { - fnPayload.name = name; - } - - const rawArguments = toolCall.function.arguments?.trim(); - if (rawArguments) { - try { - fnPayload.arguments = JSON.parse(rawArguments); - } catch { - fnPayload.arguments = rawArguments; - } - } - - if (Object.keys(fnPayload).length > 0) { - payload.function = fnPayload; + function parseArguments( + toolCall: ApiChatCompletionToolCall + ): { pairs: { key: string; value: string }[] } | { raw: string } | null { + const rawArguments = toolCall.function?.arguments?.trim(); + if (!rawArguments) return null; + try { + const parsed = JSON.parse(rawArguments); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const pairs = Object.entries(parsed).map(([key, value]) => ({ + key, + value: typeof value === 'string' ? value : JSON.stringify(value, null, 2) + })); + return { pairs }; } + } catch { + // ignore parse errors, fall back to raw } - - const formattedPayload = JSON.stringify(payload, null, 2); - - return { - label, - tooltip: formattedPayload, - copyValue: formattedPayload - }; + return { raw: rawArguments }; } - function handleCopyToolCall(payload: string) { - void copyToClipboard(payload, 'Tool call copied to clipboard'); + function parseToolMessage(msg: ToolMessageLike): ToolParsed | null { + if (!msg.content) return null; + try { + const parsed = JSON.parse(msg.content); + if (parsed && typeof parsed === 'object') { + const duration = + typeof parsed.duration_ms === 'number' ? (parsed.duration_ms as number) : undefined; + return { + expression: parsed.expression ?? undefined, + result: parsed.result ?? undefined, + duration_ms: duration + }; + } + } catch { + // not JSON; fall back + } + return { result: msg.content }; + } + + function formatDurationSeconds(durationMs?: number): string | null { + if (durationMs === undefined) return null; + if (!Number.isFinite(durationMs)) return null; + return `${(durationMs / 1000).toFixed(2)}s`; + } + + function toFencedCodeBlock(code: string, language: string): string { + const matches = code.match(/`+/g) ?? []; + const maxBackticks = matches.reduce((max, s) => Math.max(max, s.length), 0); + const fence = '`'.repeat(Math.max(3, maxBackticks + 1)); + return `${fence}${language}\n${code}\n${fence}`; + } + + function getToolLabel(toolCall: ApiChatCompletionToolCall, index: number) { + const name = toolCall.function?.name ?? ''; + if (name === 'calculator') return 'Calculator'; + if (name === 'code_interpreter_javascript') return 'Code Interpreter (JavaScript)'; + return name || `Call #${index + 1}`; } @@ -176,10 +274,153 @@ > {#if thinkingContent} + > + {#if segments && segments.length} + {#each segments as segment, segIndex (segIndex)} + {#if segment.kind === 'thinking'} +
+ {segment.content} +
+ {:else if segment.kind === 'tool'} + {#each segment.toolCalls as toolCall, index (toolCall.id ?? `${segIndex}-${index}`)} + {@const argsParsed = parseArguments(toolCall)} + {@const parsed = advanceToolResult(toolCall)} + {@const collectedResult = toolMessagesCollected + ? toolMessagesCollected.find((c) => c.toolCallId === toolCall.id)?.parsed?.result + : undefined} + {@const collectedDurationMs = toolMessagesCollected + ? toolMessagesCollected.find((c) => c.toolCallId === toolCall.id)?.parsed + ?.duration_ms + : undefined} + {@const durationMs = parsed?.duration_ms ?? collectedDurationMs} + {@const durationText = formatDurationSeconds(durationMs)} +
+
+
+ + {getToolLabel(toolCall, index)} +
+ {#if durationText} + + {/if} +
+ {#if argsParsed} +
Arguments
+ {#if 'pairs' in argsParsed} + {#each argsParsed.pairs as pair (pair.key)} +
+
{pair.key}
+ {#if pair.key === 'code' && toolCall.function?.name === 'code_interpreter_javascript'} + + {:else} +
+{pair.value}
+													
+ {/if} +
+ {/each} + {:else} +
+{argsParsed.raw}
+										
+ {/if} + {/if} + {#if parsed && parsed.result !== undefined} +
Result
+
+ {parsed.result} +
+ {:else if collectedResult !== undefined} +
Result
+
+ {collectedResult} +
+ {/if} +
+ {/each} + {/if} + {/each} + {/if} +
+ {/if} + + {#if !thinkingContent && segments && segments.length} + {#each segments as segment, segIndex (segIndex)} + {#if segment.kind === 'tool'} + {#each segment.toolCalls as toolCall, index (toolCall.id ?? `${segIndex}-${index}`)} + {@const argsParsed = parseArguments(toolCall)} + {@const parsed = advanceToolResult(toolCall)} + {@const collectedResult = toolMessagesCollected + ? toolMessagesCollected.find((c) => c.toolCallId === toolCall.id)?.parsed?.result + : undefined} + {@const collectedDurationMs = toolMessagesCollected + ? toolMessagesCollected.find((c) => c.toolCallId === toolCall.id)?.parsed?.duration_ms + : undefined} + {@const durationMs = parsed?.duration_ms ?? collectedDurationMs} + {@const durationText = formatDurationSeconds(durationMs)} +
+
+
+ + {getToolLabel(toolCall, index)} +
+ {#if durationText} + + {/if} +
+ {#if argsParsed} +
Arguments
+ {#if 'pairs' in argsParsed} + {#each argsParsed.pairs as pair (pair.key)} +
+
{pair.key}
+ {#if pair.key === 'code' && toolCall.function?.name === 'code_interpreter_javascript'} + + {:else} +
+{pair.value}
+											
+ {/if} +
+ {/each} + {:else} +
+{argsParsed.raw}
+								
+ {/if} + {/if} + {#if parsed && parsed.result !== undefined} +
Result
+
+ {parsed.result} +
+ {:else if collectedResult !== undefined} +
Result
+
+ {collectedResult} +
+ {/if} +
+ {/each} + {/if} + {/each} {/if} {#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()} @@ -232,9 +473,9 @@ {:else if message.role === 'assistant'} {#if config().disableReasoningFormat} -
{messageContent || ''}
+
{messageContent}
{:else} - + {/if} {:else}
@@ -264,48 +505,6 @@ {/if} {/if} - - {#if config().showToolCalls} - {#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls} - - - - - Tool calls: - - - {#if toolCalls && toolCalls.length > 0} - {#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)} - {@const badge = formatToolCallBadge(toolCall, index)} - - {/each} - {:else if fallbackToolCalls} - - {/if} - - {/if} - {/if}
{#if message.timestamp && !isEditing} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte index 9245ad5153..cd7ad69769 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte @@ -22,18 +22,31 @@ const currentConfig = config(); - let isExpanded = $state(currentConfig.showThoughtInProgress); + // Expand automatically only while streaming when "Show thought in progress" is enabled. + const initialAutoExpand = isStreaming && currentConfig.showThoughtInProgress; + let isExpanded = $state(initialAutoExpand); + let autoExpanded = $state(initialAutoExpand); $effect(() => { - if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) { + if (isStreaming && currentConfig.showThoughtInProgress && !isExpanded && !autoExpanded) { + isExpanded = true; + autoExpanded = true; + } else if (!isStreaming && autoExpanded) { + // Only collapse if this session auto-opened it; user manual toggles stay respected. isExpanded = false; + autoExpanded = false; } }); - + - + { + autoExpanded = false; // user choice overrides auto behavior + }} + >
@@ -59,7 +72,9 @@
- {reasoningContent ?? ''} + + {reasoningContent ?? ''} +
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageTool.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageTool.svelte new file mode 100644 index 0000000000..c2e3ec44c7 --- /dev/null +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageTool.svelte @@ -0,0 +1,44 @@ + + +
+
Tool • Calculator
+ {#if parsed && typeof parsed === 'object'} + {#if parsed.expression} +
Expression
+
+ {parsed.expression} +
+ {/if} + {#if parsed.result !== undefined} +
Result
+
+ {parsed.result} +
+ {/if} + {:else} +
+ {message.content} +
+ {/if} +
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte index 2e5f57cb61..9e02ccfe74 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte @@ -4,6 +4,12 @@ import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte'; import { config } from '$lib/stores/settings.svelte'; import { getMessageSiblings } from '$lib/utils'; + import { SvelteSet } from 'svelte/reactivity'; + import type { + ApiChatCompletionToolCall, + ChatMessageSiblingInfo, + DatabaseMessage + } from '$lib/types'; interface Props { class?: string; @@ -13,6 +19,11 @@ let { class: className, messages = [], onUserAction }: Props = $props(); + // Prefer live store messages; fall back to the provided prop (e.g. initial render/tests). + const sourceMessages = $derived( + conversationsStore.activeMessages.length ? conversationsStore.activeMessages : messages + ); + let allConversationMessages = $state([]); const currentConfig = config(); @@ -37,31 +48,195 @@ } }); - let displayMessages = $derived.by(() => { - if (!messages.length) { - return []; - } + type ToolSegment = + | { kind: 'thinking'; content: string } + | { kind: 'tool'; toolCalls: ApiChatCompletionToolCall[]; parentId: string }; + type CollectedToolMessage = { + toolCallId?: string | null; + parsed: { expression?: string; result?: string; duration_ms?: number }; + }; + type AssistantDisplayMessage = DatabaseMessage & { + _toolParentIds?: string[]; + _segments?: ToolSegment[]; + _toolMessagesCollected?: CollectedToolMessage[]; + _actionTargetId?: string; + }; + type DisplayEntry = { + message: DatabaseMessage | AssistantDisplayMessage; + siblingInfo: ChatMessageSiblingInfo; + }; + + let displayMessages = $derived.by((): DisplayEntry[] => { + // Force reactivity on message field changes (important for streaming updates) + const signature = sourceMessages + .map( + (m) => + `${m.id}-${m.role}-${m.parent ?? ''}-${m.timestamp ?? ''}-${m.thinking ?? ''}-${ + m.toolCalls ?? '' + }-${m.content ?? ''}` + ) + .join('|'); + // signature is unused but ensures Svelte tracks the above fields + void signature; + + if (!sourceMessages.length) return []; // Filter out system messages if showSystemMessage is false const filteredMessages = currentConfig.showSystemMessage - ? messages - : messages.filter((msg) => msg.type !== 'system'); + ? sourceMessages + : sourceMessages.filter((msg) => msg.type !== 'system'); - return filteredMessages.map((message) => { - const siblingInfo = getMessageSiblings(allConversationMessages, message.id); + const visited = new SvelteSet(); + const result: DisplayEntry[] = []; + const getChildren = (parentId: string, role?: string) => + filteredMessages.filter((m) => m.parent === parentId && (!role || m.role === role)); + + const normalizeToolParsed = ( + value: unknown + ): { expression?: string; result?: string; duration_ms?: number } | null => { + if (!value || typeof value !== 'object') return null; + const obj = value as Record; return { - message, - siblingInfo: siblingInfo || { - message, - siblingIds: [message.id], + expression: typeof obj.expression === 'string' ? obj.expression : undefined, + result: typeof obj.result === 'string' ? obj.result : undefined, + duration_ms: typeof obj.duration_ms === 'number' ? obj.duration_ms : undefined + }; + }; + + for (const msg of filteredMessages) { + if (visited.has(msg.id)) continue; + // Don't render tools directly, but keep them for collection; skip marking visited here + + // Skip tool messages (rendered inline) + if (msg.role === 'tool') continue; + + if (msg.role === 'assistant') { + // Collapse consecutive assistant/tool chains into one display message + const toolParentIds: string[] = []; + const thinkingParts: string[] = []; + const toolCallsCombined: ApiChatCompletionToolCall[] = []; + const segments: ToolSegment[] = []; + const toolMessagesCollected: CollectedToolMessage[] = []; + const toolCallIds = new SvelteSet(); + + let currentAssistant: DatabaseMessage | undefined = msg; + + while (currentAssistant) { + visited.add(currentAssistant.id); + toolParentIds.push(currentAssistant.id); + + if (currentAssistant.thinking) { + thinkingParts.push(currentAssistant.thinking); + segments.push({ kind: 'thinking', content: currentAssistant.thinking }); + } + let thisAssistantToolCalls: ApiChatCompletionToolCall[] = []; + if (currentAssistant.toolCalls) { + try { + const parsed: unknown = JSON.parse(currentAssistant.toolCalls); + if (Array.isArray(parsed)) { + for (const tc of parsed as ApiChatCompletionToolCall[]) { + if (tc?.id && toolCallIds.has(tc.id)) continue; + if (tc?.id) toolCallIds.add(tc.id); + toolCallsCombined.push(tc); + thisAssistantToolCalls.push(tc); + } + } + } catch { + // ignore malformed + } + } + if (thisAssistantToolCalls.length) { + segments.push({ + kind: 'tool', + toolCalls: thisAssistantToolCalls, + parentId: currentAssistant.id + }); + } + + const toolChildren = getChildren(currentAssistant.id, 'tool'); + for (const t of toolChildren) { + visited.add(t.id); + // capture parsed tool message for inline use + try { + const parsedUnknown: unknown = t.content ? JSON.parse(t.content) : null; + const normalized = normalizeToolParsed(parsedUnknown); + toolMessagesCollected.push({ + toolCallId: t.toolCallId, + parsed: normalized ?? { result: t.content } + }); + } catch { + const p = { result: t.content }; + toolMessagesCollected.push({ toolCallId: t.toolCallId, parsed: p }); + } + } + + // Assume at most one assistant child chained after tools + let nextAssistant = toolChildren + .map((t) => getChildren(t.id, 'assistant')[0]) + .find((a) => a !== undefined); + + // Also allow direct assistant->assistant continuation (no intervening tool) + if (!nextAssistant) { + nextAssistant = getChildren(currentAssistant.id, 'assistant')[0]; + } + + if (nextAssistant) { + currentAssistant = nextAssistant; + continue; + } + break; + } + + const siblingInfo: ChatMessageSiblingInfo = getMessageSiblings( + allConversationMessages, + msg.id + ) || { + message: msg, + siblingIds: [msg.id], currentIndex: 0, totalSiblings: 1 - } + }; + + const mergedAssistant: AssistantDisplayMessage = { + ...(currentAssistant ?? msg), + content: currentAssistant?.content ?? '', + thinking: thinkingParts.filter(Boolean).join('\n\n'), + toolCalls: toolCallsCombined.length ? JSON.stringify(toolCallsCombined) : '', + _toolParentIds: toolParentIds, + _segments: segments, + _actionTargetId: msg.id, + _toolMessagesCollected: toolMessagesCollected + }; + + result.push({ message: mergedAssistant, siblingInfo }); + continue; + } + + // user/system messages + const siblingInfo: ChatMessageSiblingInfo = getMessageSiblings( + allConversationMessages, + msg.id + ) || { + message: msg, + siblingIds: [msg.id], + currentIndex: 0, + totalSiblings: 1 }; - }); + result.push({ message: msg, siblingInfo }); + } + + return result; }); + function getToolParentIdsForMessage(msg: DisplayEntry['message']): string[] | undefined { + return (msg as AssistantDisplayMessage)._toolParentIds; + } + + function getDisplayKeyForMessage(msg: DisplayEntry['message']): string { + return (msg as AssistantDisplayMessage)._actionTargetId ?? msg.id; + } + async function handleNavigateToSibling(siblingId: string) { await conversationsStore.navigateToSibling(siblingId); } @@ -121,11 +296,12 @@
- {#each displayMessages as { message, siblingInfo } (message.id)} + {#each displayMessages as { message, siblingInfo } (getDisplayKeyForMessage(message))} `m-${++idCounter}`; + +const makeMsg = (partial: Partial): DatabaseMessage => ({ + id: uid(), + convId: 'c1', + role: 'assistant', + type: 'text', + parent: '-1', + content: '', + thinking: '', + toolCalls: '', + timestamp: Date.now(), + children: [], + ...partial +}); + +describe('ChatMessages reasoning streaming', () => { + it('renders consecutive reasoning chunks around a tool call inline without refresh', async () => { + const user = makeMsg({ role: 'user', type: 'text', content: 'hi', id: 'u1' }); + const assistant1 = makeMsg({ id: 'a1', thinking: 'reasoning-step-1' }); + + conversationsStore.activeMessages = [user, assistant1]; + + const { container } = render(ChatMessages, { + props: { messages: conversationsStore.activeMessages } + }); + + await waitFor(() => { + expect(container.textContent || '').toContain('reasoning-step-1'); + }); + + // Tool call arrives + conversationsStore.updateMessageAtIndex(1, { + toolCalls: JSON.stringify([ + { + id: 'call-1', + type: 'function', + function: { name: 'calculator', arguments: '{"expression":"1+1"}' } + } + ]) + }); + + // Tool message + continued assistant + const tool = makeMsg({ + id: 't1', + role: 'tool', + type: 'tool', + parent: 'a1', + content: JSON.stringify({ result: '2' }), + toolCallId: 'call-1' + }); + const assistant2 = makeMsg({ + id: 'a2', + parent: 't1', + thinking: 'reasoning-step-2', + content: 'final-answer' + }); + conversationsStore.addMessageToActive(tool); + conversationsStore.addMessageToActive(assistant2); + + await waitFor(() => { + const text = container.textContent || ''; + expect(text).toContain('reasoning-step-1'); + expect(text).toContain('reasoning-step-2'); + expect(text).toContain('final-answer'); + }); + + cleanup(); + }); +}); diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte index 57a2edac58..30501b2ec1 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte @@ -38,6 +38,8 @@ let disableAutoScroll = $derived(Boolean(config().disableAutoScroll)); let autoScrollEnabled = $state(true); let chatScrollContainer: HTMLDivElement | undefined = $state(); + // Always hand ChatMessages a fresh array so mutations in the store trigger rerenders/merges + const liveMessages = $derived.by(() => [...conversationsStore.activeMessages]); let dragCounter = $state(0); let isDragOver = $state(false); let lastScrollTop = $state(0); @@ -345,6 +347,14 @@ scrollInterval = undefined; } }); + + // Keep view pinned to bottom across message merges while auto-scroll is enabled. + $effect(() => { + void liveMessages; + if (!disableAutoScroll && autoScrollEnabled) { + queueMicrotask(() => scrollChatToBottom('instant')); + } + }); {#if isDragOver} @@ -369,7 +379,7 @@ > { if (!disableAutoScroll) { userScrolledUp = false; diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte index 4ec9b478fd..562e3ea1ce 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte @@ -9,7 +9,8 @@ Moon, ChevronLeft, ChevronRight, - Database + Database, + Wrench } from '@lucide/svelte'; import { ChatSettingsFooter, @@ -20,6 +21,8 @@ import { config, settingsStore } from '$lib/stores/settings.svelte'; import { setMode } from 'mode-watcher'; import type { Component } from 'svelte'; + import '$lib/services/tools'; // ensure built-in tools register + import { getAllTools } from '$lib/services/tools'; interface Props { onSave?: () => void; @@ -27,259 +30,286 @@ let { onSave }: Props = $props(); - const settingSections: Array<{ - fields: SettingsFieldConfig[]; - icon: Component; - title: string; - }> = [ - { - title: 'General', - icon: Settings, - fields: [ - { - key: 'theme', - label: 'Theme', - type: 'select', - options: [ - { value: 'system', label: 'System', icon: Monitor }, - { value: 'light', label: 'Light', icon: Sun }, - { value: 'dark', label: 'Dark', icon: Moon } - ] - }, - { key: 'apiKey', label: 'API Key', type: 'input' }, - { - key: 'systemMessage', - label: 'System Message', - type: 'textarea' - }, - { - key: 'pasteLongTextToFileLen', - label: 'Paste long text to file length', - type: 'input' - }, - { - key: 'copyTextAttachmentsAsPlainText', - label: 'Copy text attachments as plain text', - type: 'checkbox' - }, - { - key: 'enableContinueGeneration', - label: 'Enable "Continue" button', - type: 'checkbox', - isExperimental: true - }, - { - key: 'pdfAsImage', - label: 'Parse PDF as image', - type: 'checkbox' - }, - { - key: 'askForTitleConfirmation', - label: 'Ask for confirmation before changing conversation title', - type: 'checkbox' - } - ] - }, - { - title: 'Display', - icon: Monitor, - fields: [ - { - key: 'showMessageStats', - label: 'Show message generation statistics', - type: 'checkbox' - }, - { - key: 'showThoughtInProgress', - label: 'Show thought in progress', - type: 'checkbox' - }, - { - key: 'keepStatsVisible', - label: 'Keep stats visible after generation', - type: 'checkbox' - }, - { - key: 'autoMicOnEmpty', - label: 'Show microphone on empty input', - type: 'checkbox', - isExperimental: true - }, - { - key: 'renderUserContentAsMarkdown', - label: 'Render user content as Markdown', - type: 'checkbox' - }, - { - key: 'disableAutoScroll', - label: 'Disable automatic scroll', - type: 'checkbox' - }, - { - key: 'alwaysShowSidebarOnDesktop', - label: 'Always show sidebar on desktop', - type: 'checkbox' - }, - { - key: 'autoShowSidebarOnNewChat', - label: 'Auto-show sidebar on new chat', - type: 'checkbox' - } - ] - }, - { - title: 'Sampling', - icon: Funnel, - fields: [ - { - key: 'temperature', - label: 'Temperature', - type: 'input' - }, - { - key: 'dynatemp_range', - label: 'Dynamic temperature range', - type: 'input' - }, - { - key: 'dynatemp_exponent', - label: 'Dynamic temperature exponent', - type: 'input' - }, - { - key: 'top_k', - label: 'Top K', - type: 'input' - }, - { - key: 'top_p', - label: 'Top P', - type: 'input' - }, - { - key: 'min_p', - label: 'Min P', - type: 'input' - }, - { - key: 'xtc_probability', - label: 'XTC probability', - type: 'input' - }, - { - key: 'xtc_threshold', - label: 'XTC threshold', - type: 'input' - }, - { - key: 'typ_p', - label: 'Typical P', - type: 'input' - }, - { - key: 'max_tokens', - label: 'Max tokens', - type: 'input' - }, - { - key: 'samplers', - label: 'Samplers', - type: 'input' - } - ] - }, - { - title: 'Penalties', - icon: AlertTriangle, - fields: [ - { - key: 'repeat_last_n', - label: 'Repeat last N', - type: 'input' - }, - { - key: 'repeat_penalty', - label: 'Repeat penalty', - type: 'input' - }, - { - key: 'presence_penalty', - label: 'Presence penalty', - type: 'input' - }, - { - key: 'frequency_penalty', - label: 'Frequency penalty', - type: 'input' - }, - { - key: 'dry_multiplier', - label: 'DRY multiplier', - type: 'input' - }, - { - key: 'dry_base', - label: 'DRY base', - type: 'input' - }, - { - key: 'dry_allowed_length', - label: 'DRY allowed length', - type: 'input' - }, - { - key: 'dry_penalty_last_n', - label: 'DRY penalty last N', - type: 'input' - } - ] - }, - { - title: 'Import/Export', - icon: Database, - fields: [] - }, - { - title: 'Developer', - icon: Code, - fields: [ - { - key: 'showToolCalls', - label: 'Show tool call labels', - type: 'checkbox' - }, - { - key: 'disableReasoningFormat', - label: 'Show raw LLM output', - type: 'checkbox' - }, - { - key: 'custom', - label: 'Custom JSON', - type: 'textarea' - } - ] - } - // TODO: Experimental features section will be implemented after initial release - // This includes Python interpreter (Pyodide integration) and other experimental features - // { - // title: 'Experimental', - // icon: Beaker, - // fields: [ - // { - // key: 'pyInterpreterEnabled', - // label: 'Enable Python interpreter', - // type: 'checkbox' - // } - // ] - // } - ]; + let localConfig: SettingsConfigType = $state({ ...config() }); + + function getToolFields(cfg: SettingsConfigType): SettingsFieldConfig[] { + return getAllTools().flatMap((tool) => { + const enableField: SettingsFieldConfig = { + key: tool.enableConfigKey, + label: tool.label, + type: 'checkbox', + help: tool.description + }; + + const enabled = Boolean(cfg[tool.enableConfigKey]); + const settingsFields = (tool.settings ?? []).map((s) => ({ + ...s, + disabled: !enabled + })); + + return [enableField, ...settingsFields]; + }); + } + + const settingSections = $derived.by( + (): Array<{ + fields: SettingsFieldConfig[]; + icon: Component; + title: string; + }> => [ + { + title: 'General', + icon: Settings, + fields: [ + { + key: 'theme', + label: 'Theme', + type: 'select', + options: [ + { value: 'system', label: 'System', icon: Monitor }, + { value: 'light', label: 'Light', icon: Sun }, + { value: 'dark', label: 'Dark', icon: Moon } + ] + }, + { key: 'apiKey', label: 'API Key', type: 'input' }, + { + key: 'systemMessage', + label: 'System Message', + type: 'textarea' + }, + { + key: 'pasteLongTextToFileLen', + label: 'Paste long text to file length', + type: 'input' + }, + { + key: 'copyTextAttachmentsAsPlainText', + label: 'Copy text attachments as plain text', + type: 'checkbox' + }, + { + key: 'enableContinueGeneration', + label: 'Enable "Continue" button', + type: 'checkbox', + isExperimental: true + }, + { + key: 'pdfAsImage', + label: 'Parse PDF as image', + type: 'checkbox' + }, + { + key: 'askForTitleConfirmation', + label: 'Ask for confirmation before changing conversation title', + type: 'checkbox' + } + ] + }, + { + title: 'Display', + icon: Monitor, + fields: [ + { + key: 'showMessageStats', + label: 'Show message generation statistics', + type: 'checkbox' + }, + { + key: 'showThoughtInProgress', + label: 'Show thought in progress', + type: 'checkbox' + }, + { + key: 'keepStatsVisible', + label: 'Keep stats visible after generation', + type: 'checkbox' + }, + { + key: 'autoMicOnEmpty', + label: 'Show microphone on empty input', + type: 'checkbox', + isExperimental: true + }, + { + key: 'renderUserContentAsMarkdown', + label: 'Render user content as Markdown', + type: 'checkbox' + }, + { + key: 'disableAutoScroll', + label: 'Disable automatic scroll', + type: 'checkbox' + }, + { + key: 'alwaysShowSidebarOnDesktop', + label: 'Always show sidebar on desktop', + type: 'checkbox' + }, + { + key: 'autoShowSidebarOnNewChat', + label: 'Auto-show sidebar on new chat', + type: 'checkbox' + } + ] + }, + { + title: 'Sampling', + icon: Funnel, + fields: [ + { + key: 'temperature', + label: 'Temperature', + type: 'input' + }, + { + key: 'dynatemp_range', + label: 'Dynamic temperature range', + type: 'input' + }, + { + key: 'dynatemp_exponent', + label: 'Dynamic temperature exponent', + type: 'input' + }, + { + key: 'top_k', + label: 'Top K', + type: 'input' + }, + { + key: 'top_p', + label: 'Top P', + type: 'input' + }, + { + key: 'min_p', + label: 'Min P', + type: 'input' + }, + { + key: 'xtc_probability', + label: 'XTC probability', + type: 'input' + }, + { + key: 'xtc_threshold', + label: 'XTC threshold', + type: 'input' + }, + { + key: 'typ_p', + label: 'Typical P', + type: 'input' + }, + { + key: 'max_tokens', + label: 'Max tokens', + type: 'input' + }, + { + key: 'samplers', + label: 'Samplers', + type: 'input' + } + ] + }, + { + title: 'Penalties', + icon: AlertTriangle, + fields: [ + { + key: 'repeat_last_n', + label: 'Repeat last N', + type: 'input' + }, + { + key: 'repeat_penalty', + label: 'Repeat penalty', + type: 'input' + }, + { + key: 'presence_penalty', + label: 'Presence penalty', + type: 'input' + }, + { + key: 'frequency_penalty', + label: 'Frequency penalty', + type: 'input' + }, + { + key: 'dry_multiplier', + label: 'DRY multiplier', + type: 'input' + }, + { + key: 'dry_base', + label: 'DRY base', + type: 'input' + }, + { + key: 'dry_allowed_length', + label: 'DRY allowed length', + type: 'input' + }, + { + key: 'dry_penalty_last_n', + label: 'DRY penalty last N', + type: 'input' + } + ] + }, + { + title: 'Import/Export', + icon: Database, + fields: [] + }, + { + title: 'Developer', + icon: Code, + fields: [ + { + key: 'showToolCalls', + label: 'Show tool call labels', + type: 'checkbox' + }, + { + key: 'disableReasoningFormat', + label: 'Show raw LLM output', + type: 'checkbox' + }, + { + key: 'custom', + label: 'Custom JSON', + type: 'textarea' + } + ] + }, + { + title: 'Tools', + icon: Wrench, + fields: getToolFields(localConfig) + } + // TODO: Experimental features section will be implemented after initial release + // This includes Python interpreter (Pyodide integration) and other experimental features + // { + // title: 'Experimental', + // icon: Beaker, + // fields: [ + // { + // key: 'pyInterpreterEnabled', + // label: 'Enable Python interpreter', + // type: 'checkbox' + // } + // ] + // } + ] + ); let activeSection = $state('General'); let currentSection = $derived( settingSections.find((section) => section.title === activeSection) || settingSections[0] ); - let localConfig: SettingsConfigType = $state({ ...config() }); let canScrollLeft = $state(false); let canScrollRight = $state(false); @@ -333,7 +363,8 @@ 'dry_multiplier', 'dry_base', 'dry_allowed_length', - 'dry_penalty_last_n' + 'dry_penalty_last_n', + 'codeInterpreterTimeoutSeconds' ]; for (const field of numericFields) { diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte index a6f51f47d6..53fc1e951e 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte @@ -69,6 +69,7 @@ { // Update local config immediately for real-time badge feedback onConfigChange(field.key, e.currentTarget.value); @@ -110,6 +111,7 @@