diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz index a5465fcd13..49219f7bd2 100644 Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ diff --git a/tools/server/webui/package-lock.json b/tools/server/webui/package-lock.json index 8d13e5a535..cdbe776dd7 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": "^24", "@vitest/browser": "^3.2.3", "@vitest/coverage-v8": "^3.2.3", @@ -925,7 +926,6 @@ "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -2085,7 +2085,6 @@ "integrity": "sha512-W9R51zUCd2iHOQBg/D93+bdpYv6kbtFx+kft5X8lPKQl6yEu0aKs9i5N5GyCASOhIApgx/tkqZIJ7vgM4cqrHA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ts-dedent": "^2.0.0", "type-fest": "~2.19" @@ -2169,7 +2168,6 @@ "integrity": "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -2213,7 +2211,6 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -2629,7 +2626,6 @@ "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2686,6 +2682,46 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "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/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, "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", @@ -2797,7 +2833,6 @@ "integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2864,7 +2899,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3101,7 +3135,6 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -3229,7 +3262,6 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -3287,7 +3319,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3876,7 +3907,8 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -4166,7 +4198,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4221,7 +4252,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7354,7 +7384,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7488,7 +7517,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7505,7 +7533,6 @@ "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -7685,7 +7712,6 @@ "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7696,7 +7722,6 @@ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7962,7 +7987,6 @@ "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -8058,7 +8082,6 @@ "integrity": "sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -8299,7 +8322,6 @@ "integrity": "sha512-DGok7XwIwdPWF+a49Yw+4madER5DZWRo9CdyySBLT3zeuxiEPt0Ua7ouJHm/y6ojnb/FVKZcQe8YmrE71s0qPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -8514,7 +8536,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.3.tgz", "integrity": "sha512-w7QZ398cdNherTdiQ/v3SYLLGOO4948Jgjh04PYqtTYVohmBvbmFwLmo7pp8gp4/1tceRWfSTjHgjtfpCVNJmQ==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -8760,7 +8781,6 @@ "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -8791,8 +8811,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.2", @@ -9046,7 +9065,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9422,7 +9440,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -9583,7 +9600,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/tools/server/webui/package.json b/tools/server/webui/package.json index 0b74e301b1..ea680aa55c 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": "^24", "@vitest/browser": "^3.2.3", "@vitest/coverage-v8": "^3.2.3", 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 ebf7f433d1..1a13e9ccc4 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 @@ -1,33 +1,47 @@ -{#if message.role === MessageRole.SYSTEM} +{#if message.role === 'system'} -{:else if message.role === MessageRole.USER} +{:else if message.role === 'user'} -{:else} +{:else if message.role === 'assistant'} (shouldBranchAfterEdit = value)} {showDeleteDialog} {siblingInfo} + {thinkingContent} + {toolCallContent} + toolParentIds={toolParentIds ?? [message.id]} + toolMessagesCollected={(message as MessageWithToolExtras)._toolMessagesCollected} /> +{: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 263f90ec80..ea7b435b01 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 @@ -1,29 +1,50 @@
- {#if !editCtx.isEditing && thinkingContent} + {#if thinkingContent && (!segments || segments.length === 0)} {/if} - {#if showProcessingInfoTop} + {#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
- {processingState.getPromptProgressText() ?? - processingState.getProcessingMessage() ?? - 'Processing...'} + {processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
{/if} - {#if editCtx.isEditing} + {#if isEditing}
@@ -271,35 +357,181 @@ (shouldBranchAfterEdit = checked === true)} + onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)} />
- -
- {:else if message.role === MessageRole.ASSISTANT} + {:else if message.role === 'assistant'} {#if showRawOutput}
{messageContent || ''}
+ {:else if segments && segments.length} + {#each segmentRenderBlocks as block, blockIdx (blockIdx)} + {#if block.kind === 'reasoning'} + + {#each block.segments as segment, segIndex (segIndex)} + {#if segment.kind === 'thinking'} +
+ {segment.content} +
+ {:else if segment.kind === 'tool'} + {#each segment.toolCalls as toolCall, index (toolCall.id ?? `${blockIdx}-${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} +
+ {:else} + {#each block.segments as segment, segIndex (segIndex)} + {#if segment.kind === 'content'} + + {:else if segment.kind === 'tool'} + {#each segment.toolCalls as toolCall, index (toolCall.id ?? `${blockIdx}-${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} + {/each} {:else} - + {/if} {:else}
@@ -307,41 +539,18 @@
{/if} - {#if showProcessingInfoBottom} -
-
- - {processingState.getPromptProgressText() ?? - processingState.getProcessingMessage() ?? - 'Processing...'} - -
-
- {/if} -
- {#if displayedModel} -
+ {#if displayedModel()} +
{#if isRouter} { - const status = modelsStore.getModelStatus(modelId); - - if (status !== ServerModelStatus.LOADED) { - await modelsStore.loadModel(modelId); - } - - onRegenerate(modelName); - return true; - }} + upToMessageId={message.id} /> {:else} - + {/if} {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms} @@ -350,7 +559,6 @@ promptMs={message.timings.prompt_ms} predictedTokens={message.timings.predicted_n} predictedMs={message.timings.predicted_ms} - onActiveViewChange={handleStatsViewChange} /> {:else if isLoading() && currentConfig.showMessageStats} {@const liveStats = processingState.getLiveProcessingStats()} @@ -374,9 +582,9 @@ {/if}
- {#if message.timestamp && !editCtx.isEditing} + {#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..da8bac90ab 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 @@ -5,35 +5,57 @@ import { buttonVariants } from '$lib/components/ui/button/index.js'; import { Card } from '$lib/components/ui/card'; import { config } from '$lib/stores/settings.svelte'; + import type { Snippet } from 'svelte'; interface Props { class?: string; hasRegularContent?: boolean; isStreaming?: boolean; reasoningContent: string | null; + children?: Snippet; } let { class: className = '', hasRegularContent = false, isStreaming = false, - reasoningContent + reasoningContent, + children }: Props = $props(); const currentConfig = config(); - let isExpanded = $state(currentConfig.showThoughtInProgress); + // Auto-expand only while streaming thought-only output. + // Once regular content appears, auto-opened blocks collapse immediately. + let isExpanded = $state(false); + let autoExpanded = $state(false); $effect(() => { - if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) { + const shouldAutoExpand = + Boolean(isStreaming) && currentConfig.showThoughtInProgress && !hasRegularContent; + + if (shouldAutoExpand && !isExpanded && !autoExpanded) { + isExpanded = true; + autoExpanded = true; + return; + } + + if (!shouldAutoExpand && 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 +81,11 @@
- {reasoningContent ?? ''} + {#if children} + {@render children()} + {:else} + {reasoningContent ?? ''} + {/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 23143c955c..2129dc1b75 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 @@ -1,11 +1,17 @@ -
- {#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)} +
+ {#each displayMessages as { message, siblingInfo } (getDisplayKeyForMessage(message))} {/each}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/__tests__/ChatMessages.thinking-stream.test.ts b/tools/server/webui/src/lib/components/app/chat/ChatMessages/__tests__/ChatMessages.thinking-stream.test.ts new file mode 100644 index 0000000000..66616d9c8c --- /dev/null +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/__tests__/ChatMessages.thinking-stream.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/svelte'; +import ChatMessages from '../ChatMessages.svelte'; +import { conversationsStore } from '$lib/stores/conversations.svelte'; +import type { DatabaseMessage } from '$lib/types'; + +// deterministic IDs for test clarity +let idCounter = 0; +const uid = () => `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 ceecf03e54..b382634cde 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 @@ -12,9 +12,11 @@ } from '$lib/components/app'; import * as Alert from '$lib/components/ui/alert'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; - import { INITIAL_SCROLL_DELAY } from '$lib/constants/auto-scroll'; - import { KeyboardKey } from '$lib/enums'; - import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte'; + import { + AUTO_SCROLL_AT_BOTTOM_THRESHOLD, + AUTO_SCROLL_INTERVAL, + INITIAL_SCROLL_DELAY + } from '$lib/constants/auto-scroll'; import { chatStore, errorDialog, @@ -42,13 +44,20 @@ let { showCenteredEmpty = false } = $props(); 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); + let scrollInterval: ReturnType | undefined; + let scrollTimeout: ReturnType | undefined; let showFileErrorDialog = $state(false); let uploadedFiles = $state([]); - - const autoScroll = createAutoScrollController(); + let userScrolledUp = $state(false); + let lastPinnedMessageCount = $state(0); + let lastPinnedTailId = $state(null); let fileErrorData = $state<{ generallyUnsupported: File[]; @@ -212,11 +221,7 @@ function handleKeydown(event: KeyboardEvent) { const isCtrlOrCmd = event.ctrlKey || event.metaKey; - if ( - isCtrlOrCmd && - event.shiftKey && - (event.key === KeyboardKey.D_LOWER || event.key === KeyboardKey.D_UPPER) - ) { + if (isCtrlOrCmd && event.shiftKey && (event.key === 'd' || event.key === 'D')) { event.preventDefault(); if (activeConversation()) { showDeleteDialog = true; @@ -232,14 +237,46 @@ await chatStore.addSystemPrompt(); } - function handleScroll() { - autoScroll.handleScroll(); + function handleScroll(event?: Event) { + if (disableAutoScroll || !chatScrollContainer) return; + + // Ignore programmatic scroll events (e.g. our own scrollTo calls) so we only + // disable auto-scroll based on user intent. + if (event && 'isTrusted' in event && !(event as Event).isTrusted) { + lastScrollTop = chatScrollContainer.scrollTop; + return; + } + + const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD; + + // Any user-driven upward scroll disables auto-scroll, even if they were close to the bottom. + if (scrollTop < lastScrollTop) { + userScrolledUp = true; + autoScrollEnabled = false; + } else if (isAtBottom && userScrolledUp) { + userScrolledUp = false; + autoScrollEnabled = true; + } + + if (scrollTimeout) { + clearTimeout(scrollTimeout); + } + + scrollTimeout = setTimeout(() => { + if (isAtBottom) { + userScrolledUp = false; + autoScrollEnabled = true; + } + }, AUTO_SCROLL_INTERVAL); + + lastScrollTop = scrollTop; } async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise { - const plainFiles = files ? $state.snapshot(files) : undefined; - const result = plainFiles - ? await parseFilesToMessageExtras(plainFiles, activeModelId ?? undefined) + const result = files + ? await parseFilesToMessageExtras(files, activeModelId ?? undefined) : undefined; if (result?.emptyFiles && result.emptyFiles.length > 0) { @@ -256,9 +293,12 @@ const extras = result?.extras; // Enable autoscroll for user-initiated message sending - autoScroll.enable(); + if (!disableAutoScroll) { + userScrolledUp = false; + autoScrollEnabled = true; + } await chatStore.sendMessage(message, extras); - autoScroll.scrollToBottom(); + scrollChatToBottom(); return true; } @@ -308,15 +348,24 @@ } } + function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') { + if (disableAutoScroll) return; + + chatScrollContainer?.scrollTo({ + top: chatScrollContainer?.scrollHeight, + behavior + }); + } + afterNavigate(() => { if (!disableAutoScroll) { - setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY); + setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY); } }); onMount(() => { if (!disableAutoScroll) { - setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY); + setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY); } const pendingDraft = chatStore.consumePendingDraft(); @@ -327,15 +376,40 @@ }); $effect(() => { - autoScroll.setContainer(chatScrollContainer); + if (disableAutoScroll) { + autoScrollEnabled = false; + if (scrollInterval) { + clearInterval(scrollInterval); + scrollInterval = undefined; + } + return; + } + + if (isCurrentConversationLoading && autoScrollEnabled) { + scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL); + } else if (scrollInterval) { + clearInterval(scrollInterval); + scrollInterval = undefined; + } }); + // Keep view pinned to bottom across message merges while auto-scroll is enabled. $effect(() => { - autoScroll.setDisabled(disableAutoScroll); - }); + const messageCount = liveMessages.length; + const tailId = liveMessages[messageCount - 1]?.id ?? null; + const shouldPinNow = messageCount !== lastPinnedMessageCount || tailId !== lastPinnedTailId; - $effect(() => { - autoScroll.updateInterval(isCurrentConversationLoading); + lastPinnedMessageCount = messageCount; + lastPinnedTailId = tailId; + + if (!shouldPinNow) return; + if (disableAutoScroll || userScrolledUp || !autoScrollEnabled) return; + + queueMicrotask(() => { + // Re-check at execution time so user scroll actions can "win" even if a pin was queued earlier. + if (disableAutoScroll || userScrolledUp || !autoScrollEnabled) return; + scrollChatToBottom('instant'); + }); }); @@ -361,10 +435,13 @@ > { - autoScroll.enable(); - autoScroll.scrollToBottom(); + if (!disableAutoScroll) { + userScrolledUp = false; + autoScrollEnabled = true; + scrollChatToBottom(); + } }} /> @@ -428,7 +505,7 @@ >
-

llama.cpp

+

llama.cpp

{serverStore.props?.modalities?.audio @@ -571,7 +648,7 @@ contextInfo={activeErrorDialog?.contextInfo} onOpenChange={handleErrorDialogOpenChange} open={Boolean(activeErrorDialog)} - type={activeErrorDialog?.type ?? ErrorDialogType.SERVER} + type={(activeErrorDialog?.type as ErrorDialogType) ?? ErrorDialogType.SERVER} />