webui: Client-side implementation of tool calling with calculator tool and (javascript) code interpreter tool
This commit is contained in:
parent
da426cb250
commit
fde17e0f5d
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,33 +1,47 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import {
|
||||
chatStore,
|
||||
pendingEditMessageId,
|
||||
clearPendingEditMessageId,
|
||||
removeSystemPromptPlaceholder
|
||||
} from '$lib/stores/chat.svelte';
|
||||
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
|
||||
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { DatabaseService } from '$lib/services';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import {
|
||||
ChatMessageAssistant,
|
||||
ChatMessageUser,
|
||||
ChatMessageSystem
|
||||
} from '$lib/components/app/chat';
|
||||
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||
import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
|
||||
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
|
||||
import ChatMessageUser from './ChatMessageUser.svelte';
|
||||
import ChatMessageSystem from './ChatMessageSystem.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
isLastAssistantMessage?: boolean;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
toolParentIds?: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
isLastAssistantMessage = false,
|
||||
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;
|
||||
}
|
||||
|
||||
const chatActions = getChatActionsContext();
|
||||
|
||||
let deletionInfo = $state<{
|
||||
|
|
@ -43,8 +57,39 @@
|
|||
let showDeleteDialog = $state(false);
|
||||
let shouldBranchAfterEdit = $state(false);
|
||||
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
||||
let showSaveOnlyOption = $derived(message.role === 'user');
|
||||
|
||||
let showSaveOnlyOption = $derived(message.role === MessageRole.USER);
|
||||
let thinkingContent = $derived.by(() => {
|
||||
if (message.role === 'assistant') {
|
||||
const trimmedThinking = message.thinking?.trim();
|
||||
|
||||
return trimmedThinking ? trimmedThinking : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
|
||||
if (message.role === 'assistant') {
|
||||
const trimmedToolCalls = message.toolCalls?.trim();
|
||||
|
||||
if (!trimmedToolCalls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedToolCalls);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed as ApiChatCompletionToolCall[];
|
||||
}
|
||||
} catch {
|
||||
// Harmony-only path: fall back to the raw string so issues surface visibly.
|
||||
}
|
||||
|
||||
return trimmedToolCalls;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
setMessageEditContext({
|
||||
get isEditing() {
|
||||
|
|
@ -83,12 +128,13 @@
|
|||
startEdit: handleEdit
|
||||
});
|
||||
|
||||
// Auto-start edit mode if this message is the pending edit target
|
||||
$effect(() => {
|
||||
const pendingId = pendingEditMessageId();
|
||||
|
||||
if (pendingId && pendingId === message.id && !isEditing) {
|
||||
handleEdit();
|
||||
chatStore.clearPendingEditMessageId();
|
||||
clearPendingEditMessageId();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -96,8 +142,8 @@
|
|||
isEditing = false;
|
||||
|
||||
// If canceling a new system message with placeholder content, remove it without deleting children
|
||||
if (message.role === MessageRole.SYSTEM) {
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
if (message.role === 'system') {
|
||||
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
|
||||
|
||||
if (conversationDeleted) {
|
||||
goto(`${base}/`);
|
||||
|
|
@ -111,34 +157,46 @@
|
|||
editedUploadedFiles = [];
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
|
||||
editedExtras = extras;
|
||||
}
|
||||
|
||||
function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
|
||||
editedUploadedFiles = files;
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
|
||||
const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
|
||||
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
|
||||
chatActions.copy(message);
|
||||
}
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
if (message.role === MessageRole.SYSTEM) {
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
const target = getActionTarget();
|
||||
if (target.role === 'system') {
|
||||
const conversationDeleted = await removeSystemPromptPlaceholder(target.id);
|
||||
|
||||
if (conversationDeleted) {
|
||||
goto(`${base}/`);
|
||||
goto('/');
|
||||
}
|
||||
} else {
|
||||
chatActions.delete(message);
|
||||
chatActions.delete(target);
|
||||
}
|
||||
|
||||
showDeleteDialog = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
deletionInfo = await chatStore.getDeletionInfo(message.id);
|
||||
const target = getActionTarget();
|
||||
deletionInfo = await chatStore.getDeletionInfo(target.id);
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
isEditing = true;
|
||||
// Clear temporary placeholder content for system messages
|
||||
// Clear placeholder content for system messages
|
||||
editedContent =
|
||||
message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER
|
||||
message.role === 'system' && message.content === SYSTEM_MESSAGE_PLACEHOLDER
|
||||
? ''
|
||||
: message.content;
|
||||
textareaElement?.focus();
|
||||
|
|
@ -156,12 +214,30 @@
|
|||
}, 0);
|
||||
}
|
||||
|
||||
function handleEditedContentChange(content: string) {
|
||||
editedContent = content;
|
||||
}
|
||||
|
||||
function handleEditKeydown(event: KeyboardEvent) {
|
||||
// Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
|
||||
// This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
|
||||
event.preventDefault();
|
||||
handleSaveEdit();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
handleCancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRegenerate(modelOverride?: string) {
|
||||
chatActions.regenerateWithBranching(message, modelOverride);
|
||||
const target = getActionTarget();
|
||||
chatActions.regenerateWithBranching(target, modelOverride);
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
chatActions.continueAssistantMessage(message);
|
||||
const target = getActionTarget();
|
||||
chatActions.continueAssistantMessage(target);
|
||||
}
|
||||
|
||||
function handleNavigateToSibling(siblingId: string) {
|
||||
|
|
@ -169,13 +245,13 @@
|
|||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (message.role === MessageRole.SYSTEM) {
|
||||
if (message.role === 'system') {
|
||||
// System messages: update in place without branching
|
||||
const newContent = editedContent.trim();
|
||||
|
||||
// If content is empty, remove without deleting children
|
||||
// If content is empty or still the placeholder, remove without deleting children
|
||||
if (!newContent) {
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
|
||||
isEditing = false;
|
||||
if (conversationDeleted) {
|
||||
goto(`${base}/`);
|
||||
|
|
@ -188,7 +264,7 @@
|
|||
if (index !== -1) {
|
||||
conversationsStore.updateMessageAtIndex(index, { content: newContent });
|
||||
}
|
||||
} else if (message.role === MessageRole.USER) {
|
||||
} else if (message.role === 'user') {
|
||||
const finalExtras = await getMergedExtras();
|
||||
chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
|
||||
} else {
|
||||
|
|
@ -203,7 +279,7 @@
|
|||
}
|
||||
|
||||
async function handleSaveEditOnly() {
|
||||
if (message.role === MessageRole.USER) {
|
||||
if (message.role === 'user') {
|
||||
// For user messages, trim to avoid accidental whitespace
|
||||
const finalExtras = await getMergedExtras();
|
||||
chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
|
||||
|
|
@ -218,8 +294,8 @@
|
|||
return editedExtras;
|
||||
}
|
||||
|
||||
const plainFiles = $state.snapshot(editedUploadedFiles);
|
||||
const result = await parseFilesToMessageExtras(plainFiles);
|
||||
const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
|
||||
const result = await parseFilesToMessageExtras(editedUploadedFiles);
|
||||
const newExtras = result?.extras || [];
|
||||
|
||||
return [...editedExtras, ...newExtras];
|
||||
|
|
@ -230,52 +306,85 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if message.role === MessageRole.SYSTEM}
|
||||
{#if message.role === 'system'}
|
||||
<ChatMessageSystem
|
||||
bind:textareaElement
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{editedContent}
|
||||
{isEditing}
|
||||
{message}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditKeydown={handleEditKeydown}
|
||||
onEditedContentChange={handleEditedContentChange}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
/>
|
||||
{:else if message.role === MessageRole.USER}
|
||||
{:else if message.role === 'user'}
|
||||
<ChatMessageUser
|
||||
bind:textareaElement
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{editedContent}
|
||||
{editedExtras}
|
||||
{editedUploadedFiles}
|
||||
{isEditing}
|
||||
{message}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditKeydown={handleEditKeydown}
|
||||
onEditedContentChange={handleEditedContentChange}
|
||||
onEditedExtrasChange={handleEditedExtrasChange}
|
||||
onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onSaveEditOnly={handleSaveEditOnly}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
/>
|
||||
{:else}
|
||||
{:else if message.role === 'assistant'}
|
||||
<ChatMessageAssistant
|
||||
bind:textareaElement
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{isLastAssistantMessage}
|
||||
{editedContent}
|
||||
{isEditing}
|
||||
{message}
|
||||
messageContent={message.content}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onContinue={handleContinue}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditKeydown={handleEditKeydown}
|
||||
onEditedContentChange={handleEditedContentChange}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onRegenerate={handleRegenerate}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{shouldBranchAfterEdit}
|
||||
onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
{thinkingContent}
|
||||
{toolCallContent}
|
||||
toolParentIds={toolParentIds ?? [message.id]}
|
||||
toolMessagesCollected={(message as MessageWithToolExtras)._toolMessagesCollected}
|
||||
/>
|
||||
{:else if message.role === 'tool'}
|
||||
<!-- Tool messages are rendered inline inside their parent assistant's reasoning block.
|
||||
Skip standalone rendering to avoid duplicate bubbles. -->
|
||||
<!-- Intentionally left blank -->
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,50 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ModelBadge,
|
||||
ChatMessageActions,
|
||||
ChatMessageStatistics,
|
||||
MarkdownContent,
|
||||
ModelBadge,
|
||||
ModelsSelector
|
||||
ModelsSelector,
|
||||
BadgeChatStatistic
|
||||
} from '$lib/components/app';
|
||||
import ChatMessageThinkingBlock from './ChatMessageThinkingBlock.svelte';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import type { DatabaseMessage, ApiChatCompletionToolCall } from '$lib/types';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
|
||||
import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
|
||||
import { tick } from 'svelte';
|
||||
import { isLoading } from '$lib/stores/chat.svelte';
|
||||
import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Check, X } 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/css-classes';
|
||||
import { MessageRole, KeyboardKey } from '$lib/enums';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { REASONING_TAGS } from '$lib/constants/agentic';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type ToolSegment =
|
||||
| { kind: 'content'; content: string; parentId: string }
|
||||
| { kind: 'thinking'; content: string }
|
||||
| {
|
||||
kind: 'tool';
|
||||
toolCalls: ApiChatCompletionToolCall[];
|
||||
parentId: string;
|
||||
inThinking: boolean;
|
||||
};
|
||||
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<DatabaseMessage, 'role' | 'content'> & {
|
||||
toolCallId?: string | null;
|
||||
parent?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -33,198 +54,265 @@
|
|||
assistantMessages: number;
|
||||
messageTypes: string[];
|
||||
} | null;
|
||||
isLastAssistantMessage?: boolean;
|
||||
editedContent?: string;
|
||||
isEditing?: boolean;
|
||||
message: DatabaseMessage;
|
||||
messageContent: string | undefined;
|
||||
onCancelEdit?: () => void;
|
||||
onCopy: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onContinue?: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: () => void;
|
||||
onEditKeydown?: (event: KeyboardEvent) => void;
|
||||
onEditedContentChange?: (content: string) => void;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onRegenerate: (modelOverride?: string) => void;
|
||||
onSaveEdit?: () => void;
|
||||
onShowDeleteDialogChange: (show: boolean) => void;
|
||||
onShouldBranchAfterEditChange?: (value: boolean) => void;
|
||||
showDeleteDialog: boolean;
|
||||
shouldBranchAfterEdit?: boolean;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
textareaElement?: HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
interface ParsedReasoningContent {
|
||||
content: string;
|
||||
reasoningContent: string | null;
|
||||
hasReasoningMarkers: boolean;
|
||||
}
|
||||
|
||||
function parseReasoningContent(content: string | undefined): ParsedReasoningContent {
|
||||
if (!content) {
|
||||
return {
|
||||
content: '',
|
||||
reasoningContent: null,
|
||||
hasReasoningMarkers: false
|
||||
};
|
||||
}
|
||||
|
||||
const plainParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
const { START, END } = REASONING_TAGS;
|
||||
let cursor = 0;
|
||||
let hasReasoningMarkers = false;
|
||||
|
||||
while (cursor < content.length) {
|
||||
const startIndex = content.indexOf(START, cursor);
|
||||
|
||||
if (startIndex === -1) {
|
||||
plainParts.push(content.slice(cursor));
|
||||
break;
|
||||
}
|
||||
|
||||
hasReasoningMarkers = true;
|
||||
plainParts.push(content.slice(cursor, startIndex));
|
||||
|
||||
const reasoningStart = startIndex + START.length;
|
||||
const endIndex = content.indexOf(END, reasoningStart);
|
||||
|
||||
if (endIndex === -1) {
|
||||
reasoningParts.push(content.slice(reasoningStart));
|
||||
cursor = content.length;
|
||||
break;
|
||||
}
|
||||
|
||||
reasoningParts.push(content.slice(reasoningStart, endIndex));
|
||||
cursor = endIndex + END.length;
|
||||
}
|
||||
|
||||
return {
|
||||
content: plainParts.join(''),
|
||||
reasoningContent: reasoningParts.length > 0 ? reasoningParts.join('\n\n') : null,
|
||||
hasReasoningMarkers
|
||||
};
|
||||
thinkingContent: string | null;
|
||||
toolCallContent: ApiChatCompletionToolCall[] | string | null;
|
||||
toolParentIds?: string[];
|
||||
segments?: ToolSegment[] | null;
|
||||
toolMessagesCollected?: CollectedToolMessage[] | null;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
deletionInfo,
|
||||
isLastAssistantMessage = false,
|
||||
editedContent = '',
|
||||
isEditing = false,
|
||||
message,
|
||||
messageContent,
|
||||
onCancelEdit,
|
||||
onConfirmDelete,
|
||||
onContinue,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onEditKeydown,
|
||||
onEditedContentChange,
|
||||
onNavigateToSibling,
|
||||
onRegenerate,
|
||||
onSaveEdit,
|
||||
onShowDeleteDialogChange,
|
||||
onShouldBranchAfterEditChange,
|
||||
showDeleteDialog,
|
||||
shouldBranchAfterEdit = false,
|
||||
siblingInfo = null,
|
||||
textareaElement = $bindable()
|
||||
textareaElement = $bindable(),
|
||||
thinkingContent,
|
||||
toolCallContent = null,
|
||||
toolParentIds = [message.id],
|
||||
segments: segmentsProp = null,
|
||||
toolMessagesCollected: toolMessagesCollectedProp = (message as MessageWithToolExtras)
|
||||
._toolMessagesCollected ?? null
|
||||
}: Props = $props();
|
||||
|
||||
// Get edit context
|
||||
const editCtx = getMessageEditContext();
|
||||
// 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
|
||||
);
|
||||
|
||||
// Local state for assistant-specific editing
|
||||
let shouldBranchAfterEdit = $state(false);
|
||||
|
||||
function handleEditKeydown(event: KeyboardEvent) {
|
||||
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
|
||||
event.preventDefault();
|
||||
editCtx.save();
|
||||
} else if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
editCtx.cancel();
|
||||
let hasRegularContent = $derived.by(() => {
|
||||
if (messageContent?.trim()) return true;
|
||||
return (segments ?? []).some((s) => s.kind === 'content' && Boolean(s.content?.trim()));
|
||||
});
|
||||
type SegmentRenderBlock = { kind: 'reasoning' | 'main'; segments: ToolSegment[] };
|
||||
let segmentRenderBlocks = $derived.by<SegmentRenderBlock[]>(() => {
|
||||
if (!segments || segments.length === 0) return [];
|
||||
const blocks: SegmentRenderBlock[] = [];
|
||||
for (const segment of segments) {
|
||||
const isReasoning =
|
||||
segment.kind === 'thinking' || (segment.kind === 'tool' && segmentToolInThinking(segment));
|
||||
const blockKind: SegmentRenderBlock['kind'] = isReasoning ? 'reasoning' : 'main';
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
if (lastBlock && lastBlock.kind === blockKind) {
|
||||
lastBlock.segments.push(segment);
|
||||
} else {
|
||||
blocks.push({ kind: blockKind, segments: [segment] });
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
});
|
||||
let hasMainRenderBlock = $derived.by(() =>
|
||||
segmentRenderBlocks.some((block) => block.kind === 'main')
|
||||
);
|
||||
|
||||
const toolCalls = $derived(
|
||||
Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
|
||||
);
|
||||
|
||||
const parsedMessageContent = $derived.by(() => parseReasoningContent(messageContent));
|
||||
const visibleMessageContent = $derived(parsedMessageContent.content);
|
||||
const thinkingContent = $derived(parsedMessageContent.reasoningContent);
|
||||
const hasReasoningMarkers = $derived(parsedMessageContent.hasReasoningMarkers);
|
||||
const processingState = useProcessingState();
|
||||
|
||||
// Local state for raw output toggle (per message)
|
||||
let showRawOutput = $state(false);
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let showRawOutput = $state(false);
|
||||
let statsContainerEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
function getScrollParent(el: HTMLElement): HTMLElement | null {
|
||||
let parent = el.parentElement;
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent);
|
||||
if (/(auto|scroll)/.test(style.overflowY)) {
|
||||
return parent;
|
||||
const toolMessages = $derived<ToolMessageLike[]>(
|
||||
(() => {
|
||||
const ids = new SvelteSet<string>();
|
||||
if (toolCalls) {
|
||||
for (const tc of toolCalls) {
|
||||
if (tc.id) ids.add(tc.id);
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
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<Record<string, ToolParsed>>(
|
||||
(() => {
|
||||
const map: Record<string, ToolParsed> = {};
|
||||
for (const t of toolMessages) {
|
||||
const parsed = parseToolMessage(t);
|
||||
if (parsed && t.toolCallId) {
|
||||
map[t.toolCallId] = parsed;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})()
|
||||
);
|
||||
|
||||
const collectedById = $derived<Record<string, ToolParsed>>(
|
||||
(() => {
|
||||
const map: Record<string, ToolParsed> = {};
|
||||
(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;
|
||||
}
|
||||
|
||||
async function handleStatsViewChange() {
|
||||
const el = statsContainerEl;
|
||||
if (!el) {
|
||||
return;
|
||||
function advanceToolResult(toolCall: ApiChatCompletionToolCall) {
|
||||
return getToolResult(toolCall) ?? null;
|
||||
}
|
||||
let displayedModel = $derived((): string | null => {
|
||||
if (message.model) {
|
||||
return message.model;
|
||||
}
|
||||
|
||||
const scrollParent = getScrollParent(el);
|
||||
if (!scrollParent) {
|
||||
return;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const yBefore = el.getBoundingClientRect().top;
|
||||
|
||||
await tick();
|
||||
|
||||
const delta = el.getBoundingClientRect().top - yBefore;
|
||||
if (delta !== 0) {
|
||||
scrollParent.scrollTop += delta;
|
||||
}
|
||||
|
||||
// Correct any drift after browser paint
|
||||
requestAnimationFrame(() => {
|
||||
const drift = el.getBoundingClientRect().top - yBefore;
|
||||
|
||||
if (Math.abs(drift) > 1) {
|
||||
scrollParent.scrollTop += drift;
|
||||
}
|
||||
});
|
||||
async function handleModelChange(_modelId: string, modelName: string): Promise<boolean> {
|
||||
onRegenerate(modelName);
|
||||
return true;
|
||||
}
|
||||
|
||||
let displayedModel = $derived(message.model ?? null);
|
||||
|
||||
let isCurrentlyLoading = $derived(isLoading());
|
||||
let isStreaming = $derived(isChatStreaming());
|
||||
let hasNoContent = $derived(!visibleMessageContent?.trim());
|
||||
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
|
||||
|
||||
let showProcessingInfoTop = $derived(
|
||||
message?.role === MessageRole.ASSISTANT &&
|
||||
isActivelyProcessing &&
|
||||
hasNoContent &&
|
||||
isLastAssistantMessage
|
||||
);
|
||||
|
||||
let showProcessingInfoBottom = $derived(
|
||||
message?.role === MessageRole.ASSISTANT &&
|
||||
isActivelyProcessing &&
|
||||
!hasNoContent &&
|
||||
isLastAssistantMessage
|
||||
);
|
||||
|
||||
function handleCopyModel() {
|
||||
void copyToClipboard(displayedModel ?? '');
|
||||
const model = displayedModel();
|
||||
|
||||
void copyToClipboard(model ?? '');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (editCtx.isEditing && textareaElement) {
|
||||
if (isEditing && textareaElement) {
|
||||
autoResizeTextarea(textareaElement);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (showProcessingInfoTop || showProcessingInfoBottom) {
|
||||
if (isLoading() && !message?.content?.trim()) {
|
||||
processingState.startMonitoring();
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
return { raw: rawArguments };
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
function segmentToolInThinking(segment: ToolSegment): boolean {
|
||||
if (segment.kind !== 'tool') return false;
|
||||
const maybe = segment as unknown as { inThinking?: unknown };
|
||||
if (typeof maybe.inThinking === 'boolean') return maybe.inThinking;
|
||||
// Back-compat fallback: if we don't know, treat as in-reasoning when there is a thinking block.
|
||||
return Boolean(thinkingContent);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
@ -232,36 +320,34 @@
|
|||
role="group"
|
||||
aria-label="Assistant message with actions"
|
||||
>
|
||||
{#if !editCtx.isEditing && thinkingContent}
|
||||
{#if thinkingContent && (!segments || segments.length === 0)}
|
||||
<ChatMessageThinkingBlock
|
||||
reasoningContent={thinkingContent}
|
||||
isStreaming={!message.timestamp}
|
||||
hasRegularContent={!!visibleMessageContent?.trim()}
|
||||
isStreaming={!message.timestamp || isLoading()}
|
||||
{hasRegularContent}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showProcessingInfoTop}
|
||||
{#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
|
||||
<div class="mt-6 w-full max-w-[48rem]" in:fade>
|
||||
<div class="processing-container">
|
||||
<span class="processing-text">
|
||||
{processingState.getPromptProgressText() ??
|
||||
processingState.getProcessingMessage() ??
|
||||
'Processing...'}
|
||||
{processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editCtx.isEditing}
|
||||
{#if isEditing}
|
||||
<div class="w-full">
|
||||
<textarea
|
||||
bind:this={textareaElement}
|
||||
value={editCtx.editedContent}
|
||||
bind:value={editedContent}
|
||||
class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
|
||||
onkeydown={handleEditKeydown}
|
||||
onkeydown={onEditKeydown}
|
||||
oninput={(e) => {
|
||||
autoResizeTextarea(e.currentTarget);
|
||||
editCtx.setContent(e.currentTarget.value);
|
||||
onEditedContentChange?.(e.currentTarget.value);
|
||||
}}
|
||||
placeholder="Edit assistant message..."
|
||||
></textarea>
|
||||
|
|
@ -271,35 +357,181 @@
|
|||
<Checkbox
|
||||
id="branch-after-edit"
|
||||
bind:checked={shouldBranchAfterEdit}
|
||||
onCheckedChange={(checked) => (shouldBranchAfterEdit = checked === true)}
|
||||
onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)}
|
||||
/>
|
||||
<Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
|
||||
Branch conversation after edit
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button class="h-8 px-3" onclick={editCtx.cancel} size="sm" variant="outline">
|
||||
<Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
|
||||
<X class="mr-1 h-3 w-3" />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
class="h-8 px-3"
|
||||
onclick={editCtx.save}
|
||||
disabled={!editCtx.editedContent?.trim()}
|
||||
size="sm"
|
||||
>
|
||||
<Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm">
|
||||
<Check class="mr-1 h-3 w-3" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if message.role === MessageRole.ASSISTANT}
|
||||
{:else if message.role === 'assistant'}
|
||||
{#if showRawOutput}
|
||||
<pre class="raw-output">{messageContent || ''}</pre>
|
||||
{:else if segments && segments.length}
|
||||
{#each segmentRenderBlocks as block, blockIdx (blockIdx)}
|
||||
{#if block.kind === 'reasoning'}
|
||||
<ChatMessageThinkingBlock
|
||||
reasoningContent={null}
|
||||
isStreaming={!message.timestamp || isLoading()}
|
||||
hasRegularContent={hasMainRenderBlock}
|
||||
>
|
||||
{#each block.segments as segment, segIndex (segIndex)}
|
||||
{#if segment.kind === 'thinking'}
|
||||
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
|
||||
{segment.content}
|
||||
</div>
|
||||
{: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)}
|
||||
<div
|
||||
class="mt-2 space-y-1 rounded-md border border-dashed border-muted-foreground/40 bg-muted/40 px-2.5 py-2"
|
||||
data-testid="tool-call-block"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 text-xs font-semibold">
|
||||
<Wrench class="h-3.5 w-3.5" />
|
||||
<span>{getToolLabel(toolCall, index)}</span>
|
||||
</div>
|
||||
{#if durationText}
|
||||
<BadgeChatStatistic icon={Clock} value={durationText} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if argsParsed}
|
||||
<div class="text-[12px] text-muted-foreground">Arguments</div>
|
||||
{#if 'pairs' in argsParsed}
|
||||
{#each argsParsed.pairs as pair (pair.key)}
|
||||
<div class="mt-1 rounded-sm bg-background/70 px-2 py-1.5">
|
||||
<div class="text-[12px] font-semibold text-foreground">{pair.key}</div>
|
||||
{#if pair.key === 'code' && toolCall.function?.name === 'code_interpreter_javascript'}
|
||||
<MarkdownContent
|
||||
class="mt-0.5 text-[12px] leading-snug"
|
||||
content={toFencedCodeBlock(pair.value, 'javascript')}
|
||||
/>
|
||||
{:else}
|
||||
<pre
|
||||
class="mt-0.5 font-mono text-[12px] leading-snug break-words whitespace-pre-wrap">
|
||||
{pair.value}
|
||||
</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<pre class="font-mono text-[12px] leading-snug break-words whitespace-pre-wrap">
|
||||
{argsParsed.raw}
|
||||
</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if parsed && parsed.result !== undefined}
|
||||
<div class="text-[12px] text-muted-foreground">Result</div>
|
||||
<div class="rounded-sm bg-background/80 px-2 py-1 font-mono text-[12px]">
|
||||
{parsed.result}
|
||||
</div>
|
||||
{:else if collectedResult !== undefined}
|
||||
<div class="text-[12px] text-muted-foreground">Result</div>
|
||||
<div class="rounded-sm bg-background/80 px-2 py-1 font-mono text-[12px]">
|
||||
{collectedResult}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</ChatMessageThinkingBlock>
|
||||
{:else}
|
||||
{#each block.segments as segment, segIndex (segIndex)}
|
||||
{#if segment.kind === 'content'}
|
||||
<MarkdownContent content={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)}
|
||||
<div
|
||||
class="mt-2 space-y-1 rounded-md border border-dashed border-muted-foreground/40 bg-muted/40 px-2.5 py-2"
|
||||
data-testid="tool-call-block"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 text-xs font-semibold">
|
||||
<Wrench class="h-3.5 w-3.5" />
|
||||
<span>{getToolLabel(toolCall, index)}</span>
|
||||
</div>
|
||||
{#if durationText}
|
||||
<BadgeChatStatistic icon={Clock} value={durationText} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if argsParsed}
|
||||
<div class="text-[12px] text-muted-foreground">Arguments</div>
|
||||
{#if 'pairs' in argsParsed}
|
||||
{#each argsParsed.pairs as pair (pair.key)}
|
||||
<div class="mt-1 rounded-sm bg-background/70 px-2 py-1.5">
|
||||
<div class="text-[12px] font-semibold text-foreground">{pair.key}</div>
|
||||
{#if pair.key === 'code' && toolCall.function?.name === 'code_interpreter_javascript'}
|
||||
<MarkdownContent
|
||||
class="mt-0.5 text-[12px] leading-snug"
|
||||
content={toFencedCodeBlock(pair.value, 'javascript')}
|
||||
/>
|
||||
{:else}
|
||||
<pre
|
||||
class="mt-0.5 font-mono text-[12px] leading-snug break-words whitespace-pre-wrap">
|
||||
{pair.value}
|
||||
</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<pre class="font-mono text-[12px] leading-snug break-words whitespace-pre-wrap">
|
||||
{argsParsed.raw}
|
||||
</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if parsed && parsed.result !== undefined}
|
||||
<div class="text-[12px] text-muted-foreground">Result</div>
|
||||
<div class="rounded-sm bg-background/80 px-2 py-1 font-mono text-[12px]">
|
||||
{parsed.result}
|
||||
</div>
|
||||
{:else if collectedResult !== undefined}
|
||||
<div class="text-[12px] text-muted-foreground">Result</div>
|
||||
<div class="rounded-sm bg-background/80 px-2 py-1 font-mono text-[12px]">
|
||||
{collectedResult}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<MarkdownContent content={visibleMessageContent || ''} attachments={message.extra} />
|
||||
<MarkdownContent content={messageContent ?? ''} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm whitespace-pre-wrap">
|
||||
|
|
@ -307,41 +539,18 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showProcessingInfoBottom}
|
||||
<div class="mt-4 w-full max-w-[48rem]" in:fade>
|
||||
<div class="processing-container">
|
||||
<span class="processing-text">
|
||||
{processingState.getPromptProgressText() ??
|
||||
processingState.getProcessingMessage() ??
|
||||
'Processing...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info my-6 grid gap-4 tabular-nums">
|
||||
{#if displayedModel}
|
||||
<div
|
||||
bind:this={statsContainerEl}
|
||||
class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{#if displayedModel()}
|
||||
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||
{#if isRouter}
|
||||
<ModelsSelector
|
||||
currentModel={displayedModel}
|
||||
currentModel={displayedModel()}
|
||||
onModelChange={handleModelChange}
|
||||
disabled={isLoading()}
|
||||
onModelChange={async (modelId, modelName) => {
|
||||
const status = modelsStore.getModelStatus(modelId);
|
||||
|
||||
if (status !== ServerModelStatus.LOADED) {
|
||||
await modelsStore.loadModel(modelId);
|
||||
}
|
||||
|
||||
onRegenerate(modelName);
|
||||
return true;
|
||||
}}
|
||||
upToMessageId={message.id}
|
||||
/>
|
||||
{:else}
|
||||
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
|
||||
<ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
|
||||
{/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}
|
||||
</div>
|
||||
|
||||
{#if message.timestamp && !editCtx.isEditing}
|
||||
{#if message.timestamp && !isEditing}
|
||||
<ChatMessageActions
|
||||
role={MessageRole.ASSISTANT}
|
||||
role="assistant"
|
||||
justify="start"
|
||||
actionsPosition="left"
|
||||
{siblingInfo}
|
||||
|
|
@ -385,7 +593,7 @@
|
|||
{onCopy}
|
||||
{onEdit}
|
||||
{onRegenerate}
|
||||
onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
|
||||
onContinue={currentConfig.enableContinueGeneration && !thinkingContent
|
||||
? onContinue
|
||||
: undefined}
|
||||
{onDelete}
|
||||
|
|
@ -445,4 +653,17 @@
|
|||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-call-badge {
|
||||
max-width: 12rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tool-call-badge--fallback {
|
||||
max-width: 20rem;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
|
||||
<Collapsible.Root bind:open={isExpanded} class="{hasRegularContent ? 'mb-4' : 'mb-6'} {className}">
|
||||
<Card class="gap-0 border-muted bg-muted/30 py-0">
|
||||
<Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
|
||||
<Collapsible.Trigger
|
||||
class="flex cursor-pointer items-center justify-between p-3"
|
||||
onclick={() => {
|
||||
autoExpanded = false; // user choice overrides auto behavior
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<Brain class="h-4 w-4" />
|
||||
|
||||
|
|
@ -59,7 +81,11 @@
|
|||
<div class="border-t border-muted px-3 pb-3">
|
||||
<div class="pt-3">
|
||||
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
|
||||
{reasoningContent ?? ''}
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{reasoningContent ?? ''}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { ChatMessage } from '$lib/components/app';
|
||||
import { setChatActionsContext } from '$lib/contexts';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { copyToClipboard, formatMessageForClipboard, getMessageSiblings } from '$lib/utils';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type {
|
||||
ApiChatCompletionToolCall,
|
||||
ChatMessageSiblingInfo,
|
||||
ChatMessageTimings,
|
||||
DatabaseMessage
|
||||
} from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -15,6 +21,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<DatabaseMessage[]>([]);
|
||||
const currentConfig = config();
|
||||
|
||||
|
|
@ -28,56 +39,38 @@
|
|||
);
|
||||
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
|
||||
},
|
||||
|
||||
delete: async (message: DatabaseMessage) => {
|
||||
await chatStore.deleteMessage(message.id);
|
||||
refreshAllMessages();
|
||||
await handleDeleteMessage(message);
|
||||
},
|
||||
|
||||
navigateToSibling: async (siblingId: string) => {
|
||||
await conversationsStore.navigateToSibling(siblingId);
|
||||
await handleNavigateToSibling(siblingId);
|
||||
},
|
||||
|
||||
editWithBranching: async (
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => {
|
||||
onUserAction?.();
|
||||
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
|
||||
refreshAllMessages();
|
||||
await handleEditWithBranching(message, newContent, newExtras);
|
||||
},
|
||||
|
||||
editWithReplacement: async (
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
shouldBranch: boolean
|
||||
) => {
|
||||
onUserAction?.();
|
||||
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
|
||||
refreshAllMessages();
|
||||
await handleEditWithReplacement(message, newContent, shouldBranch);
|
||||
},
|
||||
|
||||
editUserMessagePreserveResponses: async (
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => {
|
||||
onUserAction?.();
|
||||
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
|
||||
refreshAllMessages();
|
||||
await handleEditUserMessagePreserveResponses(message, newContent, newExtras);
|
||||
},
|
||||
|
||||
regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
|
||||
onUserAction?.();
|
||||
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
|
||||
refreshAllMessages();
|
||||
await handleRegenerateWithBranching(message, modelOverride);
|
||||
},
|
||||
|
||||
continueAssistantMessage: async (message: DatabaseMessage) => {
|
||||
onUserAction?.();
|
||||
await chatStore.continueAssistantMessage(message.id);
|
||||
refreshAllMessages();
|
||||
await handleContinueAssistantMessage(message);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -102,51 +95,366 @@
|
|||
}
|
||||
});
|
||||
|
||||
let displayMessages = $derived.by(() => {
|
||||
if (!messages.length) {
|
||||
return [];
|
||||
}
|
||||
type ToolSegment =
|
||||
| { kind: 'content'; content: string; parentId: string }
|
||||
| { kind: 'thinking'; content: string }
|
||||
| {
|
||||
kind: 'tool';
|
||||
toolCalls: ApiChatCompletionToolCall[];
|
||||
parentId: string;
|
||||
inThinking: boolean;
|
||||
};
|
||||
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 !== MessageRole.SYSTEM);
|
||||
? sourceMessages
|
||||
: sourceMessages.filter((msg) => msg.type !== 'system');
|
||||
const sortedFilteredMessages = [...filteredMessages].sort(
|
||||
(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0) || a.id.localeCompare(b.id)
|
||||
);
|
||||
|
||||
let lastAssistantIndex = -1;
|
||||
const visited = new SvelteSet<string>();
|
||||
const result: DisplayEntry[] = [];
|
||||
|
||||
for (let i = filteredMessages.length - 1; i >= 0; i--) {
|
||||
if (filteredMessages[i].role === MessageRole.ASSISTANT) {
|
||||
lastAssistantIndex = i;
|
||||
const getChildren = (parentId: string, role?: string) =>
|
||||
sortedFilteredMessages
|
||||
.filter((m) => m.parent === parentId && (!role || m.role === role))
|
||||
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0) || a.id.localeCompare(b.id));
|
||||
|
||||
break;
|
||||
const normalizeToolParsed = (
|
||||
value: unknown
|
||||
): { expression?: string; result?: string; duration_ms?: number } | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return {
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
const sumTimings = (assistantIds: string[]): ChatMessageTimings | undefined => {
|
||||
if (assistantIds.length <= 1) return undefined;
|
||||
|
||||
let predicted_n_sum = 0;
|
||||
let predicted_ms_sum = 0;
|
||||
let prompt_n_sum = 0;
|
||||
let prompt_ms_sum = 0;
|
||||
let cache_n_sum = 0;
|
||||
|
||||
let hasPredicted = false;
|
||||
let hasPrompt = false;
|
||||
let hasCache = false;
|
||||
|
||||
for (const id of assistantIds) {
|
||||
const m = filteredMessages.find((x) => x.id === id);
|
||||
const t = m?.timings;
|
||||
if (!t) continue;
|
||||
|
||||
if (typeof t.predicted_n === 'number' && typeof t.predicted_ms === 'number') {
|
||||
predicted_n_sum += t.predicted_n;
|
||||
predicted_ms_sum += t.predicted_ms;
|
||||
hasPredicted = true;
|
||||
}
|
||||
|
||||
if (typeof t.prompt_n === 'number' && typeof t.prompt_ms === 'number') {
|
||||
prompt_n_sum += t.prompt_n;
|
||||
prompt_ms_sum += t.prompt_ms;
|
||||
hasPrompt = true;
|
||||
}
|
||||
|
||||
if (typeof t.cache_n === 'number') {
|
||||
cache_n_sum += t.cache_n;
|
||||
hasCache = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredMessages.map((message, index) => {
|
||||
const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
|
||||
const isLastAssistantMessage =
|
||||
message.role === MessageRole.ASSISTANT && index === lastAssistantIndex;
|
||||
if (!hasPredicted && !hasPrompt && !hasCache) return undefined;
|
||||
|
||||
return {
|
||||
message,
|
||||
isLastAssistantMessage,
|
||||
siblingInfo: siblingInfo || {
|
||||
message,
|
||||
siblingIds: [message.id],
|
||||
...(hasPredicted ? { predicted_n: predicted_n_sum, predicted_ms: predicted_ms_sum } : {}),
|
||||
...(hasPrompt ? { prompt_n: prompt_n_sum, prompt_ms: prompt_ms_sum } : {}),
|
||||
...(hasCache ? { cache_n: cache_n_sum } : {})
|
||||
};
|
||||
};
|
||||
|
||||
for (const msg of sortedFilteredMessages) {
|
||||
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 contentParts: string[] = [];
|
||||
const toolCallsCombined: ApiChatCompletionToolCall[] = [];
|
||||
const segments: ToolSegment[] = [];
|
||||
const toolMessagesCollected: CollectedToolMessage[] = [];
|
||||
const toolCallIds = new SvelteSet<string>();
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const hasContent = Boolean(currentAssistant.content?.trim());
|
||||
if (hasContent) {
|
||||
contentParts.push(currentAssistant.content);
|
||||
segments.push({
|
||||
kind: 'content',
|
||||
content: currentAssistant.content,
|
||||
parentId: currentAssistant.id
|
||||
});
|
||||
}
|
||||
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) {
|
||||
const toolCallsInThinking = Boolean(currentAssistant.thinking) && !hasContent;
|
||||
segments.push({
|
||||
kind: 'tool',
|
||||
toolCalls: thisAssistantToolCalls,
|
||||
parentId: currentAssistant.id,
|
||||
// Treat tool calls as in-reasoning only when this assistant step is still thought-only.
|
||||
inThinking: toolCallsInThinking
|
||||
});
|
||||
}
|
||||
|
||||
const directToolChildren = getChildren(currentAssistant.id, 'tool');
|
||||
const toolChildren: DatabaseMessage[] = [];
|
||||
const toolQueue = [...directToolChildren];
|
||||
const seenToolIds = new SvelteSet<string>();
|
||||
let nextAssistantFromToolChain: DatabaseMessage | undefined;
|
||||
|
||||
while (toolQueue.length > 0) {
|
||||
const toolMsg = toolQueue.shift();
|
||||
if (!toolMsg || seenToolIds.has(toolMsg.id)) continue;
|
||||
seenToolIds.add(toolMsg.id);
|
||||
toolChildren.push(toolMsg);
|
||||
|
||||
const nestedTools = getChildren(toolMsg.id, 'tool');
|
||||
if (nestedTools.length) {
|
||||
toolQueue.push(...nestedTools);
|
||||
}
|
||||
|
||||
const assistantChildren = getChildren(toolMsg.id, 'assistant');
|
||||
const candidate = assistantChildren[0];
|
||||
if (candidate) {
|
||||
const candidateKey = `${candidate.timestamp ?? 0}-${candidate.id}`;
|
||||
const currentKey = nextAssistantFromToolChain
|
||||
? `${nextAssistantFromToolChain.timestamp ?? 0}-${nextAssistantFromToolChain.id}`
|
||||
: '';
|
||||
if (!nextAssistantFromToolChain || candidateKey < currentKey) {
|
||||
nextAssistantFromToolChain = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Follow continuation assistant from the collected tool chain first.
|
||||
let nextAssistant = nextAssistantFromToolChain;
|
||||
|
||||
// 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 aggregatedTimings = sumTimings(toolParentIds);
|
||||
|
||||
const mergedAssistant: AssistantDisplayMessage = {
|
||||
...(currentAssistant ?? msg),
|
||||
// Keep a plain-text combined content for edit/copy; display can use `_segments` for ordering.
|
||||
content: contentParts.filter(Boolean).join('\n\n'),
|
||||
thinking: thinkingParts.filter(Boolean).join('\n\n'),
|
||||
toolCalls: toolCallsCombined.length ? JSON.stringify(toolCallsCombined) : '',
|
||||
...(aggregatedTimings ? { timings: aggregatedTimings } : {}),
|
||||
_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);
|
||||
}
|
||||
|
||||
async function handleEditWithBranching(
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) {
|
||||
onUserAction?.();
|
||||
|
||||
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
async function handleEditWithReplacement(
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
shouldBranch: boolean
|
||||
) {
|
||||
onUserAction?.();
|
||||
|
||||
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
|
||||
onUserAction?.();
|
||||
|
||||
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
async function handleContinueAssistantMessage(message: DatabaseMessage) {
|
||||
onUserAction?.();
|
||||
|
||||
await chatStore.continueAssistantMessage(message.id);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
async function handleEditUserMessagePreserveResponses(
|
||||
message: DatabaseMessage,
|
||||
newContent: string,
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) {
|
||||
onUserAction?.();
|
||||
|
||||
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
|
||||
async function handleDeleteMessage(message: DatabaseMessage) {
|
||||
await chatStore.deleteMessage(message.id);
|
||||
|
||||
refreshAllMessages();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col space-y-10 pt-24 {className}" style="height: auto; ">
|
||||
{#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)}
|
||||
<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
|
||||
{#each displayMessages as { message, siblingInfo } (getDisplayKeyForMessage(message))}
|
||||
<ChatMessage
|
||||
class="mx-auto w-full max-w-[48rem]"
|
||||
{message}
|
||||
{isLastAssistantMessage}
|
||||
{siblingInfo}
|
||||
toolParentIds={getToolParentIdsForMessage(message)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>): 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof setInterval> | undefined;
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let showFileErrorDialog = $state(false);
|
||||
let uploadedFiles = $state<ChatUploadedFile[]>([]);
|
||||
|
||||
const autoScroll = createAutoScrollController();
|
||||
let userScrolledUp = $state(false);
|
||||
let lastPinnedMessageCount = $state(0);
|
||||
let lastPinnedTailId = $state<string | null>(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<boolean> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -361,10 +435,13 @@
|
|||
>
|
||||
<ChatMessages
|
||||
class="mb-16 md:mb-24"
|
||||
messages={activeMessages()}
|
||||
messages={liveMessages}
|
||||
onUserAction={() => {
|
||||
autoScroll.enable();
|
||||
autoScroll.scrollToBottom();
|
||||
if (!disableAutoScroll) {
|
||||
userScrolledUp = false;
|
||||
autoScrollEnabled = true;
|
||||
scrollChatToBottom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -428,7 +505,7 @@
|
|||
>
|
||||
<div class="w-full max-w-[48rem] px-4">
|
||||
<div class="mb-10 text-center" in:fade={{ duration: 300 }}>
|
||||
<h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
|
||||
<h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
|
||||
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{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}
|
||||
/>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@
|
|||
AlertTriangle,
|
||||
Code,
|
||||
Monitor,
|
||||
Sun,
|
||||
Moon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Database
|
||||
Database,
|
||||
Wrench
|
||||
} from '@lucide/svelte';
|
||||
import {
|
||||
ChatSettingsFooter,
|
||||
|
|
@ -21,12 +24,9 @@
|
|||
type SettingsSectionTitle
|
||||
} from '$lib/constants/settings-sections';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { ColorMode } from '$lib/enums/ui';
|
||||
import { SettingsFieldType } from '$lib/enums/settings';
|
||||
import type { Component } from 'svelte';
|
||||
import { NUMERIC_FIELDS, POSITIVE_INTEGER_FIELDS } from '$lib/constants/settings-fields';
|
||||
import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
|
||||
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
|
||||
import '$lib/services/tools'; // ensure built-in tools register
|
||||
import { getAllTools } from '$lib/services/tools';
|
||||
|
||||
interface Props {
|
||||
onSave?: () => void;
|
||||
|
|
@ -35,259 +35,296 @@
|
|||
|
||||
let { onSave, initialSection }: Props = $props();
|
||||
|
||||
const settingSections: Array<{
|
||||
fields: SettingsFieldConfig[];
|
||||
icon: Component;
|
||||
title: SettingsSectionTitle;
|
||||
}> = [
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.GENERAL,
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.THEME,
|
||||
label: 'Theme',
|
||||
type: SettingsFieldType.SELECT,
|
||||
options: SETTINGS_COLOR_MODES_CONFIG
|
||||
},
|
||||
{ key: SETTINGS_KEYS.API_KEY, label: 'API Key', type: SettingsFieldType.INPUT },
|
||||
{
|
||||
key: SETTINGS_KEYS.SYSTEM_MESSAGE,
|
||||
label: 'System Message',
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
|
||||
label: 'Paste long text to file length',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
|
||||
label: 'Copy text attachments as plain text',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
|
||||
label: 'Enable "Continue" button',
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.PDF_AS_IMAGE,
|
||||
label: 'Parse PDF as image',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
|
||||
label: 'Ask for confirmation before changing conversation title',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.DISPLAY,
|
||||
icon: Monitor,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
|
||||
label: 'Show message generation statistics',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
|
||||
label: 'Show thought in progress',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
|
||||
label: 'Keep stats visible after generation',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
|
||||
label: 'Show microphone on empty input',
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
|
||||
label: 'Render user content as Markdown',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
|
||||
label: 'Use full height code blocks',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
|
||||
label: 'Disable automatic scroll',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
|
||||
label: 'Always show sidebar on desktop',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT,
|
||||
label: 'Auto-show sidebar on new chat',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.SAMPLING,
|
||||
icon: Funnel,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.TEMPERATURE,
|
||||
label: 'Temperature',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DYNATEMP_RANGE,
|
||||
label: 'Dynamic temperature range',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
|
||||
label: 'Dynamic temperature exponent',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TOP_K,
|
||||
label: 'Top K',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TOP_P,
|
||||
label: 'Top P',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.MIN_P,
|
||||
label: 'Min P',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.XTC_PROBABILITY,
|
||||
label: 'XTC probability',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.XTC_THRESHOLD,
|
||||
label: 'XTC threshold',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TYP_P,
|
||||
label: 'Typical P',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.MAX_TOKENS,
|
||||
label: 'Max tokens',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SAMPLERS,
|
||||
label: 'Samplers',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.BACKEND_SAMPLING,
|
||||
label: 'Backend sampling',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.PENALTIES,
|
||||
icon: AlertTriangle,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.REPEAT_LAST_N,
|
||||
label: 'Repeat last N',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.REPEAT_PENALTY,
|
||||
label: 'Repeat penalty',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.PRESENCE_PENALTY,
|
||||
label: 'Presence penalty',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
|
||||
label: 'Frequency penalty',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_MULTIPLIER,
|
||||
label: 'DRY multiplier',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_BASE,
|
||||
label: 'DRY base',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
|
||||
label: 'DRY allowed length',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
|
||||
label: 'DRY penalty last N',
|
||||
type: SettingsFieldType.INPUT
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
|
||||
icon: Database,
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.DEVELOPER,
|
||||
icon: Code,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
|
||||
label: 'Disable reasoning content parsing',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
|
||||
label: 'Enable raw output toggle',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.CUSTOM,
|
||||
label: 'Custom JSON',
|
||||
type: SettingsFieldType.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: SettingsSectionTitle;
|
||||
}> => [
|
||||
{
|
||||
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: 'fullHeightCodeBlocks',
|
||||
label: 'Use full height code blocks',
|
||||
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'
|
||||
},
|
||||
{
|
||||
key: 'backend_sampling',
|
||||
label: 'Backend sampling',
|
||||
type: 'checkbox'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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: 'disableReasoningParsing',
|
||||
label: 'Disable reasoning content parsing',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'showRawOutputSwitch',
|
||||
label: 'Enable raw output toggle',
|
||||
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 = $derived<SettingsSectionTitle>(
|
||||
initialSection ?? SETTINGS_SECTION_TITLES.GENERAL
|
||||
|
|
@ -295,14 +332,17 @@
|
|||
let currentSection = $derived(
|
||||
settingSections.find((section) => section.title === activeSection) || settingSections[0]
|
||||
);
|
||||
let localConfig: SettingsConfigType = $state({ ...config() });
|
||||
|
||||
let canScrollLeft = $state(false);
|
||||
let canScrollRight = $state(false);
|
||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (initialSection) {
|
||||
if (!initialSection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingSections.some((section) => section.title === initialSection)) {
|
||||
activeSection = initialSection;
|
||||
}
|
||||
});
|
||||
|
|
@ -310,7 +350,7 @@
|
|||
function handleThemeChange(newTheme: string) {
|
||||
localConfig.theme = newTheme;
|
||||
|
||||
setMode(newTheme as ColorMode);
|
||||
setMode(newTheme as 'light' | 'dark' | 'system');
|
||||
}
|
||||
|
||||
function handleConfigChange(key: string, value: string | boolean) {
|
||||
|
|
@ -320,7 +360,7 @@
|
|||
function handleReset() {
|
||||
localConfig = { ...config() };
|
||||
|
||||
setMode(localConfig.theme as ColorMode);
|
||||
setMode(localConfig.theme as 'light' | 'dark' | 'system');
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
|
|
@ -336,16 +376,34 @@
|
|||
|
||||
// Convert numeric strings to numbers for numeric fields
|
||||
const processedConfig = { ...localConfig };
|
||||
const numericFields = [
|
||||
'temperature',
|
||||
'top_k',
|
||||
'top_p',
|
||||
'min_p',
|
||||
'max_tokens',
|
||||
'pasteLongTextToFileLen',
|
||||
'dynatemp_range',
|
||||
'dynatemp_exponent',
|
||||
'typ_p',
|
||||
'xtc_probability',
|
||||
'xtc_threshold',
|
||||
'repeat_last_n',
|
||||
'repeat_penalty',
|
||||
'presence_penalty',
|
||||
'frequency_penalty',
|
||||
'dry_multiplier',
|
||||
'dry_base',
|
||||
'dry_allowed_length',
|
||||
'dry_penalty_last_n',
|
||||
'codeInterpreterTimeoutSeconds'
|
||||
];
|
||||
|
||||
for (const field of NUMERIC_FIELDS) {
|
||||
for (const field of numericFields) {
|
||||
if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
|
||||
const numValue = Number(processedConfig[field]);
|
||||
if (!isNaN(numValue)) {
|
||||
if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
|
||||
processedConfig[field] = Math.max(1, Math.round(numValue));
|
||||
} else {
|
||||
processedConfig[field] = numValue;
|
||||
}
|
||||
processedConfig[field] = numValue;
|
||||
} else {
|
||||
alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
|
||||
return;
|
||||
|
|
@ -484,7 +542,7 @@
|
|||
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
|
||||
</div>
|
||||
|
||||
{#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
|
||||
{#if currentSection.title === 'Import/Export'}
|
||||
<ChatSettingsImportExportTab />
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@
|
|||
<Input
|
||||
id={field.key}
|
||||
value={currentValue}
|
||||
disabled={Boolean(field.disabled)}
|
||||
oninput={(e) => {
|
||||
// Update local config immediately for real-time badge feedback
|
||||
onConfigChange(field.key, e.currentTarget.value);
|
||||
|
|
@ -112,6 +113,7 @@
|
|||
<Textarea
|
||||
id={field.key}
|
||||
value={String(localConfig[field.key] ?? '')}
|
||||
disabled={Boolean(field.disabled)}
|
||||
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
||||
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] ?? 'none'}`}
|
||||
class="min-h-[10rem] w-full md:max-w-2xl"
|
||||
|
|
@ -167,6 +169,7 @@
|
|||
<Select.Root
|
||||
type="single"
|
||||
value={currentValue}
|
||||
disabled={Boolean(field.disabled)}
|
||||
onValueChange={(value) => {
|
||||
if (field.key === SETTINGS_KEYS.THEME && value && onThemeChange) {
|
||||
onThemeChange(value);
|
||||
|
|
@ -229,6 +232,7 @@
|
|||
<Checkbox
|
||||
id={field.key}
|
||||
checked={Boolean(localConfig[field.key])}
|
||||
disabled={Boolean(field.disabled)}
|
||||
onCheckedChange={(checked) => onConfigChange(field.key, checked)}
|
||||
class="mt-1"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { ColorMode } from '$lib/enums/ui';
|
||||
import { Monitor, Moon, Sun } from '@lucide/svelte';
|
||||
// Ensure all built-in tools are registered before deriving defaults
|
||||
import '$lib/services/tools';
|
||||
import { getToolConfigDefaults, getToolSettingDefaults } from '$lib/services/tools/registry';
|
||||
|
||||
export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> = {
|
||||
const BASE_SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> = {
|
||||
// Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value.
|
||||
// Do not use nested objects, keep it single level. Prefix the key if you need to group them.
|
||||
apiKey: '',
|
||||
systemMessage: '',
|
||||
showSystemMessage: true,
|
||||
theme: ColorMode.SYSTEM,
|
||||
theme: 'system',
|
||||
showThoughtInProgress: false,
|
||||
showToolCalls: false,
|
||||
disableReasoningParsing: false,
|
||||
showRawOutputSwitch: false,
|
||||
keepStatsVisible: false,
|
||||
|
|
@ -50,6 +52,12 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
|
|||
enableContinueGeneration: false
|
||||
};
|
||||
|
||||
export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> = {
|
||||
...BASE_SETTING_CONFIG_DEFAULT,
|
||||
...getToolSettingDefaults(),
|
||||
...getToolConfigDefaults()
|
||||
};
|
||||
|
||||
export const SETTING_CONFIG_INFO: Record<string, string> = {
|
||||
apiKey: 'Set the API Key if you are using <code>--api-key</code> option for the server.',
|
||||
systemMessage: 'The starting message that defines how model should behave.',
|
||||
|
|
@ -94,10 +102,16 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
|
|||
max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).',
|
||||
custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
|
||||
showThoughtInProgress: 'Expand thought process by default when generating messages.',
|
||||
showToolCalls:
|
||||
'Display tool call labels and payloads from Harmony-compatible delta.tool_calls data below assistant messages.',
|
||||
disableReasoningParsing:
|
||||
'Send reasoning_format=none to prevent server-side extraction of reasoning tokens into separate field',
|
||||
showRawOutputSwitch:
|
||||
'Show toggle button to display messages as plain text instead of Markdown-formatted content',
|
||||
enableCalculatorTool:
|
||||
'Expose a simple calculator tool to the model. When the model calls it, the web UI evaluates the expression locally and resumes generation with the result.',
|
||||
enableCodeInterpreterTool:
|
||||
'Expose a JavaScript code interpreter to the model. Code runs in a sandboxed Worker and the result is sent back to the conversation.',
|
||||
keepStatsVisible: 'Keep processing statistics visible after generation finishes.',
|
||||
showMessageStats:
|
||||
'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
|
||||
|
|
@ -121,9 +135,3 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
|
|||
enableContinueGeneration:
|
||||
'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.'
|
||||
};
|
||||
|
||||
export const SETTINGS_COLOR_MODES_CONFIG = [
|
||||
{ value: ColorMode.SYSTEM, label: 'System', icon: Monitor },
|
||||
{ value: ColorMode.LIGHT, label: 'Light', icon: Sun },
|
||||
{ value: ColorMode.DARK, label: 'Dark', icon: Moon }
|
||||
];
|
||||
|
|
|
|||
|
|
@ -23,6 +23,22 @@ export interface MessageEditActions {
|
|||
export type MessageEditContext = MessageEditState & MessageEditActions;
|
||||
|
||||
const MESSAGE_EDIT_KEY = Symbol.for('chat-message-edit');
|
||||
const DEFAULT_MESSAGE_EDIT_CONTEXT: MessageEditContext = {
|
||||
isEditing: false,
|
||||
editedContent: '',
|
||||
editedExtras: [],
|
||||
editedUploadedFiles: [],
|
||||
originalContent: '',
|
||||
originalExtras: [],
|
||||
showSaveOnlyOption: false,
|
||||
setContent: () => {},
|
||||
setExtras: () => {},
|
||||
setUploadedFiles: () => {},
|
||||
save: () => {},
|
||||
saveOnly: () => {},
|
||||
cancel: () => {},
|
||||
startEdit: () => {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the message edit context. Call this in the parent component (ChatMessage.svelte).
|
||||
|
|
@ -35,5 +51,5 @@ export function setMessageEditContext(ctx: MessageEditContext): MessageEditConte
|
|||
* Gets the message edit context. Call this in child components.
|
||||
*/
|
||||
export function getMessageEditContext(): MessageEditContext {
|
||||
return getContext(MESSAGE_EDIT_KEY);
|
||||
return getContext<MessageEditContext | undefined>(MESSAGE_EDIT_KEY) ?? DEFAULT_MESSAGE_EDIT_CONTEXT;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,44 @@
|
|||
import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
|
||||
import { ATTACHMENT_LABEL_PDF_FILE } from '$lib/constants/attachment-labels';
|
||||
import {
|
||||
AttachmentType,
|
||||
ContentPartType,
|
||||
MessageRole,
|
||||
ReasoningFormat,
|
||||
UrlPrefix
|
||||
} from '$lib/enums';
|
||||
import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
|
||||
import { getJsonHeaders } from '$lib/utils';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { AGENTIC_REGEX } from '$lib/constants/agentic';
|
||||
import type { ChatRole, ApiToolDefinition, ApiChatCompletionRequestMessage } from '$lib/types';
|
||||
|
||||
/**
|
||||
* ChatService - Low-level API communication layer for Chat Completions
|
||||
*
|
||||
* **Terminology - Chat vs Conversation:**
|
||||
* - **Chat**: The active interaction space with the Chat Completions API. This service
|
||||
* handles the real-time communication with the AI backend - sending messages, receiving
|
||||
* streaming responses, and managing request lifecycles. "Chat" is ephemeral and runtime-focused.
|
||||
* - **Conversation**: The persistent database entity storing all messages and metadata.
|
||||
* Managed by ConversationsService/Store, conversations persist across sessions.
|
||||
*
|
||||
* This service handles direct communication with the llama-server's Chat Completions API.
|
||||
* It provides the network layer abstraction for AI model interactions while remaining
|
||||
* stateless and focused purely on API communication.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ChatService** (this class): Stateless API communication layer
|
||||
* - Handles HTTP requests/responses with the llama-server
|
||||
* - Manages streaming and non-streaming response parsing
|
||||
* - Provides per-conversation request abortion capabilities
|
||||
* - Converts database messages to API format
|
||||
* - Handles error translation for server responses
|
||||
*
|
||||
* - **chatStore**: Uses ChatService for all AI model communication
|
||||
* - **conversationsStore**: Provides message context for API requests
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Message format conversion (DatabaseMessage → API format)
|
||||
* - Streaming response handling with real-time callbacks
|
||||
* - Reasoning content extraction and processing
|
||||
* - File attachment processing (images, PDFs, audio, text)
|
||||
* - Request lifecycle management (abort via AbortSignal)
|
||||
*/
|
||||
export class ChatService {
|
||||
private static stripReasoningContent(
|
||||
content: ApiChatMessageData['content'] | null | undefined
|
||||
): ApiChatMessageData['content'] | null | undefined {
|
||||
if (!content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return content
|
||||
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
|
||||
.replace(AGENTIC_REGEX.REASONING_OPEN, '');
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return content.map((part: ApiChatMessageContentPart) => {
|
||||
if (part.type !== ContentPartType.TEXT || !part.text) return part;
|
||||
return {
|
||||
...part,
|
||||
text: part.text
|
||||
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
|
||||
.replace(AGENTIC_REGEX.REASONING_OPEN, '')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Messaging
|
||||
*
|
||||
*
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Messaging
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sends a chat completion request to the llama.cpp server.
|
||||
|
|
@ -73,8 +65,6 @@ export class ChatService {
|
|||
onToolCallChunk,
|
||||
onModel,
|
||||
onTimings,
|
||||
// Tools for function calling
|
||||
tools,
|
||||
// Generation parameters
|
||||
temperature,
|
||||
max_tokens,
|
||||
|
|
@ -101,6 +91,8 @@ export class ChatService {
|
|||
backend_sampling,
|
||||
custom,
|
||||
timings_per_token,
|
||||
tools,
|
||||
tool_choice,
|
||||
// Config options
|
||||
disableReasoningParsing
|
||||
} = options;
|
||||
|
|
@ -109,7 +101,6 @@ export class ChatService {
|
|||
.map((msg) => {
|
||||
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
|
||||
const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
|
||||
|
||||
return ChatService.convertDbMessageToApiChatMessageData(dbMsg);
|
||||
} else {
|
||||
return msg as ApiChatMessageData;
|
||||
|
|
@ -117,7 +108,7 @@ export class ChatService {
|
|||
})
|
||||
.filter((msg) => {
|
||||
// Filter out empty system messages
|
||||
if (msg.role === MessageRole.SYSTEM) {
|
||||
if (msg.role === 'system') {
|
||||
const content = typeof msg.content === 'string' ? msg.content : '';
|
||||
|
||||
return content.trim().length > 0;
|
||||
|
|
@ -126,25 +117,20 @@ export class ChatService {
|
|||
return true;
|
||||
});
|
||||
|
||||
// Filter out image attachments if the model doesn't support vision
|
||||
// Filter out image parts when targeting a non-vision model.
|
||||
if (options.model && !modelsStore.modelSupportsVision(options.model)) {
|
||||
normalizedMessages.forEach((msg) => {
|
||||
if (Array.isArray(msg.content)) {
|
||||
msg.content = msg.content.filter((part: ApiChatMessageContentPart) => {
|
||||
if (part.type === ContentPartType.IMAGE_URL) {
|
||||
console.info(
|
||||
`[ChatService] Skipping image attachment in message history (model "${options.model}" does not support vision)`
|
||||
);
|
||||
if (!Array.isArray(msg.content)) return;
|
||||
|
||||
return false;
|
||||
}
|
||||
msg.content = msg.content.filter((part) => part.type !== 'image_url');
|
||||
|
||||
return true;
|
||||
});
|
||||
// If only text remains and it's a single part, simplify to string
|
||||
if (msg.content.length === 1 && msg.content[0].type === ContentPartType.TEXT) {
|
||||
msg.content = msg.content[0].text;
|
||||
}
|
||||
// Normalize back to string when only one text part remains.
|
||||
if (
|
||||
msg.content.length === 1 &&
|
||||
msg.content[0] &&
|
||||
msg.content[0].type === 'text'
|
||||
) {
|
||||
msg.content = msg.content[0].text;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -152,15 +138,15 @@ export class ChatService {
|
|||
const requestBody: ApiChatCompletionRequest = {
|
||||
messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
|
||||
role: msg.role,
|
||||
// Strip reasoning tags/content from the prompt to avoid polluting KV cache.
|
||||
// TODO: investigate backend expectations for reasoning tags and add a toggle if needed.
|
||||
content: ChatService.stripReasoningContent(msg.content),
|
||||
tool_calls: msg.tool_calls,
|
||||
tool_call_id: msg.tool_call_id
|
||||
content: msg.content,
|
||||
...(msg.reasoning_content ? { reasoning_content: msg.reasoning_content } : {}),
|
||||
...((msg as ApiChatCompletionRequestMessage).tool_call_id
|
||||
? { tool_call_id: (msg as ApiChatCompletionRequestMessage).tool_call_id }
|
||||
: {}),
|
||||
...(msg.tool_calls ? { tool_calls: msg.tool_calls } : {})
|
||||
})),
|
||||
stream,
|
||||
return_progress: stream ? true : undefined,
|
||||
tools: tools && tools.length > 0 ? tools : undefined
|
||||
return_progress: stream ? true : undefined
|
||||
};
|
||||
|
||||
// Include model in request if provided (required in ROUTER mode)
|
||||
|
|
@ -168,9 +154,7 @@ export class ChatService {
|
|||
requestBody.model = options.model;
|
||||
}
|
||||
|
||||
requestBody.reasoning_format = disableReasoningParsing
|
||||
? ReasoningFormat.NONE
|
||||
: ReasoningFormat.AUTO;
|
||||
requestBody.reasoning_format = disableReasoningParsing ? 'none' : 'auto';
|
||||
|
||||
if (temperature !== undefined) requestBody.temperature = temperature;
|
||||
if (max_tokens !== undefined) {
|
||||
|
|
@ -216,6 +200,13 @@ export class ChatService {
|
|||
}
|
||||
}
|
||||
|
||||
if (tools) {
|
||||
requestBody.tools = tools as unknown as ApiToolDefinition[];
|
||||
}
|
||||
if (tool_choice) {
|
||||
requestBody.tool_choice = tool_choice;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`./v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
|
|
@ -226,11 +217,9 @@ export class ChatService {
|
|||
|
||||
if (!response.ok) {
|
||||
const error = await ChatService.parseErrorResponse(response);
|
||||
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +236,6 @@ export class ChatService {
|
|||
conversationId,
|
||||
signal
|
||||
);
|
||||
|
||||
return;
|
||||
} else {
|
||||
return ChatService.handleNonStreamResponse(
|
||||
|
|
@ -259,7 +247,7 @@ export class ChatService {
|
|||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.log('Chat completion request was aborted');
|
||||
return;
|
||||
}
|
||||
|
|
@ -286,22 +274,16 @@ export class ChatService {
|
|||
}
|
||||
|
||||
console.error('Error in sendMessage:', error);
|
||||
|
||||
if (onError) {
|
||||
onError(userFriendlyError);
|
||||
}
|
||||
|
||||
throw userFriendlyError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Streaming
|
||||
*
|
||||
*
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Streaming
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handles streaming response from the chat completion API
|
||||
|
|
@ -375,10 +357,6 @@ export class ChatService {
|
|||
|
||||
const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
|
||||
}
|
||||
|
||||
if (!serializedToolCalls) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -405,11 +383,10 @@ export class ChatService {
|
|||
for (const line of lines) {
|
||||
if (abortSignal?.aborted) break;
|
||||
|
||||
if (line.startsWith(UrlPrefix.DATA)) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
streamFinished = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -515,7 +492,6 @@ export class ChatService {
|
|||
|
||||
if (!responseText.trim()) {
|
||||
const noResponseError = new Error('No response received from server. Please try again.');
|
||||
|
||||
throw noResponseError;
|
||||
}
|
||||
|
||||
|
|
@ -534,18 +510,13 @@ export class ChatService {
|
|||
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
const mergedToolCalls = ChatService.mergeToolCallDeltas([], toolCalls);
|
||||
|
||||
if (mergedToolCalls.length > 0) {
|
||||
serializedToolCalls = JSON.stringify(mergedToolCalls);
|
||||
if (serializedToolCalls) {
|
||||
onToolCallChunk?.(serializedToolCalls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!content.trim() && !serializedToolCalls) {
|
||||
const noResponseError = new Error('No response received from server. Please try again.');
|
||||
|
||||
throw noResponseError;
|
||||
}
|
||||
|
||||
|
|
@ -618,13 +589,9 @@ export class ChatService {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Conversion
|
||||
*
|
||||
*
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Conversion
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts a database message with attachments to API chat message format.
|
||||
|
|
@ -641,48 +608,40 @@ export class ChatService {
|
|||
static convertDbMessageToApiChatMessageData(
|
||||
message: DatabaseMessage & { extra?: DatabaseMessageExtra[] }
|
||||
): ApiChatMessageData {
|
||||
// Handle tool result messages (role: 'tool')
|
||||
if (message.role === MessageRole.TOOL && message.toolCallId) {
|
||||
return {
|
||||
role: MessageRole.TOOL,
|
||||
content: message.content,
|
||||
tool_call_id: message.toolCallId
|
||||
};
|
||||
}
|
||||
|
||||
// Parse tool calls for assistant messages
|
||||
let toolCalls: ApiChatCompletionToolCall[] | undefined;
|
||||
if (message.toolCalls) {
|
||||
let toolCalls: ApiChatCompletionToolCallDelta[] | undefined;
|
||||
if (message.role === 'assistant' && message.toolCalls) {
|
||||
try {
|
||||
toolCalls = JSON.parse(message.toolCalls);
|
||||
const parsed = JSON.parse(message.toolCalls);
|
||||
if (Array.isArray(parsed)) {
|
||||
toolCalls = parsed as ApiChatCompletionToolCallDelta[];
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors for malformed tool calls
|
||||
// ignore malformed toolCalls; UI will still show raw string if needed
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.extra || message.extra.length === 0) {
|
||||
const result: ApiChatMessageData = {
|
||||
role: message.role as MessageRole,
|
||||
content: message.content
|
||||
return {
|
||||
role: message.role as ChatRole,
|
||||
content: message.content,
|
||||
...(message.role === 'assistant' && message.thinking
|
||||
? { reasoning_content: message.thinking }
|
||||
: {}),
|
||||
// tool_call_id is only relevant for tool role messages
|
||||
...(message.toolCallId ? { tool_call_id: message.toolCallId } : {}),
|
||||
...(toolCalls ? { tool_calls: toolCalls } : {})
|
||||
};
|
||||
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
result.tool_calls = toolCalls;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const contentParts: ApiChatMessageContentPart[] = [];
|
||||
|
||||
if (message.content) {
|
||||
contentParts.push({
|
||||
type: ContentPartType.TEXT,
|
||||
type: 'text',
|
||||
text: message.content
|
||||
});
|
||||
}
|
||||
|
||||
// Include images from all messages
|
||||
const imageFiles = message.extra.filter(
|
||||
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
|
||||
extra.type === AttachmentType.IMAGE
|
||||
|
|
@ -690,7 +649,7 @@ export class ChatService {
|
|||
|
||||
for (const image of imageFiles) {
|
||||
contentParts.push({
|
||||
type: ContentPartType.IMAGE_URL,
|
||||
type: 'image_url',
|
||||
image_url: { url: image.base64Url }
|
||||
});
|
||||
}
|
||||
|
|
@ -702,8 +661,8 @@ export class ChatService {
|
|||
|
||||
for (const textFile of textFiles) {
|
||||
contentParts.push({
|
||||
type: ContentPartType.TEXT,
|
||||
text: formatAttachmentText('File', textFile.name, textFile.content)
|
||||
type: 'text',
|
||||
text: `\n\n--- File: ${textFile.name} ---\n${textFile.content}`
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -715,8 +674,8 @@ export class ChatService {
|
|||
|
||||
for (const legacyContextFile of legacyContextFiles) {
|
||||
contentParts.push({
|
||||
type: ContentPartType.TEXT,
|
||||
text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content)
|
||||
type: 'text',
|
||||
text: `\n\n--- File: ${legacyContextFile.name} ---\n${legacyContextFile.content}`
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -727,7 +686,7 @@ export class ChatService {
|
|||
|
||||
for (const audio of audioFiles) {
|
||||
contentParts.push({
|
||||
type: ContentPartType.INPUT_AUDIO,
|
||||
type: 'input_audio',
|
||||
input_audio: {
|
||||
data: audio.base64Data,
|
||||
format: audio.mimeType.includes('wav') ? 'wav' : 'mp3'
|
||||
|
|
@ -744,33 +703,32 @@ export class ChatService {
|
|||
if (pdfFile.processedAsImages && pdfFile.images) {
|
||||
for (let i = 0; i < pdfFile.images.length; i++) {
|
||||
contentParts.push({
|
||||
type: ContentPartType.IMAGE_URL,
|
||||
type: 'image_url',
|
||||
image_url: { url: pdfFile.images[i] }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
contentParts.push({
|
||||
type: ContentPartType.TEXT,
|
||||
text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content)
|
||||
type: 'text',
|
||||
text: `\n\n--- PDF File: ${pdfFile.name} ---\n${pdfFile.content}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result: ApiChatMessageData = {
|
||||
role: message.role as MessageRole,
|
||||
content: contentParts
|
||||
return {
|
||||
role: message.role as ChatRole,
|
||||
content: contentParts,
|
||||
...(message.role === 'assistant' && message.thinking
|
||||
? { reasoning_content: message.thinking }
|
||||
: {}),
|
||||
...(message.toolCallId ? { tool_call_id: message.toolCallId } : {}),
|
||||
...(toolCalls ? { tool_calls: toolCalls } : {})
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Utilities
|
||||
*
|
||||
*
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parses error response and creates appropriate error with context information
|
||||
|
|
@ -805,7 +763,6 @@ export class ChatService {
|
|||
contextInfo?: { n_prompt_tokens: number; n_ctx: number };
|
||||
};
|
||||
fallback.name = 'HttpError';
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
|
@ -837,26 +794,18 @@ export class ChatService {
|
|||
|
||||
// 1) root (some implementations provide `model` at the top level)
|
||||
const rootModel = getTrimmedString(root.model);
|
||||
if (rootModel) {
|
||||
return rootModel;
|
||||
}
|
||||
if (rootModel) return rootModel;
|
||||
|
||||
// 2) streaming choice (delta) or final response (message)
|
||||
const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
|
||||
if (!firstChoice) {
|
||||
return undefined;
|
||||
}
|
||||
if (!firstChoice) return undefined;
|
||||
|
||||
// priority: delta.model (first chunk) else message.model (final response)
|
||||
const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
|
||||
if (deltaModel) {
|
||||
return deltaModel;
|
||||
}
|
||||
if (deltaModel) return deltaModel;
|
||||
|
||||
const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
|
||||
if (messageModel) {
|
||||
return messageModel;
|
||||
}
|
||||
if (messageModel) return messageModel;
|
||||
|
||||
// avoid guessing from non-standard locations (metadata, etc.)
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import type { ApiToolDefinition } from '$lib/types';
|
||||
import { registerTool } from './registry';
|
||||
|
||||
export const CALCULATOR_TOOL_NAME = 'calculator';
|
||||
|
||||
export const calculatorToolDefinition: ApiToolDefinition = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: CALCULATOR_TOOL_NAME,
|
||||
description:
|
||||
'Safely evaluate a math expression. Supports operators + - * / % ^ and parentheses. Functions: sin, cos, tan, asin, acos, atan, atan2, sqrt, abs, exp, ln/log/log2, max, min, floor, ceil, round, pow. Constants: pi, e. Angles are in radians.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
expression: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Math expression using numbers, parentheses, +, -, *, /, %, and exponentiation (^).'
|
||||
}
|
||||
},
|
||||
required: ['expression']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Allow digits, letters (for functions/constants), commas, whitespace, and math operators
|
||||
const SAFE_EXPRESSION = /^[0-9+*/().,^%\sA-Za-z_-]*$/;
|
||||
const ALLOWED_IDENTIFIERS = new Set([
|
||||
'sin',
|
||||
'cos',
|
||||
'tan',
|
||||
'asin',
|
||||
'acos',
|
||||
'atan',
|
||||
'atan2',
|
||||
'sqrt',
|
||||
'abs',
|
||||
'exp',
|
||||
'ln',
|
||||
'log',
|
||||
'log2',
|
||||
'max',
|
||||
'min',
|
||||
'floor',
|
||||
'ceil',
|
||||
'round',
|
||||
'pow',
|
||||
'pi',
|
||||
'e'
|
||||
]);
|
||||
|
||||
function rewriteFunctions(expr: string): string {
|
||||
// Map identifiers to Math.* (or constants)
|
||||
const replacements: Record<string, string> = {
|
||||
sin: 'Math.sin',
|
||||
cos: 'Math.cos',
|
||||
tan: 'Math.tan',
|
||||
asin: 'Math.asin',
|
||||
acos: 'Math.acos',
|
||||
atan: 'Math.atan',
|
||||
atan2: 'Math.atan2',
|
||||
sqrt: 'Math.sqrt',
|
||||
abs: 'Math.abs',
|
||||
exp: 'Math.exp',
|
||||
ln: 'Math.log',
|
||||
log: 'Math.log',
|
||||
log2: 'Math.log2',
|
||||
max: 'Math.max',
|
||||
min: 'Math.min',
|
||||
floor: 'Math.floor',
|
||||
ceil: 'Math.ceil',
|
||||
round: 'Math.round',
|
||||
pow: 'Math.pow'
|
||||
};
|
||||
|
||||
let rewritten = expr;
|
||||
|
||||
for (const [id, replacement] of Object.entries(replacements)) {
|
||||
// only match bare function names not already qualified (no letter/number/_ or dot before)
|
||||
const re = new RegExp(`(^|[^A-Za-z0-9_\\.])${id}\\s*\\(`, 'g');
|
||||
rewritten = rewritten.replace(re, `$1${replacement}(`);
|
||||
}
|
||||
|
||||
rewritten = rewritten.replace(/\bpi\b/gi, 'Math.PI').replace(/\be\b/gi, 'Math.E');
|
||||
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
export function evaluateCalculatorExpression(expr: string): string {
|
||||
const trimmed = expr.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return 'Error: empty expression.';
|
||||
}
|
||||
if (!SAFE_EXPRESSION.test(trimmed)) {
|
||||
return 'Error: invalid characters. Allowed: digits, + - * / % ^ ( ) , and basic function names.';
|
||||
}
|
||||
|
||||
// Check identifiers are allowed
|
||||
const identifiers = trimmed.match(/[A-Za-z_][A-Za-z0-9_]*/g) || [];
|
||||
for (const id of identifiers) {
|
||||
if (!ALLOWED_IDENTIFIERS.has(id.toLowerCase())) {
|
||||
return `Error: unknown identifier "${id}".`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Replace caret with JS exponent operator
|
||||
const caretExpr = trimmed.replace(/\^/g, '**');
|
||||
// Rewrite functions/constants to Math.*
|
||||
const rewritten = rewriteFunctions(caretExpr);
|
||||
const result = Function(`"use strict"; return (${rewritten});`)();
|
||||
|
||||
if (typeof result === 'number' && Number.isFinite(result)) {
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
return 'Error: expression did not produce a finite number.';
|
||||
} catch (err) {
|
||||
return `Error: ${err instanceof Error ? err.message : 'failed to evaluate expression.'}`;
|
||||
}
|
||||
}
|
||||
|
||||
registerTool({
|
||||
name: CALCULATOR_TOOL_NAME,
|
||||
label: 'Calculator',
|
||||
description:
|
||||
'Safely evaluate a math expression using basic operators and Math functions (client-side).',
|
||||
enableConfigKey: 'enableCalculatorTool',
|
||||
defaultEnabled: true,
|
||||
definition: calculatorToolDefinition,
|
||||
execute: async (argsJson: string) => {
|
||||
let expression = argsJson;
|
||||
try {
|
||||
const parsedArgs = JSON.parse(argsJson);
|
||||
if (parsedArgs && typeof parsedArgs === 'object' && 'expression' in parsedArgs) {
|
||||
expression = parsedArgs.expression as string;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const result = evaluateCalculatorExpression(expression);
|
||||
return { content: result };
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
import type { ApiToolDefinition } from '$lib/types';
|
||||
import type { ToolSettingDefinition } from '$lib/services/tools/registry';
|
||||
import { registerTool } from './registry';
|
||||
import { Parser } from 'acorn';
|
||||
import type { Options as AcornOptions } from 'acorn';
|
||||
|
||||
export const CODE_INTERPRETER_JS_TOOL_NAME = 'code_interpreter_javascript';
|
||||
|
||||
export const codeInterpreterToolDefinition: ApiToolDefinition = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: CODE_INTERPRETER_JS_TOOL_NAME,
|
||||
description:
|
||||
'Execute JavaScript in a sandboxed Worker. Your code runs inside an async function (top-level await is supported). Do not wrap code in an async IIFE like (async () => { ... })() unless you return/await it, otherwise the tool may finish before async logs run. If you use promises, they must be awaited. Returns combined console output and the final evaluated value. (no output) likely indicates either an unawaited promise or that you did not output anything.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
description: 'JavaScript source code to run.'
|
||||
}
|
||||
},
|
||||
required: ['code']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface CodeInterpreterResult {
|
||||
result?: string;
|
||||
logs: string[];
|
||||
error?: string;
|
||||
errorLine?: number;
|
||||
errorLineContent?: string;
|
||||
errorStack?: string;
|
||||
errorFrame?: string;
|
||||
errorColumn?: number;
|
||||
}
|
||||
|
||||
export async function runCodeInterpreter(
|
||||
code: string,
|
||||
timeoutMs = 30_000
|
||||
): Promise<CodeInterpreterResult> {
|
||||
// Pre-parse in the main thread so syntax errors include line/column and source line.
|
||||
// (V8 SyntaxError stacks from eval/new Function are often missing user-code locations.)
|
||||
// Lines before user code in wrapped string:
|
||||
// 1: (async () => {
|
||||
// 2: "use strict";
|
||||
// 3: // __USER_CODE_START__
|
||||
const USER_OFFSET = 3;
|
||||
try {
|
||||
const sourceName = CODE_INTERPRETER_JS_TOOL_NAME;
|
||||
const wrappedForParse =
|
||||
`(async () => {\n` +
|
||||
`"use strict";\n` +
|
||||
`// __USER_CODE_START__\n` +
|
||||
`${code ?? ''}\n` +
|
||||
`// __USER_CODE_END__\n` +
|
||||
`})()\n` +
|
||||
`//# sourceURL=${sourceName}\n`;
|
||||
|
||||
const acornOptions: AcornOptions = {
|
||||
ecmaVersion: 2024,
|
||||
sourceType: 'script',
|
||||
allowAwaitOutsideFunction: true,
|
||||
locations: true
|
||||
};
|
||||
Parser.parse(wrappedForParse, acornOptions);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const loc = (err as Error & { loc?: { line: number; column: number } }).loc;
|
||||
const userLine = loc?.line ? Math.max(1, loc.line - USER_OFFSET) : undefined;
|
||||
const userColumn = typeof loc?.column === 'number' ? loc.column + 1 : undefined;
|
||||
const lines = (code ?? '').split('\n');
|
||||
const lineContent = userLine ? lines[userLine - 1]?.trim() : undefined;
|
||||
return {
|
||||
logs: [],
|
||||
error: message,
|
||||
errorLine: userLine,
|
||||
errorColumn: userColumn,
|
||||
errorLineContent: lineContent
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const logs: string[] = [];
|
||||
|
||||
const workerSource = `
|
||||
const send = (msg) => postMessage(msg);
|
||||
|
||||
const logs = [];
|
||||
['log','info','warn','error'].forEach((level) => {
|
||||
const orig = console[level];
|
||||
console[level] = (...args) => {
|
||||
const text = args.map((a) => {
|
||||
try { return typeof a === 'string' ? a : JSON.stringify(a); }
|
||||
catch { return String(a); }
|
||||
}).join(' ');
|
||||
logs.push(text);
|
||||
send({ type: 'log', level, text });
|
||||
if (orig) try { orig.apply(console, args); } catch { /* ignore */ }
|
||||
};
|
||||
});
|
||||
|
||||
const transformCode = (code) => {
|
||||
const lines = (code ?? '').split('\\n');
|
||||
let i = lines.length - 1;
|
||||
while (i >= 0 && lines[i].trim() === '') i--;
|
||||
if (i < 0) return code ?? '';
|
||||
|
||||
const last = lines[i];
|
||||
const trimmed = last.trim();
|
||||
|
||||
// If already returns, leave as-is.
|
||||
if (/^return\\b/.test(trimmed)) return code ?? '';
|
||||
|
||||
// If the last line starts/ends with block delimiters, keep code as-is (likely a statement block).
|
||||
if (/^[}\\])]/.test(trimmed) || trimmed.endsWith('{') || trimmed.endsWith('};')) {
|
||||
return code ?? '';
|
||||
}
|
||||
|
||||
// If it's a declaration, return that identifier.
|
||||
const declMatch = trimmed.match(/^(const|let|var)\\s+([A-Za-z_$][\\w$]*)/);
|
||||
if (declMatch) {
|
||||
const name = declMatch[2];
|
||||
lines.push(\`return \${name};\`);
|
||||
return lines.join('\\n');
|
||||
}
|
||||
|
||||
// Default: treat last statement as expression and return it.
|
||||
lines[i] = \`return (\${trimmed.replace(/;$/, '')});\`;
|
||||
return lines.join('\\n');
|
||||
};
|
||||
|
||||
const run = async (code) => {
|
||||
try {
|
||||
const executable = transformCode(code);
|
||||
const markerStart = '__USER_CODE_START__';
|
||||
const markerEnd = '__USER_CODE_END__';
|
||||
// Use eval within an async IIFE so we can use return and get better syntax error locations.
|
||||
// Lines before user code in wrapped string:
|
||||
// 1: (async () => {
|
||||
// 2: "use strict";
|
||||
// 3: // __USER_CODE_START__
|
||||
const USER_OFFSET = 3;
|
||||
const sourceName = 'code_interpreter_javascript';
|
||||
const wrapped =
|
||||
\`(async () => {\\n\` +
|
||||
\`"use strict";\\n\` +
|
||||
\`// \${markerStart}\\n\` +
|
||||
\`\${executable}\\n\` +
|
||||
\`// \${markerEnd}\\n\` +
|
||||
\`})()\\n\` +
|
||||
\`//# sourceURL=\${sourceName}\\n\`;
|
||||
// eslint-disable-next-line no-eval
|
||||
const result = await eval(wrapped);
|
||||
send({ type: 'done', result, logs });
|
||||
} catch (err) {
|
||||
let lineNum = undefined;
|
||||
let lineText = undefined;
|
||||
let columnNum = undefined;
|
||||
try {
|
||||
const stack = String(err?.stack ?? '');
|
||||
const match =
|
||||
stack.match(/(?:<anonymous>|code_interpreter_javascript):(\\d+):(\\d+)/) ||
|
||||
stack.match(/:(\\d+):(\\d+)/); // fallback: first frame with line/col
|
||||
if (match) {
|
||||
const rawLine = Number(match[1]);
|
||||
const rawCol = Number(match[2]);
|
||||
// Our wrapped string puts user code starting at line USER_OFFSET + 1
|
||||
const userLine = Math.max(1, rawLine - USER_OFFSET);
|
||||
lineNum = userLine;
|
||||
columnNum = rawCol;
|
||||
const srcLines = (code ?? '').split('\\n');
|
||||
lineText = srcLines[userLine - 1]?.trim();
|
||||
}
|
||||
} catch {}
|
||||
if (!lineNum && err?.message) {
|
||||
const idMatch = String(err.message).match(/['"]?([A-Za-z_$][\\w$]*)['"]? is not defined/);
|
||||
if (idMatch) {
|
||||
const ident = idMatch[1];
|
||||
const srcLines = (code ?? '').split('\\n');
|
||||
const foundIdx = srcLines.findIndex((l) => l.includes(ident));
|
||||
if (foundIdx !== -1) {
|
||||
lineNum = foundIdx + 1;
|
||||
lineText = srcLines[foundIdx]?.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!lineNum) {
|
||||
const ln = err?.lineNumber ?? err?.lineno ?? err?.line;
|
||||
if (typeof ln === 'number') {
|
||||
lineNum = ln;
|
||||
const srcLines = (code ?? '').split('\\n');
|
||||
lineText = srcLines[ln - 1]?.trim();
|
||||
}
|
||||
}
|
||||
if (columnNum === undefined) {
|
||||
const col = err?.columnNumber ?? err?.colno ?? undefined;
|
||||
if (typeof col === 'number') columnNum = col;
|
||||
}
|
||||
const stack = err?.stack ? String(err.stack) : undefined;
|
||||
const firstStackFrame = stack
|
||||
?.split('\\n')
|
||||
.find((l) => l.includes('<anonymous>') || l.includes('code_interpreter_javascript'));
|
||||
send({
|
||||
type: 'error',
|
||||
message: err?.message ?? String(err),
|
||||
stack,
|
||||
frame: firstStackFrame,
|
||||
logs,
|
||||
line: lineNum,
|
||||
lineContent: lineText,
|
||||
column: columnNum
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
self.onmessage = (e) => {
|
||||
run(e.data?.code ?? '');
|
||||
};
|
||||
`;
|
||||
|
||||
const blob = new Blob([workerSource], { type: 'application/javascript' });
|
||||
const worker = new Worker(URL.createObjectURL(blob));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
worker.terminate();
|
||||
resolve({ logs, error: 'Timed out' });
|
||||
}, timeoutMs);
|
||||
|
||||
worker.onmessage = (event: MessageEvent) => {
|
||||
const { type } = event.data || {};
|
||||
if (type === 'log') {
|
||||
logs.push(event.data.text);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
worker.terminate();
|
||||
|
||||
if (type === 'error') {
|
||||
resolve({
|
||||
logs: event.data.logs ?? logs,
|
||||
error: event.data.message,
|
||||
errorLine: event.data.line,
|
||||
errorLineContent: event.data.lineContent,
|
||||
errorStack: event.data.stack,
|
||||
errorFrame: event.data.frame,
|
||||
errorColumn: event.data.column
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'done') {
|
||||
const value = event.data.result;
|
||||
let rendered = '';
|
||||
try {
|
||||
rendered = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
rendered = String(value);
|
||||
}
|
||||
resolve({ logs: event.data.logs ?? logs, result: rendered });
|
||||
}
|
||||
};
|
||||
|
||||
worker.postMessage({ code });
|
||||
});
|
||||
}
|
||||
|
||||
registerTool({
|
||||
name: CODE_INTERPRETER_JS_TOOL_NAME,
|
||||
label: 'Code Interpreter (JavaScript)',
|
||||
description: 'Run JavaScript in a sandboxed Worker and capture logs plus final value.',
|
||||
enableConfigKey: 'enableCodeInterpreterTool',
|
||||
defaultEnabled: true,
|
||||
settings: [
|
||||
{
|
||||
key: 'codeInterpreterTimeoutSeconds',
|
||||
label: 'Code interpreter timeout (seconds)',
|
||||
type: 'input',
|
||||
defaultValue: 30,
|
||||
help: 'Maximum time allowed for the JavaScript tool to run before it is terminated.'
|
||||
} satisfies ToolSettingDefinition
|
||||
],
|
||||
definition: codeInterpreterToolDefinition,
|
||||
execute: async (argsJson: string, config) => {
|
||||
let code = argsJson;
|
||||
try {
|
||||
const parsedArgs = JSON.parse(argsJson);
|
||||
if (parsedArgs && typeof parsedArgs === 'object' && typeof parsedArgs.code === 'string') {
|
||||
code = parsedArgs.code;
|
||||
}
|
||||
} catch {
|
||||
// leave raw
|
||||
}
|
||||
|
||||
const timeoutSecondsRaw = config?.codeInterpreterTimeoutSeconds;
|
||||
const timeoutSeconds =
|
||||
typeof timeoutSecondsRaw === 'number'
|
||||
? timeoutSecondsRaw
|
||||
: typeof timeoutSecondsRaw === 'string'
|
||||
? Number(timeoutSecondsRaw)
|
||||
: 30;
|
||||
const timeoutMs = Number.isFinite(timeoutSeconds)
|
||||
? Math.max(0, Math.round(timeoutSeconds * 1000))
|
||||
: 30_000;
|
||||
|
||||
const {
|
||||
result,
|
||||
logs,
|
||||
error,
|
||||
errorLine,
|
||||
errorLineContent,
|
||||
errorStack,
|
||||
errorFrame,
|
||||
errorColumn
|
||||
} = await runCodeInterpreter(code, timeoutMs);
|
||||
let combined = '';
|
||||
if (logs?.length) combined += logs.join('\n');
|
||||
if (combined && (result !== undefined || error)) combined += '\n';
|
||||
if (error) {
|
||||
const lineLabel = errorLine !== undefined ? `line ${errorLine}` : null;
|
||||
const columnLabel =
|
||||
errorLine !== undefined && typeof errorColumn === 'number' ? `, col ${errorColumn}` : '';
|
||||
const lineSnippet =
|
||||
errorLine !== undefined && errorLineContent ? `: ${errorLineContent.trim()}` : '';
|
||||
const lineInfo = lineLabel ? ` (${lineLabel}${columnLabel}${lineSnippet})` : '';
|
||||
combined += `Error${lineInfo}: ${error}`;
|
||||
if (!lineLabel) {
|
||||
if (errorFrame) {
|
||||
combined += `\nFrame: ${errorFrame}`;
|
||||
} else if (errorStack) {
|
||||
combined += `\nStack: ${errorStack}`;
|
||||
}
|
||||
}
|
||||
} else if (result !== undefined) {
|
||||
combined += result;
|
||||
} else if (!combined) {
|
||||
combined = '(no output, did you forget to await a top level promise?)';
|
||||
}
|
||||
return { content: combined };
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// Import all built-in tools so their registration side effects run.
|
||||
import './calculator';
|
||||
import './codeInterpreter';
|
||||
|
||||
export * from './registry';
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import type {
|
||||
ApiToolDefinition,
|
||||
SettingsConfigType,
|
||||
SettingsConfigValue,
|
||||
SettingsFieldConfig
|
||||
} from '$lib/types';
|
||||
|
||||
export type ToolSettingDefinition = SettingsFieldConfig & {
|
||||
defaultValue: SettingsConfigValue;
|
||||
};
|
||||
|
||||
export interface ToolRegistration {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enableConfigKey: string; // key in settings config
|
||||
defaultEnabled?: boolean;
|
||||
settings?: ToolSettingDefinition[];
|
||||
definition: ApiToolDefinition;
|
||||
execute: (argsJson: string, config?: SettingsConfigType) => Promise<{ content: string }>;
|
||||
}
|
||||
|
||||
const tools: ToolRegistration[] = [];
|
||||
|
||||
export function registerTool(tool: ToolRegistration) {
|
||||
const existing = tools.find((t) => t.name === tool.name);
|
||||
if (existing) {
|
||||
// Allow updates (useful for HMR and incremental tool evolution)
|
||||
Object.assign(existing, tool);
|
||||
return;
|
||||
}
|
||||
tools.push(tool);
|
||||
}
|
||||
|
||||
export function getAllTools(): ToolRegistration[] {
|
||||
return tools;
|
||||
}
|
||||
|
||||
export function getEnabledToolDefinitions(config: Record<string, unknown>): ApiToolDefinition[] {
|
||||
return tools.filter((t) => config[t.enableConfigKey] === true).map((t) => t.definition);
|
||||
}
|
||||
|
||||
export function findToolByName(name: string): ToolRegistration | undefined {
|
||||
return tools.find((t) => t.name === name);
|
||||
}
|
||||
|
||||
export function isToolEnabled(name: string, config: Record<string, unknown>): boolean {
|
||||
const tool = findToolByName(name);
|
||||
return !!(tool && config[tool.enableConfigKey] === true);
|
||||
}
|
||||
|
||||
export function getToolSettingDefaults(): Record<string, boolean> {
|
||||
const defaults: Record<string, boolean> = {};
|
||||
for (const tool of tools) {
|
||||
defaults[tool.enableConfigKey] = tool.defaultEnabled ?? false;
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function getToolConfigDefaults(): Record<string, SettingsConfigValue> {
|
||||
const defaults: Record<string, SettingsConfigValue> = {};
|
||||
for (const tool of tools) {
|
||||
for (const setting of tool.settings ?? []) {
|
||||
// Prefer the first registration to avoid non-deterministic overrides
|
||||
if (defaults[setting.key] !== undefined) continue;
|
||||
defaults[setting.key] = setting.defaultValue;
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,38 +1,53 @@
|
|||
/**
|
||||
* conversationsStore - Reactive State Store for Conversations
|
||||
*
|
||||
* Manages conversation lifecycle, persistence, navigation.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **DatabaseService**: Stateless IndexedDB layer
|
||||
* - **conversationsStore** (this): Reactive state + business logic
|
||||
* - **chatStore**: Chat-specific state (streaming, loading)
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Conversation CRUD (create, load, delete)
|
||||
* - Message management and tree navigation
|
||||
* - Import/Export functionality
|
||||
* - Title management with confirmation
|
||||
*
|
||||
* @see DatabaseService in services/database.ts for IndexedDB operations
|
||||
*/
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* conversationsStore - Persistent conversation data and lifecycle management
|
||||
*
|
||||
* **Terminology - Chat vs Conversation:**
|
||||
* - **Chat**: The active interaction space with the Chat Completions API. Represents the
|
||||
* real-time streaming session, loading states, and UI visualization of AI communication.
|
||||
* Managed by chatStore, a "chat" is ephemeral and exists during active AI interactions.
|
||||
* - **Conversation**: The persistent database entity storing all messages and metadata.
|
||||
* A "conversation" survives across sessions, page reloads, and browser restarts.
|
||||
* It contains the complete message history, branching structure, and conversation metadata.
|
||||
*
|
||||
* This store manages all conversation-level data and operations including creation, loading,
|
||||
* deletion, and navigation. It maintains the list of conversations and the currently active
|
||||
* conversation with its message history, providing reactive state for UI components.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **conversationsStore** (this class): Persistent conversation data management
|
||||
* - Manages conversation list and active conversation state
|
||||
* - Handles conversation CRUD operations via DatabaseService
|
||||
* - Maintains active message array for current conversation
|
||||
* - Coordinates branching navigation (currNode tracking)
|
||||
*
|
||||
* - **chatStore**: Uses conversation data as context for active AI streaming
|
||||
* - **DatabaseService**: Low-level IndexedDB storage for conversations and messages
|
||||
*
|
||||
* **Key Features:**
|
||||
* - **Conversation Lifecycle**: Create, load, update, delete conversations
|
||||
* - **Message Management**: Active message array with branching support
|
||||
* - **Import/Export**: JSON-based conversation backup and restore
|
||||
* - **Branch Navigation**: Navigate between message tree branches
|
||||
* - **Title Management**: Auto-update titles with confirmation dialogs
|
||||
* - **Reactive State**: Svelte 5 runes for automatic UI updates
|
||||
*
|
||||
* **State Properties:**
|
||||
* - `conversations`: All conversations sorted by last modified
|
||||
* - `activeConversation`: Currently viewed conversation
|
||||
* - `activeMessages`: Messages in current conversation path
|
||||
* - `isInitialized`: Store initialization status
|
||||
*/
|
||||
class ConversationsStore {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* State
|
||||
*
|
||||
*
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List of all conversations */
|
||||
conversations = $state<DatabaseConversation[]>([]);
|
||||
|
|
@ -49,110 +64,39 @@ class ConversationsStore {
|
|||
/** Callback for title update confirmation dialog */
|
||||
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Lifecycle
|
||||
*
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize the store by loading conversations from database.
|
||||
* Must be called once after app startup.
|
||||
* Initializes the conversations store by loading conversations from the database
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (!browser) return;
|
||||
if (this.isInitialized) return;
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.loadConversations();
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize conversations:', error);
|
||||
console.error('Failed to initialize conversations store:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for init() for backward compatibility.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
return this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Message Array Operations
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Adds a message to the active messages array
|
||||
*/
|
||||
addMessageToActive(message: DatabaseMessage): void {
|
||||
this.activeMessages.push(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a message at a specific index in active messages
|
||||
*/
|
||||
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
|
||||
if (index !== -1 && this.activeMessages[index]) {
|
||||
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of a message in active messages
|
||||
*/
|
||||
findMessageIndex(messageId: string): number {
|
||||
return this.activeMessages.findIndex((m) => m.id === messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes messages from active messages starting at an index
|
||||
*/
|
||||
sliceActiveMessages(startIndex: number): void {
|
||||
this.activeMessages = this.activeMessages.slice(0, startIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a message from active messages by index
|
||||
*/
|
||||
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
|
||||
if (index !== -1) {
|
||||
return this.activeMessages.splice(index, 1)[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the callback function for title update confirmations
|
||||
*/
|
||||
setTitleUpdateConfirmationCallback(
|
||||
callback: (currentTitle: string, newTitle: string) => Promise<boolean>
|
||||
): void {
|
||||
this.titleUpdateConfirmationCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Conversation CRUD
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Loads all conversations from the database
|
||||
*/
|
||||
async loadConversations(): Promise<void> {
|
||||
const conversations = await DatabaseService.getAllConversations();
|
||||
this.conversations = conversations;
|
||||
this.conversations = await DatabaseService.getAllConversations();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Conversation CRUD
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a new conversation and navigates to it
|
||||
* @param name - Optional name for the conversation
|
||||
|
|
@ -162,7 +106,7 @@ class ConversationsStore {
|
|||
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
||||
const conversation = await DatabaseService.createConversation(conversationName);
|
||||
|
||||
this.conversations = [conversation, ...this.conversations];
|
||||
this.conversations.unshift(conversation);
|
||||
this.activeConversation = conversation;
|
||||
this.activeMessages = [];
|
||||
|
||||
|
|
@ -188,15 +132,13 @@ class ConversationsStore {
|
|||
|
||||
if (conversation.currNode) {
|
||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
||||
const filteredMessages = filterByLeafNodeId(
|
||||
this.activeMessages = filterByLeafNodeId(
|
||||
allMessages,
|
||||
conversation.currNode,
|
||||
false
|
||||
) as DatabaseMessage[];
|
||||
this.activeMessages = filteredMessages;
|
||||
} else {
|
||||
const messages = await DatabaseService.getConversationMessages(convId);
|
||||
this.activeMessages = messages;
|
||||
this.activeMessages = await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -207,11 +149,168 @@ class ConversationsStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Clears the active conversation and messages.
|
||||
* Clears the active conversation and messages
|
||||
* Used when navigating away from chat or starting fresh
|
||||
*/
|
||||
clearActiveConversation(): void {
|
||||
this.activeConversation = null;
|
||||
this.activeMessages = [];
|
||||
// Active processing conversation is now managed by chatStore
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Message Management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Refreshes active messages based on currNode after branch navigation
|
||||
*/
|
||||
async refreshActiveMessages(): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
||||
|
||||
if (allMessages.length === 0) {
|
||||
this.activeMessages = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const leafNodeId =
|
||||
this.activeConversation.currNode ||
|
||||
allMessages.reduce((latest: DatabaseMessage, msg: DatabaseMessage) =>
|
||||
msg.timestamp > latest.timestamp ? msg : latest
|
||||
).id;
|
||||
|
||||
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
|
||||
|
||||
this.activeMessages = [...currentPath];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the name of a conversation
|
||||
* @param convId - The conversation ID to update
|
||||
* @param name - The new name for the conversation
|
||||
*/
|
||||
async updateConversationName(convId: string, name: string): Promise<void> {
|
||||
try {
|
||||
await DatabaseService.updateConversation(convId, { name });
|
||||
|
||||
const convIndex = this.conversations.findIndex((c) => c.id === convId);
|
||||
|
||||
if (convIndex !== -1) {
|
||||
this.conversations[convIndex].name = name;
|
||||
}
|
||||
|
||||
if (this.activeConversation?.id === convId) {
|
||||
this.activeConversation.name = name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation name:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation title with optional confirmation dialog based on settings
|
||||
* @param convId - The conversation ID to update
|
||||
* @param newTitle - The new title content
|
||||
* @param onConfirmationNeeded - Callback when user confirmation is needed
|
||||
* @returns True if title was updated, false if cancelled
|
||||
*/
|
||||
async updateConversationTitleWithConfirmation(
|
||||
convId: string,
|
||||
newTitle: string,
|
||||
onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const currentConfig = config();
|
||||
|
||||
if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
|
||||
const conversation = await DatabaseService.getConversation(convId);
|
||||
if (!conversation) return false;
|
||||
|
||||
const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
|
||||
if (!shouldUpdate) return false;
|
||||
}
|
||||
|
||||
await this.updateConversationName(convId, newTitle);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation title with confirmation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Navigation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Updates the current node of the active conversation
|
||||
* @param nodeId - The new current node ID
|
||||
*/
|
||||
async updateCurrentNode(nodeId: string): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
|
||||
this.activeConversation.currNode = nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation lastModified timestamp and moves it to top of list
|
||||
*/
|
||||
updateConversationTimestamp(): void {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
||||
|
||||
if (chatIndex !== -1) {
|
||||
this.conversations[chatIndex].lastModified = Date.now();
|
||||
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
|
||||
this.conversations.unshift(updatedConv);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to a specific sibling branch by updating currNode and refreshing messages
|
||||
* @param siblingId - The sibling message ID to navigate to
|
||||
*/
|
||||
async navigateToSibling(siblingId: string): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
||||
const rootMessage = allMessages.find(
|
||||
(m: DatabaseMessage) => m.type === 'root' && m.parent === null
|
||||
);
|
||||
const currentFirstUserMessage = this.activeMessages.find(
|
||||
(m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage?.id
|
||||
);
|
||||
|
||||
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
|
||||
this.activeConversation.currNode = currentLeafNodeId;
|
||||
await this.refreshActiveMessages();
|
||||
|
||||
// Only show title dialog if we're navigating between different first user message siblings
|
||||
if (rootMessage && this.activeMessages.length > 0) {
|
||||
const newFirstUserMessage = this.activeMessages.find(
|
||||
(m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage.id
|
||||
);
|
||||
|
||||
if (
|
||||
newFirstUserMessage &&
|
||||
newFirstUserMessage.content.trim() &&
|
||||
(!currentFirstUserMessage ||
|
||||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
|
||||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
|
||||
) {
|
||||
await this.updateConversationTitleWithConfirmation(
|
||||
this.activeConversation.id,
|
||||
newFirstUserMessage.content.trim(),
|
||||
this.titleUpdateConfirmationCallback
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -256,192 +355,12 @@ class ConversationsStore {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Message Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Import/Export
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Refreshes active messages based on currNode after branch navigation.
|
||||
*/
|
||||
async refreshActiveMessages(): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
||||
|
||||
if (allMessages.length === 0) {
|
||||
this.activeMessages = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const leafNodeId =
|
||||
this.activeConversation.currNode ||
|
||||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
|
||||
|
||||
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
|
||||
|
||||
this.activeMessages = currentPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all messages for a specific conversation
|
||||
* @param convId - The conversation ID
|
||||
* @returns Array of messages
|
||||
*/
|
||||
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
||||
return await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Title Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Updates the name of a conversation.
|
||||
* @param convId - The conversation ID to update
|
||||
* @param name - The new name for the conversation
|
||||
*/
|
||||
async updateConversationName(convId: string, name: string): Promise<void> {
|
||||
try {
|
||||
await DatabaseService.updateConversation(convId, { name });
|
||||
|
||||
const convIndex = this.conversations.findIndex((c) => c.id === convId);
|
||||
|
||||
if (convIndex !== -1) {
|
||||
this.conversations[convIndex].name = name;
|
||||
this.conversations = [...this.conversations];
|
||||
}
|
||||
|
||||
if (this.activeConversation?.id === convId) {
|
||||
this.activeConversation = { ...this.activeConversation, name };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation name:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation title with optional confirmation dialog based on settings
|
||||
* @param convId - The conversation ID to update
|
||||
* @param newTitle - The new title content
|
||||
* @returns True if title was updated, false if cancelled
|
||||
*/
|
||||
async updateConversationTitleWithConfirmation(
|
||||
convId: string,
|
||||
newTitle: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const currentConfig = config();
|
||||
|
||||
if (currentConfig.askForTitleConfirmation && this.titleUpdateConfirmationCallback) {
|
||||
const conversation = await DatabaseService.getConversation(convId);
|
||||
if (!conversation) return false;
|
||||
|
||||
const shouldUpdate = await this.titleUpdateConfirmationCallback(
|
||||
conversation.name,
|
||||
newTitle
|
||||
);
|
||||
if (!shouldUpdate) return false;
|
||||
}
|
||||
|
||||
await this.updateConversationName(convId, newTitle);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update conversation title with confirmation:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates conversation lastModified timestamp and moves it to top of list
|
||||
*/
|
||||
updateConversationTimestamp(): void {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
||||
|
||||
if (chatIndex !== -1) {
|
||||
this.conversations[chatIndex].lastModified = Date.now();
|
||||
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
|
||||
this.conversations = [updatedConv, ...this.conversations];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current node of the active conversation
|
||||
* @param nodeId - The new current node ID
|
||||
*/
|
||||
async updateCurrentNode(nodeId: string): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
|
||||
this.activeConversation = { ...this.activeConversation, currNode: nodeId };
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Branch Navigation
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Navigates to a specific sibling branch by updating currNode and refreshing messages.
|
||||
* @param siblingId - The sibling message ID to navigate to
|
||||
*/
|
||||
async navigateToSibling(siblingId: string): Promise<void> {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
|
||||
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
|
||||
const currentFirstUserMessage = this.activeMessages.find(
|
||||
(m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
|
||||
);
|
||||
|
||||
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
|
||||
this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
|
||||
await this.refreshActiveMessages();
|
||||
|
||||
if (rootMessage && this.activeMessages.length > 0) {
|
||||
const newFirstUserMessage = this.activeMessages.find(
|
||||
(m) => m.role === MessageRole.USER && m.parent === rootMessage.id
|
||||
);
|
||||
|
||||
if (
|
||||
newFirstUserMessage &&
|
||||
newFirstUserMessage.content.trim() &&
|
||||
(!currentFirstUserMessage ||
|
||||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
|
||||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
|
||||
) {
|
||||
await this.updateConversationTitleWithConfirmation(
|
||||
this.activeConversation.id,
|
||||
newFirstUserMessage.content.trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Import & Export
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Downloads a conversation as JSON file.
|
||||
* Downloads a conversation as JSON file
|
||||
* @param convId - The conversation ID to download
|
||||
*/
|
||||
async downloadConversation(convId: string): Promise<void> {
|
||||
|
|
@ -472,7 +391,7 @@ class ConversationsStore {
|
|||
}
|
||||
|
||||
const allData = await Promise.all(
|
||||
allConversations.map(async (conv) => {
|
||||
allConversations.map(async (conv: DatabaseConversation) => {
|
||||
const messages = await DatabaseService.getConversationMessages(conv.id);
|
||||
return { conv, messages };
|
||||
})
|
||||
|
|
@ -552,6 +471,15 @@ class ConversationsStore {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all messages for a specific conversation
|
||||
* @param convId - The conversation ID
|
||||
* @returns Array of messages
|
||||
*/
|
||||
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
||||
return await DatabaseService.getConversationMessages(convId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports conversations from provided data (without file picker)
|
||||
* @param data - Array of conversation data with messages
|
||||
|
|
@ -565,8 +493,71 @@ class ConversationsStore {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a message to the active messages array
|
||||
* Used by chatStore when creating new messages
|
||||
* @param message - The message to add
|
||||
*/
|
||||
addMessageToActive(message: DatabaseMessage): void {
|
||||
this.activeMessages = [...this.activeMessages, message];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a message at a specific index in active messages
|
||||
* Creates a new object to trigger Svelte 5 reactivity
|
||||
* @param index - The index of the message to update
|
||||
* @param updates - Partial message data to update
|
||||
*/
|
||||
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
|
||||
if (index !== -1 && this.activeMessages[index]) {
|
||||
// Create new object to trigger Svelte 5 reactivity
|
||||
const updated = { ...this.activeMessages[index], ...updates };
|
||||
this.activeMessages = [
|
||||
...this.activeMessages.slice(0, index),
|
||||
updated,
|
||||
...this.activeMessages.slice(index + 1)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of a message in active messages
|
||||
* @param messageId - The message ID to find
|
||||
* @returns The index of the message, or -1 if not found
|
||||
*/
|
||||
findMessageIndex(messageId: string): number {
|
||||
return this.activeMessages.findIndex((m) => m.id === messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes messages from active messages starting at an index
|
||||
* @param startIndex - The index to start removing from
|
||||
*/
|
||||
sliceActiveMessages(startIndex: number): void {
|
||||
this.activeMessages = this.activeMessages.slice(0, startIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a message from active messages by index
|
||||
* @param index - The index to remove
|
||||
* @returns The removed message or undefined
|
||||
*/
|
||||
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
|
||||
if (index !== -1) {
|
||||
const removed = this.activeMessages[index];
|
||||
this.activeMessages = [
|
||||
...this.activeMessages.slice(0, index),
|
||||
...this.activeMessages.slice(index + 1)
|
||||
];
|
||||
return removed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers file download in browser
|
||||
* @param data - The data to download
|
||||
* @param filename - Optional filename for the download
|
||||
*/
|
||||
private triggerDownload(data: ExportedConversations, filename?: string): void {
|
||||
const conversation =
|
||||
|
|
@ -595,15 +586,24 @@ class ConversationsStore {
|
|||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sets the callback function for title update confirmations
|
||||
* @param callback - Function to call when confirmation is needed
|
||||
*/
|
||||
setTitleUpdateConfirmationCallback(
|
||||
callback: (currentTitle: string, newTitle: string) => Promise<boolean>
|
||||
): void {
|
||||
this.titleUpdateConfirmationCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
export const conversationsStore = new ConversationsStore();
|
||||
|
||||
// Auto-initialize in browser
|
||||
if (browser) {
|
||||
conversationsStore.init();
|
||||
}
|
||||
|
||||
export const conversations = () => conversationsStore.conversations;
|
||||
export const activeConversation = () => conversationsStore.activeConversation;
|
||||
export const activeMessages = () => conversationsStore.activeMessages;
|
||||
|
|
|
|||
|
|
@ -45,9 +45,16 @@ export interface ApiErrorResponse {
|
|||
export interface ApiChatMessageData {
|
||||
role: ChatRole;
|
||||
content: string | ApiChatMessageContentPart[];
|
||||
tool_calls?: ApiChatCompletionToolCall[];
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ApiChatCompletionToolCall[] | ApiChatCompletionToolCallDelta[];
|
||||
timestamp?: number;
|
||||
/**
|
||||
* Optional reasoning/thinking content to be sent back to the server.
|
||||
*
|
||||
* llama-server accepts this non-OpenAI field and uses it to preserve the model's
|
||||
* internal "thinking" blocks across tool-call resumptions (notably for gpt-oss).
|
||||
*/
|
||||
reasoning_content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -193,15 +200,21 @@ export interface ApiLlamaCppServerProps {
|
|||
webui_settings?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface ApiChatCompletionRequestMessage {
|
||||
role: ChatRole;
|
||||
content: string | ApiChatMessageContentPart[];
|
||||
reasoning_content?: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ApiChatCompletionToolCallDelta[];
|
||||
}
|
||||
|
||||
export interface ApiChatCompletionRequest {
|
||||
messages: Array<{
|
||||
role: ChatRole;
|
||||
content: string | ApiChatMessageContentPart[];
|
||||
}>;
|
||||
messages: Array<ApiChatCompletionRequestMessage>;
|
||||
stream?: boolean;
|
||||
model?: string;
|
||||
return_progress?: boolean;
|
||||
tools?: ApiChatCompletionTool[];
|
||||
tools?: ApiToolDefinition[];
|
||||
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
||||
// Reasoning parameters
|
||||
reasoning_format?: string;
|
||||
// Generation parameters
|
||||
|
|
@ -249,6 +262,15 @@ export interface ApiChatCompletionToolCall extends ApiChatCompletionToolCallDelt
|
|||
function?: ApiChatCompletionToolCallFunctionDelta & { arguments?: string };
|
||||
}
|
||||
|
||||
export interface ApiToolDefinition {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiChatCompletionStreamChunk {
|
||||
object?: string;
|
||||
model?: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { ErrorDialogType } from '$lib/enums';
|
||||
import type { DatabaseMessageExtra } from './database';
|
||||
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
|
||||
|
||||
export type ChatMessageType = 'root' | 'text' | 'think' | 'system' | 'tool';
|
||||
export type ChatRole = 'user' | 'assistant' | 'system' | 'tool';
|
||||
|
||||
export interface ChatUploadedFile {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export type {
|
|||
ApiChatCompletionToolCall,
|
||||
ApiChatCompletionStreamChunk,
|
||||
ApiChatCompletionResponse,
|
||||
ApiChatCompletionRequestMessage,
|
||||
ApiToolDefinition,
|
||||
ApiSlotData,
|
||||
ApiProcessingState,
|
||||
ApiRouterModelMeta,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface SettingsFieldConfig {
|
|||
key: string;
|
||||
label: string;
|
||||
type: SettingsFieldType;
|
||||
disabled?: boolean;
|
||||
isExperimental?: boolean;
|
||||
help?: string;
|
||||
options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
|
||||
|
|
@ -48,6 +49,9 @@ export interface SettingsChatServiceOptions {
|
|||
backend_sampling?: boolean;
|
||||
// Custom parameters
|
||||
custom?: string;
|
||||
// Tools
|
||||
tools?: unknown[];
|
||||
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
||||
timings_per_token?: boolean;
|
||||
// Callbacks
|
||||
onChunk?: (chunk: string) => void;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import TestMessagesWrapper from './components/TestMessagesWrapper.svelte';
|
||||
|
||||
const msg = (
|
||||
id: string,
|
||||
role: ChatRole,
|
||||
content: string,
|
||||
parent: string | null,
|
||||
extra: Partial<DatabaseMessage> = {}
|
||||
): DatabaseMessage => ({
|
||||
id,
|
||||
convId: 'c1',
|
||||
type: 'text',
|
||||
role,
|
||||
content,
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
parent: parent ?? '-1',
|
||||
children: [],
|
||||
timestamp: Date.now(),
|
||||
...extra
|
||||
});
|
||||
|
||||
describe('ChatMessages inline tool call rendering (collapse chain)', () => {
|
||||
it('shows tool arguments and result inside one reasoning block', async () => {
|
||||
const user = msg('u1', 'user', 'Question', null);
|
||||
const a1 = msg('a1', 'assistant', '', user.id, {
|
||||
thinking: 'think step 1',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '2+2' }) }
|
||||
}
|
||||
])
|
||||
});
|
||||
const t1 = msg('t1', 'tool', JSON.stringify({ expression: '2+2', result: '4' }), a1.id, {
|
||||
toolCallId: 'call-1'
|
||||
});
|
||||
const a2 = msg('a2', 'assistant', 'Final answer', t1.id, { thinking: 'think step 2' });
|
||||
|
||||
const messages = [user, a1, t1, a2];
|
||||
|
||||
const { container } = render(TestMessagesWrapper, {
|
||||
target: document.body,
|
||||
props: { messages }
|
||||
});
|
||||
|
||||
const assistants = container.querySelectorAll('[aria-label="Assistant message with actions"]');
|
||||
expect(assistants.length).toBe(1);
|
||||
|
||||
const text = container.textContent || '';
|
||||
expect(text).toContain('2+2');
|
||||
expect(text).toContain('4');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import type { ChatRole, DatabaseMessage } from '$lib/types';
|
||||
import TestChatMessageWrapper from './components/TestChatMessageWrapper.svelte';
|
||||
|
||||
const msg = (
|
||||
id: string,
|
||||
role: ChatRole,
|
||||
content: string,
|
||||
parent: string | null,
|
||||
extra: Partial<DatabaseMessage> = {}
|
||||
): DatabaseMessage => ({
|
||||
id,
|
||||
convId: 'c1',
|
||||
type: 'text',
|
||||
role,
|
||||
content,
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
parent: parent ?? '-1',
|
||||
children: [],
|
||||
timestamp: Date.now(),
|
||||
...extra
|
||||
});
|
||||
|
||||
async function waitForDialogContent(): Promise<HTMLElement> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await tick();
|
||||
const dialog = document.body.querySelector(
|
||||
'[data-slot="alert-dialog-content"]'
|
||||
) as HTMLElement | null;
|
||||
if (dialog) return dialog;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
throw new Error('Timed out waiting for delete confirmation dialog');
|
||||
}
|
||||
|
||||
describe('ChatMessage delete for merged assistant messages', () => {
|
||||
it('deletes the actionTarget message id (not the merged display id)', async () => {
|
||||
settingsStore.config = { ...SETTING_CONFIG_DEFAULT };
|
||||
|
||||
conversationsStore.activeConversation = {
|
||||
id: 'c1',
|
||||
name: 'Test',
|
||||
currNode: null,
|
||||
lastModified: Date.now()
|
||||
};
|
||||
|
||||
// Chain: user -> assistant(toolcall) -> tool -> assistant(final)
|
||||
const user = msg('u1', 'user', 'Question', null, { children: ['a1'] });
|
||||
const a1 = msg('a1', 'assistant', '', user.id, {
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '1+1' }) }
|
||||
}
|
||||
]),
|
||||
children: ['t1']
|
||||
});
|
||||
const t1 = msg('t1', 'tool', JSON.stringify({ result: '2' }), a1.id, {
|
||||
toolCallId: 'call-1',
|
||||
children: ['a2']
|
||||
});
|
||||
const a2 = msg('a2', 'assistant', 'Answer is 2', t1.id);
|
||||
const allMessages = [user, a1, t1, a2];
|
||||
|
||||
// Merged display message: looks like a2, but actions should target a1.
|
||||
const mergedAssistant = { ...a2, _actionTargetId: a1.id } as unknown as DatabaseMessage;
|
||||
|
||||
conversationsStore.activeMessages = allMessages;
|
||||
|
||||
// Avoid touching IndexedDB by stubbing the store call used by getDeletionInfo.
|
||||
const originalGetConversationMessages =
|
||||
conversationsStore.getConversationMessages.bind(conversationsStore);
|
||||
conversationsStore.getConversationMessages = async () => allMessages;
|
||||
|
||||
const onDelete = vi.fn();
|
||||
|
||||
try {
|
||||
const { container } = render(TestChatMessageWrapper, {
|
||||
target: document.body,
|
||||
props: { message: mergedAssistant, onDelete }
|
||||
});
|
||||
|
||||
const deleteButton = container.querySelector(
|
||||
'button[aria-label="Delete"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(deleteButton).toBeTruthy();
|
||||
|
||||
deleteButton?.click();
|
||||
|
||||
const dialog = await waitForDialogContent();
|
||||
const confirm = Array.from(dialog.querySelectorAll('button')).find((b) =>
|
||||
(b.textContent ?? '').includes('Delete')
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(confirm).toBeTruthy();
|
||||
|
||||
confirm?.click();
|
||||
await tick();
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
expect(onDelete.mock.calls[0][0].id).toBe(a1.id);
|
||||
} finally {
|
||||
conversationsStore.getConversationMessages = originalGetConversationMessages;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import TestChatMessageWrapper from './components/TestChatMessageWrapper.svelte';
|
||||
|
||||
function createUserMessage(): DatabaseMessage {
|
||||
return {
|
||||
id: 'u-edit-1',
|
||||
convId: 'conv-edit-1',
|
||||
type: 'text',
|
||||
role: 'user',
|
||||
content: 'Original user message',
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
parent: '-1',
|
||||
children: [],
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
describe('ChatMessage user editing', () => {
|
||||
it('enters edit mode when clicking Edit on a user message', async () => {
|
||||
const { container } = render(TestChatMessageWrapper, {
|
||||
target: document.body,
|
||||
props: {
|
||||
message: createUserMessage(),
|
||||
onDelete: vi.fn()
|
||||
}
|
||||
});
|
||||
|
||||
const editButton = container.querySelector('button[aria-label="Edit"]') as HTMLButtonElement | null;
|
||||
expect(editButton).toBeTruthy();
|
||||
|
||||
editButton?.click();
|
||||
await tick();
|
||||
|
||||
const editInput = container.querySelector(
|
||||
'[placeholder="Edit your message..."]'
|
||||
) as HTMLElement | null;
|
||||
expect(editInput).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import TestMessagesWrapper from './components/TestMessagesWrapper.svelte';
|
||||
|
||||
const waitForText = async (container: HTMLElement, text: string, timeoutMs = 2000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if ((container.textContent || '').includes(text)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
throw new Error(`Timed out waiting for text: ${text}`);
|
||||
};
|
||||
|
||||
const msg = (
|
||||
id: string,
|
||||
role: 'user' | 'assistant' | 'tool' | 'system',
|
||||
content: string,
|
||||
parent: string | null,
|
||||
timestamp: number,
|
||||
extra: Partial<DatabaseMessage> = {}
|
||||
): DatabaseMessage => ({
|
||||
id,
|
||||
convId: 'c1',
|
||||
type: role === 'tool' ? 'tool' : 'text',
|
||||
role,
|
||||
content,
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
parent: parent ?? '-1',
|
||||
children: [],
|
||||
timestamp,
|
||||
...extra
|
||||
});
|
||||
|
||||
describe('ChatMessages multi-tool chaining and ordering', () => {
|
||||
it('keeps all tool results visible when tool messages are parent-chained', async () => {
|
||||
const ts = Date.now();
|
||||
const user = msg('u1', 'user', 'Use two tools', null, ts);
|
||||
const a1 = msg('a1', 'assistant', '', user.id, ts + 1, {
|
||||
thinking: 'planning',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '2+2' }) }
|
||||
},
|
||||
{
|
||||
id: 'call-2',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '10-3' }) }
|
||||
}
|
||||
])
|
||||
});
|
||||
// Parent-chained tool messages (the branch that should be persisted and reloaded correctly)
|
||||
const t1 = msg('t1', 'tool', JSON.stringify({ result: '4' }), a1.id, ts + 2, {
|
||||
toolCallId: 'call-1'
|
||||
});
|
||||
const t2 = msg('t2', 'tool', JSON.stringify({ result: '7' }), t1.id, ts + 3, {
|
||||
toolCallId: 'call-2'
|
||||
});
|
||||
const a2 = msg('a2', 'assistant', 'done', t2.id, ts + 4);
|
||||
|
||||
const { container } = render(TestMessagesWrapper, {
|
||||
target: document.body,
|
||||
props: { messages: [user, a1, t1, t2, a2] }
|
||||
});
|
||||
|
||||
await waitForText(container, 'done');
|
||||
|
||||
const text = container.textContent || '';
|
||||
expect(text).toContain('2+2');
|
||||
expect(text).toContain('10-3');
|
||||
expect(text).toContain('4');
|
||||
expect(text).toContain('7');
|
||||
expect(text).toContain('done');
|
||||
});
|
||||
|
||||
it('renders deterministic reasoning/tool order when timestamps tie and input is shuffled', async () => {
|
||||
const ts = Date.now();
|
||||
const user = msg('u1', 'user', 'Question', null, ts);
|
||||
const a1 = msg('a1', 'assistant', '', user.id, ts, {
|
||||
thinking: 'reason-step-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '1+1' }) }
|
||||
}
|
||||
])
|
||||
});
|
||||
const t1 = msg('t1', 'tool', JSON.stringify({ result: '2' }), a1.id, ts, {
|
||||
toolCallId: 'call-1'
|
||||
});
|
||||
const a2 = msg('a2', 'assistant', 'final-answer', t1.id, ts, {
|
||||
thinking: 'reason-step-2'
|
||||
});
|
||||
|
||||
// Intentionally shuffled; deterministic sort should still render the same chain order.
|
||||
const { container } = render(TestMessagesWrapper, {
|
||||
target: document.body,
|
||||
props: { messages: [a2, t1, user, a1] }
|
||||
});
|
||||
|
||||
await waitForText(container, 'final-answer');
|
||||
|
||||
const text = container.textContent || '';
|
||||
const idxReason1 = text.indexOf('reason-step-1');
|
||||
const idxTool = text.indexOf('1+1');
|
||||
const idxReason2 = text.indexOf('reason-step-2');
|
||||
const idxFinal = text.indexOf('final-answer');
|
||||
|
||||
expect(idxReason1).toBeGreaterThanOrEqual(0);
|
||||
expect(idxTool).toBeGreaterThan(idxReason1);
|
||||
expect(idxReason2).toBeGreaterThan(idxTool);
|
||||
expect(idxFinal).toBeGreaterThan(idxReason2);
|
||||
});
|
||||
|
||||
it('does not hoist later thinking above earlier visible output when phases interleave', async () => {
|
||||
const ts = Date.now();
|
||||
const user = msg('u1', 'user', 'Interleave phases', null, ts);
|
||||
const a1 = msg('a1', 'assistant', 'outside-1', user.id, ts + 1, {
|
||||
thinking: 'reason-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '3*3' }) }
|
||||
}
|
||||
])
|
||||
});
|
||||
const t1 = msg('t1', 'tool', JSON.stringify({ result: '9' }), a1.id, ts + 2, {
|
||||
toolCallId: 'call-1'
|
||||
});
|
||||
const a2 = msg('a2', 'assistant', '', t1.id, ts + 3, {
|
||||
thinking: 'reason-2',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-2',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '5+2' }) }
|
||||
}
|
||||
])
|
||||
});
|
||||
const t2 = msg('t2', 'tool', JSON.stringify({ result: '7' }), a2.id, ts + 4, {
|
||||
toolCallId: 'call-2'
|
||||
});
|
||||
const a3 = msg('a3', 'assistant', 'outside-2', t2.id, ts + 5);
|
||||
|
||||
const { container } = render(TestMessagesWrapper, {
|
||||
target: document.body,
|
||||
props: { messages: [user, a1, t1, a2, t2, a3] }
|
||||
});
|
||||
|
||||
await waitForText(container, 'outside-2');
|
||||
|
||||
const text = container.textContent || '';
|
||||
const idxReason1 = text.indexOf('reason-1');
|
||||
const idxOutside1 = text.indexOf('outside-1');
|
||||
const idxReason2 = text.indexOf('reason-2');
|
||||
const idxOutside2 = text.indexOf('outside-2');
|
||||
|
||||
expect(idxReason1).toBeGreaterThanOrEqual(0);
|
||||
expect(idxOutside1).toBeGreaterThan(idxReason1);
|
||||
expect(idxReason2).toBeGreaterThan(idxOutside1);
|
||||
expect(idxOutside2).toBeGreaterThan(idxReason2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import TestReactiveMessagesWrapper from './components/TestReactiveMessagesWrapper.svelte';
|
||||
import type { ChatRole, DatabaseMessage } from '$lib/types';
|
||||
|
||||
const msg = (
|
||||
id: string,
|
||||
role: ChatRole,
|
||||
content: string,
|
||||
parent: string | null,
|
||||
extra: Partial<DatabaseMessage> = {}
|
||||
): DatabaseMessage => ({
|
||||
id,
|
||||
convId: 'c1',
|
||||
type: 'text',
|
||||
role,
|
||||
content,
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
parent: parent ?? '-1',
|
||||
children: [],
|
||||
timestamp: Date.now(),
|
||||
...extra
|
||||
});
|
||||
|
||||
describe('ChatMessages reactivity to streaming additions', () => {
|
||||
it('updates reasoning block when new assistant tool-child arrives later', async () => {
|
||||
// reset store
|
||||
conversationsStore.activeMessages = [];
|
||||
|
||||
const user = msg('u1', 'user', 'Question', null);
|
||||
const a1 = msg('a1', 'assistant', '', user.id, { thinking: 'step1' });
|
||||
const t1 = msg('t1', 'tool', JSON.stringify({ result: 'ok' }), a1.id, { toolCallId: 'call-1' });
|
||||
const a2 = msg('a2', 'assistant', '', t1.id, { thinking: 'step2' });
|
||||
|
||||
// Render wrapper that consumes conversationsStore.activeMessages
|
||||
const { container } = render(TestReactiveMessagesWrapper);
|
||||
|
||||
// Add initial chain (user + first assistant + tool)
|
||||
conversationsStore.addMessageToActive(user);
|
||||
conversationsStore.addMessageToActive(a1);
|
||||
conversationsStore.addMessageToActive(t1);
|
||||
|
||||
// Initial reasoning shows step1 only
|
||||
await Promise.resolve();
|
||||
expect(container.textContent || '').toContain('step1');
|
||||
expect(container.textContent || '').not.toContain('step2');
|
||||
|
||||
// Stream in follow-up assistant (same chain)
|
||||
conversationsStore.addMessageToActive(a2);
|
||||
|
||||
// Wait a tick for UI to react
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const text = container.textContent || '';
|
||||
expect(text).toContain('step1');
|
||||
expect(text).toContain('step2');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import TestSnapshotMessagesWrapper from './components/TestSnapshotMessagesWrapper.svelte';
|
||||
import type { ChatRole, DatabaseMessage } from '$lib/types';
|
||||
|
||||
const waitForText = async (container: HTMLElement, text: string, timeoutMs = 2000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if ((container.textContent || '').includes(text)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
throw new Error(`Timed out waiting for text: ${text}`);
|
||||
};
|
||||
|
||||
const msg = (
|
||||
id: string,
|
||||
role: ChatRole,
|
||||
content: string,
|
||||
parent: string | null,
|
||||
extra: Partial<DatabaseMessage> = {}
|
||||
): DatabaseMessage => ({
|
||||
id,
|
||||
convId: 'c1',
|
||||
type: 'text',
|
||||
role,
|
||||
content,
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
parent: parent ?? '-1',
|
||||
children: [],
|
||||
timestamp: Date.now(),
|
||||
...extra
|
||||
});
|
||||
|
||||
/**
|
||||
* Reproduces the UI snapshot-passing pattern (messages={activeMessages()}).
|
||||
* Expects the final assistant content to appear after streaming additions.
|
||||
* This should fail until ChatScreen passes a reactive source.
|
||||
*/
|
||||
describe('ChatMessages snapshot regression', () => {
|
||||
it('fails to show final content when messages prop is a stale snapshot', async () => {
|
||||
conversationsStore.activeMessages = [];
|
||||
|
||||
const user = msg('u1', 'user', 'Question', null);
|
||||
const a1 = msg('a1', 'assistant', '', user.id, {
|
||||
thinking: 'reasoning-step-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '1+1' }) }
|
||||
}
|
||||
])
|
||||
});
|
||||
const t1 = msg('t1', 'tool', JSON.stringify({ result: '2' }), a1.id, { toolCallId: 'call-1' });
|
||||
const a2 = msg('a2', 'assistant', '', t1.id, { thinking: 'reasoning-step-2' });
|
||||
const a3 = msg('a3', 'assistant', 'final-answer', a2.id);
|
||||
|
||||
// Seed initial messages before render
|
||||
conversationsStore.addMessageToActive(user);
|
||||
conversationsStore.addMessageToActive(a1);
|
||||
conversationsStore.addMessageToActive(t1);
|
||||
|
||||
const { container } = render(TestSnapshotMessagesWrapper);
|
||||
|
||||
// Add later reasoning + final answer after mount (prop is stale snapshot)
|
||||
conversationsStore.addMessageToActive(a2);
|
||||
conversationsStore.addMessageToActive(a3);
|
||||
|
||||
await waitForText(container, 'final-answer');
|
||||
|
||||
// With a stale snapshot, final-answer will be missing; this expectation enforces correct behavior.
|
||||
expect(container.textContent || '').toContain('final-answer');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import TestReactiveMessagesWrapper from './components/TestReactiveMessagesWrapper.svelte';
|
||||
import type { ChatRole, DatabaseMessage } from '$lib/types';
|
||||
|
||||
const waitForText = async (container: HTMLElement, text: string, timeoutMs = 2000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if ((container.textContent || '').includes(text)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
throw new Error(`Timed out waiting for text: ${text}`);
|
||||
};
|
||||
|
||||
const msg = (
|
||||
id: string,
|
||||
role: ChatRole,
|
||||
content: string,
|
||||
parent: string | null,
|
||||
extra: Partial<DatabaseMessage> = {}
|
||||
): DatabaseMessage => ({
|
||||
id,
|
||||
convId: 'c1',
|
||||
type: 'text',
|
||||
role,
|
||||
content,
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
parent: parent ?? '-1',
|
||||
children: [],
|
||||
timestamp: Date.now(),
|
||||
...extra
|
||||
});
|
||||
|
||||
/**
|
||||
* This test is designed to fail when reactivity breaks:
|
||||
* - We mount ChatMessages.
|
||||
* - We stream messages in phases.
|
||||
* - We assert that the second reasoning chunk appears without remount/refresh.
|
||||
*/
|
||||
describe('ChatMessages streaming regression', () => {
|
||||
it('renders later reasoning without refresh after a tool call', async () => {
|
||||
// reset store
|
||||
conversationsStore.activeMessages = [];
|
||||
|
||||
const user = msg('u1', 'user', 'Question', null);
|
||||
const a1 = msg('a1', 'assistant', '', user.id, {
|
||||
thinking: 'reasoning-step-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '1+1' }) }
|
||||
}
|
||||
])
|
||||
});
|
||||
const t1 = msg('t1', 'tool', JSON.stringify({ result: '2' }), a1.id, { toolCallId: 'call-1' });
|
||||
const a2 = msg('a2', 'assistant', '', t1.id, { thinking: 'reasoning-step-2' });
|
||||
const a3 = msg('a3', 'assistant', 'final-answer', a2.id);
|
||||
|
||||
// Render wrapper consuming live store data
|
||||
const { container } = render(TestReactiveMessagesWrapper);
|
||||
|
||||
// Phase 1: user + first assistant (with tool call), then tool result
|
||||
conversationsStore.addMessageToActive(user);
|
||||
conversationsStore.addMessageToActive(a1);
|
||||
conversationsStore.addMessageToActive(t1);
|
||||
|
||||
// Let DOM update
|
||||
await waitForText(container, 'reasoning-step-1');
|
||||
expect(container.textContent || '').toContain('reasoning-step-1');
|
||||
expect(container.textContent || '').not.toContain('reasoning-step-2');
|
||||
expect(container.textContent || '').not.toContain('final-answer');
|
||||
|
||||
// Phase 2: stream in later reasoning (a2)
|
||||
conversationsStore.addMessageToActive(a2);
|
||||
await waitForText(container, 'reasoning-step-2');
|
||||
|
||||
const afterA2 = container.textContent || '';
|
||||
expect(afterA2).toContain('reasoning-step-1'); // old reasoning still present
|
||||
expect(afterA2).toContain('reasoning-step-2'); // new reasoning must appear without refresh
|
||||
|
||||
// Phase 3: final assistant content
|
||||
conversationsStore.addMessageToActive(a3);
|
||||
await waitForText(container, 'final-answer');
|
||||
|
||||
const finalText = container.textContent || '';
|
||||
expect(finalText).toContain('final-answer');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/svelte';
|
||||
import TestMessagesWrapper from './components/TestMessagesWrapper.svelte';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
|
||||
const makeMsg = (partial: Partial<DatabaseMessage>): DatabaseMessage => ({
|
||||
id: crypto.randomUUID(),
|
||||
convId: 'c1',
|
||||
role: 'assistant',
|
||||
type: 'text',
|
||||
parent: '-1',
|
||||
content: '',
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
timestamp: Date.now(),
|
||||
children: [],
|
||||
...partial
|
||||
});
|
||||
|
||||
describe('ChatMessages component reasoning streaming', () => {
|
||||
it('keeps showing later reasoning chunks inline after a tool call', async () => {
|
||||
const user = makeMsg({ id: 'u1', role: 'user', type: 'text', content: 'hi' });
|
||||
const a1 = makeMsg({ id: 'a1', thinking: 'reasoning-step-1' });
|
||||
|
||||
conversationsStore.activeMessages = [user, a1];
|
||||
|
||||
const { container } = render(TestMessagesWrapper, {
|
||||
props: { messages: conversationsStore.activeMessages }
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent || '').toContain('reasoning-step-1');
|
||||
});
|
||||
|
||||
// Streamed tool call
|
||||
conversationsStore.updateMessageAtIndex(1, {
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: '{"expression":"1+1"}' }
|
||||
}
|
||||
])
|
||||
});
|
||||
|
||||
// Tool result + next assistant continuation
|
||||
const tool = makeMsg({
|
||||
id: 't1',
|
||||
role: 'tool',
|
||||
type: 'tool',
|
||||
parent: 'a1',
|
||||
content: JSON.stringify({ result: '2' }),
|
||||
toolCallId: 'call-1'
|
||||
});
|
||||
const a2 = makeMsg({
|
||||
id: 'a2',
|
||||
parent: 't1',
|
||||
role: 'assistant',
|
||||
type: 'text',
|
||||
thinking: 'reasoning-step-2',
|
||||
content: 'final-answer'
|
||||
});
|
||||
|
||||
conversationsStore.addMessageToActive(tool);
|
||||
conversationsStore.addMessageToActive(a2);
|
||||
|
||||
await waitFor(() => {
|
||||
const text = container.textContent || '';
|
||||
expect(text).toContain('reasoning-step-1');
|
||||
expect(text).toContain('reasoning-step-2');
|
||||
expect(text).toContain('final-answer');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { render, waitFor, cleanup } from '@testing-library/svelte';
|
||||
import TestMessagesWrapper from './components/TestMessagesWrapper.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
|
||||
let idCounter = 0;
|
||||
const uid = () => `m-${++idCounter}`;
|
||||
|
||||
const makeMsg = (partial: Partial<DatabaseMessage>): DatabaseMessage => ({
|
||||
id: uid(),
|
||||
convId: 'c1',
|
||||
role: 'assistant',
|
||||
type: 'text',
|
||||
parent: '-1',
|
||||
content: '',
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
timestamp: Date.now(),
|
||||
children: [],
|
||||
...partial
|
||||
});
|
||||
|
||||
describe('ChatMessages inline reasoning streaming', () => {
|
||||
it('shows reasoning -> tool -> reasoning -> final in one reasoning block reactively', 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(TestMessagesWrapper, {
|
||||
props: { messages: conversationsStore.activeMessages }
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent || '').toContain('reasoning-step-1');
|
||||
});
|
||||
|
||||
// stream tool call onto same assistant
|
||||
conversationsStore.updateMessageAtIndex(1, {
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: '{"expression":"1+1"}' }
|
||||
}
|
||||
])
|
||||
});
|
||||
|
||||
// insert tool msg and chained assistant continuation with more thinking
|
||||
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');
|
||||
expect(text).toContain('2'); // tool result
|
||||
});
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import type { ChatRole, DatabaseMessage } from '$lib/types';
|
||||
import TestMessagesWrapper from './components/TestMessagesWrapper.svelte';
|
||||
|
||||
const waitForText = async (container: HTMLElement, text: string, timeoutMs = 2000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if ((container.textContent || '').includes(text)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
throw new Error(`Timed out waiting for text: ${text}`);
|
||||
};
|
||||
|
||||
// Utility to build a message quickly
|
||||
const msg = (
|
||||
id: string,
|
||||
role: ChatRole,
|
||||
content: string,
|
||||
parent: string | null,
|
||||
extra: Partial<DatabaseMessage> = {}
|
||||
): DatabaseMessage => ({
|
||||
id,
|
||||
convId: 'c1',
|
||||
type: 'text',
|
||||
role,
|
||||
content,
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
parent: parent ?? '-1',
|
||||
children: [],
|
||||
timestamp: Date.now(),
|
||||
...extra
|
||||
});
|
||||
|
||||
describe('ChatMessages inline tool rendering', () => {
|
||||
it('collapses reasoning+tool chain and shows arguments and result in one block', async () => {
|
||||
// Enable calculator tool (client-side tools)
|
||||
settingsStore.config = { ...SETTING_CONFIG_DEFAULT, enableCalculatorTool: true };
|
||||
|
||||
// Conversation context
|
||||
conversationsStore.activeConversation = {
|
||||
id: 'c1',
|
||||
name: 'Test',
|
||||
currNode: null,
|
||||
lastModified: Date.now()
|
||||
};
|
||||
|
||||
// Message chain: user -> assistant(thinking+toolcall) -> tool -> assistant(thinking) -> tool -> assistant(final)
|
||||
const user = msg('u1', 'user', 'Question', null);
|
||||
const a1 = msg('a1', 'assistant', 'Let me calculate that.', user.id, {
|
||||
thinking: 'step1',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '20.25/7.84' }) }
|
||||
}
|
||||
])
|
||||
});
|
||||
const t1 = msg(
|
||||
't1',
|
||||
'tool',
|
||||
JSON.stringify({ expression: '20.25/7.84', result: '2.5829', duration_ms: 1234 }),
|
||||
a1.id,
|
||||
{
|
||||
toolCallId: 'call-1'
|
||||
}
|
||||
);
|
||||
const a2 = msg('a2', 'assistant', '', t1.id, {
|
||||
thinking: 'step2',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-2',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'calculator',
|
||||
arguments: JSON.stringify({ expression: 'log2(2.5829)' })
|
||||
}
|
||||
}
|
||||
])
|
||||
});
|
||||
const t2 = msg(
|
||||
't2',
|
||||
'tool',
|
||||
JSON.stringify({ expression: 'log2(2.5829)', result: '1.3689', duration_ms: 50 }),
|
||||
a2.id,
|
||||
{
|
||||
toolCallId: 'call-2'
|
||||
}
|
||||
);
|
||||
const a3 = msg('a3', 'assistant', 'About 1.37 stops', t2.id, { thinking: 'final step' });
|
||||
|
||||
const messages = [user, a1, t1, a2, t2, a3];
|
||||
conversationsStore.activeMessages = messages;
|
||||
|
||||
const { container } = render(TestMessagesWrapper, {
|
||||
target: document.body,
|
||||
props: { messages }
|
||||
});
|
||||
|
||||
await waitForText(container, 'Arguments');
|
||||
await waitForText(container, 'Let me calculate that.');
|
||||
|
||||
// One assistant card after collapsing the chain
|
||||
const assistants = container.querySelectorAll('[aria-label="Assistant message with actions"]');
|
||||
expect(assistants.length).toBe(1);
|
||||
|
||||
// Arguments and result should both be visible
|
||||
expect(container.textContent).toContain('Arguments');
|
||||
expect(container.textContent).toContain('20.25/7.84');
|
||||
expect(container.textContent).toContain('1.3689');
|
||||
expect(container.textContent).toContain('1.23s');
|
||||
|
||||
// Content produced before the first tool call should not be lost when the chain collapses.
|
||||
expect(container.textContent).toContain('Let me calculate that.');
|
||||
});
|
||||
|
||||
it('does not render post-reasoning tool calls inside the reasoning block', async () => {
|
||||
settingsStore.config = {
|
||||
...SETTING_CONFIG_DEFAULT,
|
||||
enableCalculatorTool: true,
|
||||
showThoughtInProgress: true
|
||||
};
|
||||
|
||||
conversationsStore.activeConversation = {
|
||||
id: 'c1',
|
||||
name: 'Test',
|
||||
currNode: null,
|
||||
lastModified: Date.now()
|
||||
};
|
||||
|
||||
const user = msg('u1', 'user', 'Question', null);
|
||||
const a1 = msg('a1', 'assistant', 'Here is the answer (before tool).', user.id, {
|
||||
thinking: 'done thinking',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: JSON.stringify({ expression: '1+1' }) }
|
||||
}
|
||||
]),
|
||||
// Simulate streaming so the reasoning block is expanded and in-DOM.
|
||||
timestamp: 0
|
||||
});
|
||||
const t1 = msg(
|
||||
't1',
|
||||
'tool',
|
||||
JSON.stringify({ expression: '1+1', result: '2', duration_ms: 10 }),
|
||||
a1.id,
|
||||
{
|
||||
toolCallId: 'call-1'
|
||||
}
|
||||
);
|
||||
const a2 = msg('a2', 'assistant', 'And here is the rest (after tool).', t1.id, {
|
||||
timestamp: 0
|
||||
});
|
||||
|
||||
const messages = [user, a1, t1, a2];
|
||||
conversationsStore.activeMessages = messages;
|
||||
|
||||
const { container } = render(TestMessagesWrapper, {
|
||||
target: document.body,
|
||||
props: { messages }
|
||||
});
|
||||
|
||||
await waitForText(container, 'Arguments');
|
||||
await waitForText(container, 'Here is the answer (before tool).');
|
||||
await waitForText(container, 'And here is the rest (after tool).');
|
||||
|
||||
const assistant = container.querySelector('[aria-label="Assistant message with actions"]');
|
||||
expect(assistant).toBeTruthy();
|
||||
|
||||
// Tool call should exist overall...
|
||||
expect(container.querySelectorAll('[data-testid="tool-call-block"]').length).toBe(1);
|
||||
|
||||
// ...but it should not be rendered inside the reasoning collapsible content.
|
||||
const reasoningRoot = assistant
|
||||
? Array.from(assistant.querySelectorAll('[data-state]')).find((el) =>
|
||||
(el.textContent ?? '').includes('Reasoning')
|
||||
)
|
||||
: null;
|
||||
expect(reasoningRoot).toBeTruthy();
|
||||
expect(reasoningRoot?.querySelectorAll('[data-testid="tool-call-block"]').length ?? 0).toBe(0);
|
||||
const reasoningTrigger = assistant?.querySelector(
|
||||
'[data-collapsible-trigger]'
|
||||
) as HTMLElement | null;
|
||||
// "Show thought in progress" auto-opens only for thought-only output.
|
||||
// Once regular content starts, it should auto-collapse immediately.
|
||||
expect(reasoningTrigger?.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
// Ordering: pre-tool content -> tool arguments -> post-tool content.
|
||||
const fullText = container.textContent ?? '';
|
||||
expect(fullText.indexOf('Here is the answer (before tool).')).toBeGreaterThanOrEqual(0);
|
||||
expect(fullText.indexOf('Arguments')).toBeGreaterThan(
|
||||
fullText.indexOf('Here is the answer (before tool).')
|
||||
);
|
||||
expect(fullText.indexOf('And here is the rest (after tool).')).toBeGreaterThan(
|
||||
fullText.indexOf('Arguments')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import type { ChatRole, DatabaseMessage } from '$lib/types';
|
||||
import TestMessagesWrapper from './components/TestMessagesWrapper.svelte';
|
||||
|
||||
const msg = (
|
||||
id: string,
|
||||
role: ChatRole,
|
||||
content: string,
|
||||
parent: string | null,
|
||||
extra: Partial<DatabaseMessage> = {}
|
||||
): DatabaseMessage => ({
|
||||
id,
|
||||
convId: 'c1',
|
||||
type: 'text',
|
||||
role,
|
||||
content,
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
parent: parent ?? '-1',
|
||||
children: [],
|
||||
timestamp: Date.now(),
|
||||
...extra
|
||||
});
|
||||
|
||||
describe('ChatMessages with multiple code_interpreter_javascript calls', () => {
|
||||
it('does not reuse earlier tool outputs for later tool blocks', async () => {
|
||||
settingsStore.config = {
|
||||
...SETTING_CONFIG_DEFAULT,
|
||||
enableCalculatorTool: false,
|
||||
enableCodeInterpreterTool: true
|
||||
};
|
||||
|
||||
conversationsStore.activeConversation = {
|
||||
id: 'c1',
|
||||
name: 'Test',
|
||||
currNode: null,
|
||||
lastModified: Date.now()
|
||||
};
|
||||
|
||||
// Build a single assistant chain with two code_interpreter_javascript calls.
|
||||
const user = msg('u1', 'user', 'calc two things', null);
|
||||
const a1 = msg('a1', 'assistant', '', user.id, {
|
||||
thinking: 'first call',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'code-1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'code_interpreter_javascript',
|
||||
arguments: JSON.stringify({ code: '1+1' })
|
||||
}
|
||||
}
|
||||
])
|
||||
});
|
||||
const t1 = msg('t1', 'tool', JSON.stringify({ expression: '1+1', result: '2' }), a1.id, {
|
||||
toolCallId: 'code-1'
|
||||
});
|
||||
const a2 = msg('a2', 'assistant', '', t1.id, {
|
||||
thinking: 'second call',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'code-2',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'code_interpreter_javascript',
|
||||
arguments: JSON.stringify({ code: '5*5' })
|
||||
}
|
||||
}
|
||||
])
|
||||
});
|
||||
// Second tool message is intentionally empty to simulate "pending"
|
||||
const t2 = msg('t2', 'tool', '', a2.id, {
|
||||
toolCallId: 'code-2'
|
||||
});
|
||||
const a3 = msg('a3', 'assistant', 'done', t2.id, {});
|
||||
|
||||
const messages = [user, a1, t1, a2, t2, a3];
|
||||
conversationsStore.activeMessages = messages;
|
||||
|
||||
const { container } = render(TestMessagesWrapper, {
|
||||
target: document.body,
|
||||
props: { messages }
|
||||
});
|
||||
|
||||
const toolBlocks = container.querySelectorAll('[data-testid="tool-call-block"]');
|
||||
expect(toolBlocks.length).toBe(2);
|
||||
|
||||
// First tool shows its result "2"
|
||||
expect(toolBlocks[0].textContent || '').toContain('2');
|
||||
|
||||
// Second tool (pending) should NOT show the first result; it should be empty/pending.
|
||||
expect(toolBlocks[1].textContent || '').not.toContain('2');
|
||||
expect(toolBlocks[1].textContent || '').not.toMatch(/Result\s*2/);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import ChatSettings from '$lib/components/app/chat/ChatSettings/ChatSettings.svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
async function selectToolsSection(container: HTMLElement) {
|
||||
const toolsButtons = Array.from(container.querySelectorAll('button')).filter((b) =>
|
||||
(b.textContent ?? '').includes('Tools')
|
||||
);
|
||||
for (const b of toolsButtons) b.click();
|
||||
await tick();
|
||||
}
|
||||
|
||||
describe('ChatSettings tool-defined settings', () => {
|
||||
it('shows a tool setting when the tool is enabled', async () => {
|
||||
settingsStore.config = { ...SETTING_CONFIG_DEFAULT, enableCodeInterpreterTool: true };
|
||||
|
||||
const { container } = render(ChatSettings, { target: document.body, props: {} });
|
||||
await selectToolsSection(container);
|
||||
|
||||
expect(container.textContent).toContain('Code Interpreter (JavaScript)');
|
||||
expect(container.textContent).toContain('Code interpreter timeout (seconds)');
|
||||
});
|
||||
|
||||
it('shows the tool setting disabled when tool is off, then enables it', async () => {
|
||||
settingsStore.config = { ...SETTING_CONFIG_DEFAULT, enableCodeInterpreterTool: false };
|
||||
|
||||
const { container } = render(ChatSettings, { target: document.body, props: {} });
|
||||
await selectToolsSection(container);
|
||||
|
||||
expect(container.textContent).toContain('Code Interpreter (JavaScript)');
|
||||
expect(container.textContent).toContain('Code interpreter timeout (seconds)');
|
||||
const timeoutInput = container.querySelector(
|
||||
'input#codeInterpreterTimeoutSeconds'
|
||||
) as HTMLInputElement | null;
|
||||
expect(timeoutInput).toBeTruthy();
|
||||
expect(timeoutInput?.disabled).toBe(true);
|
||||
|
||||
const enableLabel = container.querySelector(
|
||||
'label[for="enableCodeInterpreterTool"]'
|
||||
) as HTMLElement | null;
|
||||
enableLabel?.click();
|
||||
await tick();
|
||||
|
||||
expect(container.textContent).toContain('Code interpreter timeout (seconds)');
|
||||
expect(timeoutInput?.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import '$lib/services/tools';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import { findToolByName } from '$lib/services/tools/registry';
|
||||
import { runCodeInterpreter } from '$lib/services/tools/codeInterpreter';
|
||||
|
||||
describe('code_interpreter_javascript tool (browser Worker)', () => {
|
||||
it('evaluates JS and returns final value', async () => {
|
||||
const code = `
|
||||
const f1 = 2.8;
|
||||
const f2 = 4.5;
|
||||
const ratio = (f2 / f1) ** 2;
|
||||
const stops = Math.log2(ratio);
|
||||
stops;
|
||||
`;
|
||||
|
||||
const { result, error, logs } = await runCodeInterpreter(code, 3000);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(logs).toStrictEqual([]);
|
||||
expect(result).toBeDefined();
|
||||
expect(Number(result)).toBeCloseTo(1.3689963, 5);
|
||||
});
|
||||
|
||||
it('captures console output', async () => {
|
||||
const code = `
|
||||
console.log('hello', 1+1);
|
||||
return 42;
|
||||
`;
|
||||
|
||||
const { result, error, logs } = await runCodeInterpreter(code, 3000);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(logs).toContain('hello 2');
|
||||
expect(result).toBe('42');
|
||||
});
|
||||
|
||||
it('handles block-ending scripts (FizzBuzz loop) without forcing a return', async () => {
|
||||
const code = `
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
let out = '';
|
||||
if (i % 3 === 0) out += 'Fizz';
|
||||
if (i % 5 === 0) out += 'Buzz';
|
||||
console.log(out || i);
|
||||
}
|
||||
`;
|
||||
|
||||
const { error, logs, result } = await runCodeInterpreter(code, 3000);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
// Should have produced logs, but no forced result
|
||||
expect(logs.length).toBeGreaterThan(0);
|
||||
expect(logs[0]).toBe('1');
|
||||
expect(result === undefined || result === '').toBe(true);
|
||||
});
|
||||
|
||||
it('reports line number and content on error', async () => {
|
||||
const code = `
|
||||
const x = 1;
|
||||
const y = oops; // ReferenceError
|
||||
return x + y;
|
||||
`;
|
||||
|
||||
const { error, errorLine, errorLineContent, errorStack, errorFrame } = await runCodeInterpreter(
|
||||
code,
|
||||
3000
|
||||
);
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(errorLine).toBeGreaterThan(0);
|
||||
expect(errorLineContent ?? '').toBeTypeOf('string');
|
||||
expect(errorFrame || errorStack || '').toSatisfy((s: string) => s.length > 0);
|
||||
});
|
||||
|
||||
it('includes at least a frame for syntax errors without line capture', async () => {
|
||||
const code = `
|
||||
function broken() {
|
||||
const a = 1;
|
||||
const b = 2;
|
||||
return a + ; // syntax error
|
||||
}
|
||||
broken();
|
||||
`;
|
||||
|
||||
const { error, errorLine, errorLineContent, errorFrame, errorStack } = await runCodeInterpreter(
|
||||
code,
|
||||
3000
|
||||
);
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(
|
||||
errorLine !== undefined ||
|
||||
(errorLineContent && errorLineContent.length > 0) ||
|
||||
(errorFrame && errorFrame.length > 0) ||
|
||||
(errorStack && errorStack.length > 0)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('captures line number and source line for a missing parenthesis syntax error', async () => {
|
||||
const code = [
|
||||
'function f() {',
|
||||
' const x = Math.max(1, 2; // missing )',
|
||||
' return x;',
|
||||
'}',
|
||||
'f();'
|
||||
].join('\n');
|
||||
|
||||
const { error, errorLine, errorLineContent } = await runCodeInterpreter(code, 3000);
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(errorLine).toBe(2);
|
||||
expect(errorLineContent || '').toContain('Math.max');
|
||||
});
|
||||
|
||||
it('includes line and snippet in the tool output string for syntax errors', async () => {
|
||||
const tool = findToolByName('code_interpreter_javascript');
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const code = [
|
||||
'function f() {',
|
||||
' const x = Math.max(1, 2; // missing )',
|
||||
' return x;',
|
||||
'}',
|
||||
'f();'
|
||||
].join('\n');
|
||||
|
||||
const res = await tool!.execute(JSON.stringify({ code }));
|
||||
expect(res.content).toContain('Error');
|
||||
expect(res.content).toContain('line 2');
|
||||
expect(res.content).toContain('Math.max');
|
||||
});
|
||||
|
||||
it('respects the configured timeout setting', async () => {
|
||||
const tool = findToolByName('code_interpreter_javascript');
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const cfg = { ...SETTING_CONFIG_DEFAULT, codeInterpreterTimeoutSeconds: 0.05 };
|
||||
const res = await tool!.execute(JSON.stringify({ code: '(() => { while (true) {} })()' }), cfg);
|
||||
|
||||
expect(res.content).toContain('Timed out');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||
import { setChatActionsContext } from '$lib/contexts';
|
||||
import ChatMessage from '$lib/components/app/chat/ChatMessages/ChatMessage.svelte';
|
||||
|
||||
const props = $props<{
|
||||
message: DatabaseMessage;
|
||||
onDelete: (message: DatabaseMessage) => void;
|
||||
}>();
|
||||
|
||||
setChatActionsContext({
|
||||
copy: () => {},
|
||||
delete: props.onDelete,
|
||||
navigateToSibling: () => {},
|
||||
editWithBranching: () => {},
|
||||
editWithReplacement: () => {},
|
||||
editUserMessagePreserveResponses: () => {},
|
||||
regenerateWithBranching: () => {},
|
||||
continueAssistantMessage: () => {}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Sidebar.Provider open={false}>
|
||||
<ChatMessage message={props.message} />
|
||||
</Sidebar.Provider>
|
||||
</Tooltip.Provider>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||
import ChatScreen from '$lib/components/app/chat/ChatScreen/ChatScreen.svelte';
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Sidebar.Provider open={false}>
|
||||
<ChatScreen />
|
||||
</Sidebar.Provider>
|
||||
</Tooltip.Provider>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||
import ChatMessages from '$lib/components/app/chat/ChatMessages/ChatMessages.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
|
||||
const props = $props<{ messages: DatabaseMessage[] }>();
|
||||
const messages = props.messages || [];
|
||||
conversationsStore.activeMessages = messages;
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Sidebar.Provider open={false}>
|
||||
<ChatMessages {messages} />
|
||||
</Sidebar.Provider>
|
||||
</Tooltip.Provider>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||
import ChatMessages from '$lib/components/app/chat/ChatMessages/ChatMessages.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
|
||||
// Always pass a new array copy so downstream components re-render on store mutations
|
||||
const liveMessages = $derived.by(() => [...conversationsStore.activeMessages]);
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Sidebar.Provider open={false}>
|
||||
<ChatMessages messages={liveMessages} />
|
||||
</Sidebar.Provider>
|
||||
</Tooltip.Provider>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||
import ChatMessages from '$lib/components/app/chat/ChatMessages/ChatMessages.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
|
||||
// Snapshot once at mount, non-reactive (mirrors messages={activeMessages()} usage)
|
||||
const snapshot = conversationsStore.activeMessages;
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Sidebar.Provider open={false}>
|
||||
<ChatMessages messages={snapshot} />
|
||||
</Sidebar.Provider>
|
||||
</Tooltip.Provider>
|
||||
|
|
@ -2,5 +2,5 @@ import { expect, test } from '@playwright/test';
|
|||
|
||||
test('home page has expected h1', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
await expect(page.locator('h1').first()).toBeVisible();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('settings shows code interpreter timeout field (disabled until tool enabled)', async ({
|
||||
page
|
||||
}) => {
|
||||
// Ensure config is present before app boot
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
'LlamaCppWebui.config',
|
||||
JSON.stringify({
|
||||
enableCalculatorTool: true,
|
||||
enableCodeInterpreterTool: false
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Mock /props and model list so UI initializes in static preview
|
||||
await page.route('**/props**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
role: 'model',
|
||||
system_prompt: null,
|
||||
default_generation_settings: { params: {}, n_ctx: 4096 }
|
||||
})
|
||||
});
|
||||
});
|
||||
await page.route('**/v1/models**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ object: 'list', data: [{ id: 'mock-model', object: 'model' }] })
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:8181/');
|
||||
|
||||
// Open settings dialog (header has only the settings button)
|
||||
await page.locator('header button').first().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Navigate to Tools section
|
||||
await page.getByRole('button', { name: 'Tools' }).first().click();
|
||||
await expect(page.getByRole('heading', { name: 'Tools' })).toBeVisible();
|
||||
|
||||
// Tool toggle exists
|
||||
await expect(page.getByText('Code Interpreter (JavaScript)')).toBeVisible();
|
||||
|
||||
// Tool-defined setting always visible
|
||||
await expect(page.getByText('Code interpreter timeout (seconds)')).toBeVisible();
|
||||
|
||||
const timeoutInput = page.locator('#codeInterpreterTimeoutSeconds');
|
||||
await expect(timeoutInput).toBeVisible();
|
||||
await expect(timeoutInput).toBeDisabled();
|
||||
|
||||
// Enable tool
|
||||
await page.locator('label[for="enableCodeInterpreterTool"]').click();
|
||||
await expect(page.locator('#enableCodeInterpreterTool')).toHaveAttribute('data-state', 'checked');
|
||||
await expect(timeoutInput).toBeEnabled();
|
||||
|
||||
// Default value
|
||||
await expect(timeoutInput).toHaveValue('30');
|
||||
});
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Helper to build a streaming body with newline-delimited "data:" lines
|
||||
const streamBody = (...lines: string[]) => lines.map((l) => `data: ${l}\n\n`).join('');
|
||||
|
||||
test('reasoning + tool + final content stream into one reasoning block', async ({ page }) => {
|
||||
page.on('console', (msg) => {
|
||||
console.log('BROWSER LOG:', msg.type(), msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => {
|
||||
console.log('BROWSER ERROR:', err.message);
|
||||
});
|
||||
|
||||
// Inject fetch stubs early so server props/models succeed before the app initializes
|
||||
await page.addInitScript(() => {
|
||||
// Ensure calculator tool is enabled so tool calls are processed client-side
|
||||
localStorage.setItem(
|
||||
'LlamaCppWebui.config',
|
||||
JSON.stringify({ enableCalculatorTool: true, showToolCalls: true })
|
||||
);
|
||||
|
||||
const propsBody = {
|
||||
role: 'model',
|
||||
system_prompt: null,
|
||||
default_generation_settings: { params: {}, n_ctx: 4096 }
|
||||
};
|
||||
const modelsBody = { object: 'list', data: [{ id: 'mock-model', object: 'model' }] };
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = (...args) => {
|
||||
const url = args[0] instanceof Request ? args[0].url : String(args[0]);
|
||||
if (url.includes('/props')) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(propsBody), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes('/v1/models')) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(modelsBody), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
}
|
||||
return originalFetch(...args);
|
||||
};
|
||||
});
|
||||
|
||||
// Mock /props to keep the UI enabled
|
||||
await page.route('**/props**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
role: 'model',
|
||||
system_prompt: null,
|
||||
default_generation_settings: { params: {}, n_ctx: 4096 }
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// Mock model list
|
||||
await page.route('**/v1/models**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ object: 'list', data: [{ id: 'mock-model', object: 'model' }] })
|
||||
});
|
||||
});
|
||||
|
||||
// Mock the chat completions endpoint twice: first call returns reasoning+tool call,
|
||||
// second call (after tool execution) returns more reasoning + final answer.
|
||||
let completionCall = 0;
|
||||
await page.route('**/v1/chat/completions**', async (route) => {
|
||||
if (completionCall === 0) {
|
||||
const chunk1 = JSON.stringify({
|
||||
choices: [{ delta: { reasoning_content: 'reasoning-step-1' } }]
|
||||
});
|
||||
const chunk2 = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: '{"expression":"1+1"}' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
const body = streamBody(chunk1, chunk2, '[DONE]');
|
||||
completionCall++;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Second completion: continued reasoning and final answer
|
||||
const chunk3 = JSON.stringify({
|
||||
choices: [{ delta: { reasoning_content: 'reasoning-step-2' } }]
|
||||
});
|
||||
const chunk4 = JSON.stringify({
|
||||
choices: [{ delta: { content: 'final-answer' } }]
|
||||
});
|
||||
const body = streamBody(chunk3, chunk4, '[DONE]');
|
||||
completionCall++;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:8181/');
|
||||
|
||||
// Wait for the form to be ready
|
||||
const textarea = page.locator('[data-slot="input-area"] textarea');
|
||||
await expect(textarea).toBeVisible();
|
||||
const sendButton = page.getByRole('button', { name: 'Send' });
|
||||
|
||||
// Force-enable the input in case the UI guarded on server props during static preview
|
||||
await page.evaluate(() => {
|
||||
const ta = document.querySelector<HTMLTextAreaElement>(
|
||||
'[data-slot="input-area"] textarea'
|
||||
);
|
||||
if (ta) ta.disabled = false;
|
||||
const submit = document.querySelector<HTMLButtonElement>('button[type="submit"]');
|
||||
if (submit) submit.disabled = false;
|
||||
});
|
||||
|
||||
// Send a user message
|
||||
const requestPromise = page.waitForRequest('**/v1/chat/completions');
|
||||
await textarea.fill('test');
|
||||
|
||||
// After typing, the send button should become enabled
|
||||
await expect(sendButton).toBeEnabled({ timeout: 5000 });
|
||||
|
||||
// Click the Send button (has sr-only text "Send")
|
||||
await sendButton.click();
|
||||
|
||||
await requestPromise;
|
||||
|
||||
// Wait for final content to appear (streamed)
|
||||
await expect(page.getByText('final-answer')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Expand the reasoning block to make streamed reasoning visible
|
||||
const reasoningToggles = page.getByRole('button', { name: 'Reasoning' });
|
||||
const toggleCount = await reasoningToggles.count();
|
||||
for (let i = 0; i < toggleCount; i++) {
|
||||
await reasoningToggles.nth(i).click();
|
||||
}
|
||||
|
||||
// Ensure both reasoning steps are present in a single reasoning block
|
||||
const reasoningBlock = page.locator('[aria-label="Assistant message with actions"]').first();
|
||||
await expect(reasoningBlock).toContainText('reasoning-step-1', { timeout: 5000 });
|
||||
await expect(reasoningBlock).toContainText('reasoning-step-2', { timeout: 5000 });
|
||||
|
||||
// Tool result should be displayed (calculator result "2")
|
||||
await expect(page.getByText('2', { exact: true })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
expect(completionCall).toBe(2);
|
||||
});
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* End-to-end regression that reproduces the real streaming bug reported by users:
|
||||
* - The model streams reasoning → tool call → (new request) reasoning → final answer.
|
||||
* - We only mock the HTTP API; the UI, stores, and client-side tool execution run unchanged.
|
||||
* - The test asserts that the second reasoning chunk becomes visible *while the second
|
||||
* completion stream is still open* (i.e., without a page refresh and before final content).
|
||||
*/
|
||||
test('reasoning -> tool -> reasoning streams inline without refresh', async ({ page }) => {
|
||||
// Install fetch stub & config before the app loads
|
||||
await page.addInitScript(() => {
|
||||
// Enable the calculator tool client-side
|
||||
localStorage.setItem(
|
||||
'LlamaCppWebui.config',
|
||||
JSON.stringify({ enableCalculatorTool: true, showToolCalls: true })
|
||||
);
|
||||
|
||||
let completionCall = 0;
|
||||
let secondController: ReadableStreamDefaultController | null = null;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
const w = window as unknown as {
|
||||
__completionCallCount?: number;
|
||||
__flushSecondStream?: () => void;
|
||||
};
|
||||
w.__completionCallCount = 0;
|
||||
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
|
||||
// Mock minimal server props & model list
|
||||
if (url.includes('/props')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
role: 'model',
|
||||
system_prompt: null,
|
||||
default_generation_settings: { params: {}, n_ctx: 4096 }
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/v1/models')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ object: 'list', data: [{ id: 'mock-model', object: 'model' }] }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Mock the streaming chat completions endpoint
|
||||
if (url.includes('/v1/chat/completions')) {
|
||||
completionCall += 1;
|
||||
w.__completionCallCount = completionCall;
|
||||
|
||||
// First request: reasoning + tool call, then DONE
|
||||
if (completionCall === 1) {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
choices: [{ delta: { reasoning_content: 'reasoning-step-1' } }]
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: { name: 'calculator', arguments: '{"expression":"1+1"}' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.resolve(
|
||||
new Response(stream, {
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Second request: stream reasoning, leave stream open until test flushes final content
|
||||
if (completionCall === 2) {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
secondController = controller;
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
choices: [{ delta: { reasoning_content: 'reasoning-step-2' } }]
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
// DO NOT close yet – test will push final content later.
|
||||
}
|
||||
});
|
||||
|
||||
// expose a helper so the test can finish the stream after the assertion
|
||||
w.__flushSecondStream = () => {
|
||||
if (!secondController) return;
|
||||
secondController.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
choices: [{ delta: { content: 'final-answer' } }]
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
secondController.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
secondController.close();
|
||||
};
|
||||
|
||||
return Promise.resolve(
|
||||
new Response(stream, {
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to real fetch for everything else
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
});
|
||||
|
||||
// Launch the UI
|
||||
await page.goto('http://localhost:8181/');
|
||||
|
||||
// Send a user message to trigger streaming
|
||||
const textarea = page.locator('[data-slot="input-area"] textarea');
|
||||
await textarea.fill('test message');
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// Expand the reasoning block so hidden text becomes visible
|
||||
const reasoningToggle = page.getByRole('button', { name: /Reasoning/ });
|
||||
await expect(reasoningToggle).toBeVisible({ timeout: 5000 });
|
||||
await reasoningToggle.click();
|
||||
|
||||
// Wait for first reasoning chunk to appear (UI)
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.locator('[aria-label="Assistant message with actions"]').first().innerText()
|
||||
)
|
||||
.toContain('reasoning-step-1');
|
||||
|
||||
// Wait for tool result (calculator executed client-side)
|
||||
await expect(page.getByText('2', { exact: true })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Ensure the follow-up completion request (after tool execution) was actually triggered
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => (window as unknown as { __completionCallCount?: number }).__completionCallCount || 0
|
||||
)
|
||||
)
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Critical assertion: the second reasoning chunk should appear while the second stream is still open
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.locator('[aria-label="Assistant message with actions"]').first().innerText()
|
||||
)
|
||||
.toContain('reasoning-step-2');
|
||||
|
||||
// Finish streaming the final content and verify it appears
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as { __flushSecondStream?: () => void }).__flushSecondStream?.()
|
||||
);
|
||||
await expect(page.getByText('final-answer').first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const streamBody = (...lines: string[]) => lines.map((l) => `data: ${l}\n\n`).join('');
|
||||
|
||||
test('tool output does not echo tool arguments back to the model', async ({ page }) => {
|
||||
const LARGE_CODE = [
|
||||
'// LARGE_CODE_BEGIN',
|
||||
'function findMaxL(outerWidth, outerHeight, outerDepth, innerWidth, innerHeight) {',
|
||||
' const outerDiagonal = Math.sqrt(outerWidth**2 + outerHeight**2 + outerDepth**2);',
|
||||
' const maxL = Math.sqrt(outerDiagonal**2 - innerWidth**2 - innerHeight**2);',
|
||||
' return maxL;',
|
||||
'}',
|
||||
'findMaxL(98, 76, 52, 6, 6);',
|
||||
'// LARGE_CODE_END',
|
||||
'',
|
||||
'// filler to simulate large prompt without breaking last-expression transform',
|
||||
`const __filler = "${'x'.repeat(4000)}";`,
|
||||
'1 + 1'
|
||||
].join('\n');
|
||||
|
||||
// Ensure tool is enabled before app boot
|
||||
await page.addInitScript(
|
||||
(cfg) => {
|
||||
localStorage.setItem('LlamaCppWebui.config', JSON.stringify(cfg));
|
||||
},
|
||||
{
|
||||
enableCodeInterpreterTool: true,
|
||||
showToolCalls: true
|
||||
}
|
||||
);
|
||||
|
||||
// Mock /props and model list so UI initializes in static preview
|
||||
await page.route('**/props**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
role: 'model',
|
||||
system_prompt: null,
|
||||
default_generation_settings: { params: {}, n_ctx: 4096 }
|
||||
})
|
||||
});
|
||||
});
|
||||
await page.route('**/v1/models**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ object: 'list', data: [{ id: 'mock-model', object: 'model' }] })
|
||||
});
|
||||
});
|
||||
|
||||
type ChatCompletionRequestBody = { messages?: unknown };
|
||||
let secondRequestBody: ChatCompletionRequestBody | string | null = null;
|
||||
let completionCall = 0;
|
||||
|
||||
await page.route('**/v1/chat/completions**', async (route) => {
|
||||
if (completionCall === 0) {
|
||||
const chunk1 = JSON.stringify({
|
||||
choices: [{ delta: { reasoning_content: 'reasoning-step-1' } }]
|
||||
});
|
||||
const chunk2 = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'code_interpreter_javascript',
|
||||
arguments: JSON.stringify({ code: LARGE_CODE })
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
const body = streamBody(chunk1, chunk2, '[DONE]');
|
||||
completionCall++;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Second completion request should include tool output. Capture request body for assertions.
|
||||
try {
|
||||
secondRequestBody = route.request().postDataJSON() as ChatCompletionRequestBody;
|
||||
} catch {
|
||||
secondRequestBody = route.request().postData();
|
||||
}
|
||||
|
||||
const chunk3 = JSON.stringify({
|
||||
choices: [{ delta: { content: 'final-answer' } }]
|
||||
});
|
||||
const body = streamBody(chunk3, '[DONE]');
|
||||
completionCall++;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:8181/');
|
||||
|
||||
// Send a user message to trigger streaming
|
||||
const textarea = page.locator('[data-slot="input-area"] textarea');
|
||||
await expect(textarea).toBeVisible();
|
||||
await textarea.fill('run js');
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await expect(page.getByText('final-answer')).toBeVisible({ timeout: 10000 });
|
||||
expect(completionCall).toBe(2);
|
||||
expect(secondRequestBody).toBeTruthy();
|
||||
if (!secondRequestBody || typeof secondRequestBody === 'string') {
|
||||
throw new Error('Expected second completion request body JSON');
|
||||
}
|
||||
|
||||
// Assert the second request contains tool output but does NOT duplicate the large code in the tool output content.
|
||||
const secondJson = secondRequestBody as unknown as ChatCompletionRequestBody;
|
||||
const messages = secondJson.messages;
|
||||
expect(Array.isArray(messages)).toBe(true);
|
||||
const typedMessages = messages as Array<Record<string, unknown>>;
|
||||
|
||||
const toolMessage = typedMessages.find((m) => m.role === 'tool' && m.tool_call_id === 'call-1');
|
||||
expect(toolMessage).toBeTruthy();
|
||||
expect(String(toolMessage?.content ?? '')).not.toContain('LARGE_CODE_BEGIN');
|
||||
expect(String(toolMessage?.content ?? '')).not.toContain('function findMaxL');
|
||||
|
||||
// The original tool call arguments are still present in the assistant tool call message.
|
||||
const assistantWithToolCall = typedMessages.find(
|
||||
(m) => m.role === 'assistant' && Array.isArray(m.tool_calls)
|
||||
);
|
||||
expect(assistantWithToolCall).toBeTruthy();
|
||||
expect(JSON.stringify(assistantWithToolCall?.tool_calls ?? null)).toContain('LARGE_CODE_BEGIN');
|
||||
// Preserve the model's reasoning across tool-call resumptions (required for gpt-oss).
|
||||
expect(String(assistantWithToolCall?.reasoning_content ?? '')).toContain('reasoning-step-1');
|
||||
});
|
||||
|
|
@ -86,6 +86,21 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { setChatActionsContext } from '$lib/contexts';
|
||||
|
||||
setChatActionsContext({
|
||||
copy: () => {},
|
||||
delete: () => {},
|
||||
navigateToSibling: () => {},
|
||||
editWithBranching: () => {},
|
||||
editWithReplacement: () => {},
|
||||
editUserMessagePreserveResponses: () => {},
|
||||
regenerateWithBranching: () => {},
|
||||
continueAssistantMessage: () => {}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="User"
|
||||
args={{
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ export default defineConfig({
|
|||
'katex-fonts': resolve('node_modules/katex/dist/fonts')
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['acorn']
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: MAX_ASSET_SIZE,
|
||||
chunkSizeWarningLimit: 3072,
|
||||
|
|
@ -120,9 +123,9 @@ export default defineConfig({
|
|||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
instances: [{ browser: 'chromium' }]
|
||||
instances: [{ browser: 'chromium', headless: true }]
|
||||
},
|
||||
include: ['tests/client/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
include: ['tests/client/**/*.{test,spec}.{js,ts,svelte}'],
|
||||
setupFiles: ['./vitest-setup-client.ts']
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue