webui: Client-side implementation of tool calling with calculator tool and (javascript) code interpreter tool
This commit is contained in:
parent
ec98e20021
commit
0a428ff112
|
|
@ -39,6 +39,7 @@
|
|||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/svelte": "^5.2.9",
|
||||
"@types/node": "^22",
|
||||
"@vitest/browser": "^3.2.3",
|
||||
"bits-ui": "^2.14.4",
|
||||
|
|
@ -2624,6 +2625,32 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/svelte": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.9.tgz",
|
||||
"integrity": "sha512-p0Lg/vL1iEsEasXKSipvW9nBCtItQGhYvxL8OZ4w7/IDdC+LGoSJw4mMS5bndVFON/gWryitEhMr29AlO4FvBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "9.x.x || 10.x.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
|
||||
"vite": "*",
|
||||
"vitest": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"vitest": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "14.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/svelte": "^5.2.9",
|
||||
"@types/node": "^22",
|
||||
"@vitest/browser": "^3.2.3",
|
||||
"bits-ui": "^2.14.4",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
|
||||
import ChatMessageUser from './ChatMessageUser.svelte';
|
||||
import ChatMessageSystem from './ChatMessageSystem.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
toolParentIds?: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -35,9 +37,21 @@
|
|||
onEditUserMessagePreserveResponses,
|
||||
onNavigateToSibling,
|
||||
onRegenerateWithBranching,
|
||||
siblingInfo = null
|
||||
siblingInfo = null,
|
||||
toolParentIds
|
||||
}: Props = $props();
|
||||
|
||||
type MessageWithToolExtras = DatabaseMessage & {
|
||||
_actionTargetId?: string;
|
||||
_toolMessagesCollected?: { toolCallId?: string | null; parsed: unknown }[];
|
||||
};
|
||||
|
||||
const actionTargetId = $derived((message as MessageWithToolExtras)._actionTargetId ?? message.id);
|
||||
|
||||
function getActionTarget(): DatabaseMessage {
|
||||
return conversationsStore.activeMessages.find((m) => m.id === actionTargetId) ?? message;
|
||||
}
|
||||
|
||||
let deletionInfo = $state<{
|
||||
totalCount: number;
|
||||
userMessages: number;
|
||||
|
|
@ -95,12 +109,14 @@
|
|||
}
|
||||
|
||||
function handleConfirmDelete() {
|
||||
onDelete?.(message);
|
||||
const target = getActionTarget();
|
||||
onDelete?.(target);
|
||||
showDeleteDialog = false;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
deletionInfo = await chatStore.getDeletionInfo(message.id);
|
||||
const target = getActionTarget();
|
||||
deletionInfo = await chatStore.getDeletionInfo(target.id);
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
|
||||
|
|
@ -136,11 +152,13 @@
|
|||
}
|
||||
|
||||
function handleRegenerate(modelOverride?: string) {
|
||||
onRegenerateWithBranching?.(message, modelOverride);
|
||||
const target = getActionTarget();
|
||||
onRegenerateWithBranching?.(target, modelOverride);
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
onContinueAssistantMessage?.(message);
|
||||
const target = getActionTarget();
|
||||
onContinueAssistantMessage?.(target);
|
||||
}
|
||||
|
||||
function handleSaveEdit() {
|
||||
|
|
@ -213,7 +231,7 @@
|
|||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
/>
|
||||
{:else}
|
||||
{:else if message.role === 'assistant'}
|
||||
<ChatMessageAssistant
|
||||
bind:textareaElement
|
||||
class={className}
|
||||
|
|
@ -240,5 +258,11 @@
|
|||
{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}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,17 @@
|
|||
ChatMessageActions,
|
||||
ChatMessageStatistics,
|
||||
ChatMessageThinkingBlock,
|
||||
CopyToClipboardIcon,
|
||||
MarkdownContent,
|
||||
ModelsSelector
|
||||
ModelsSelector,
|
||||
BadgeChatStatistic
|
||||
} from '$lib/components/app';
|
||||
import type { DatabaseMessage, ApiChatCompletionToolCall } from '$lib/types';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
|
||||
import { isLoading } from '$lib/stores/chat.svelte';
|
||||
import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Check, X, Wrench } from '@lucide/svelte';
|
||||
import { Check, Clock, X, Wrench } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { INPUT_CLASSES } from '$lib/constants/input-classes';
|
||||
|
|
@ -21,6 +22,24 @@
|
|||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type ToolSegment =
|
||||
| { kind: 'thinking'; content: string }
|
||||
| { kind: 'tool'; toolCalls: ApiChatCompletionToolCall[]; parentId: string };
|
||||
type ToolParsed = { expression?: string; result?: string; duration_ms?: number };
|
||||
type CollectedToolMessage = {
|
||||
toolCallId?: string | null;
|
||||
parsed: ToolParsed;
|
||||
};
|
||||
type MessageWithToolExtras = DatabaseMessage & {
|
||||
_segments?: ToolSegment[];
|
||||
_toolMessagesCollected?: CollectedToolMessage[];
|
||||
};
|
||||
type ToolMessageLike = Pick<DatabaseMessage, 'role' | 'content'> & {
|
||||
toolCallId?: string | null;
|
||||
parent?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -53,6 +72,9 @@
|
|||
textareaElement?: HTMLTextAreaElement;
|
||||
thinkingContent: string | null;
|
||||
toolCallContent: ApiChatCompletionToolCall[] | string | null;
|
||||
toolParentIds?: string[];
|
||||
segments?: ToolSegment[] | null;
|
||||
toolMessagesCollected?: CollectedToolMessage[] | null;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -80,17 +102,86 @@
|
|||
siblingInfo = null,
|
||||
textareaElement = $bindable(),
|
||||
thinkingContent,
|
||||
toolCallContent = null
|
||||
toolCallContent = null,
|
||||
toolParentIds = [message.id],
|
||||
segments: segmentsProp = null,
|
||||
toolMessagesCollected: toolMessagesCollectedProp = (message as MessageWithToolExtras)
|
||||
._toolMessagesCollected ?? null
|
||||
}: Props = $props();
|
||||
|
||||
// Keep segments/tool messages in sync with the merged assistant produced upstream.
|
||||
let segments = $derived(segmentsProp ?? (message as MessageWithToolExtras)._segments ?? null);
|
||||
let toolMessagesCollected = $derived(
|
||||
toolMessagesCollectedProp ?? (message as MessageWithToolExtras)._toolMessagesCollected ?? null
|
||||
);
|
||||
|
||||
const toolCalls = $derived(
|
||||
Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
|
||||
);
|
||||
const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
|
||||
|
||||
const processingState = useProcessingState();
|
||||
let currentConfig = $derived(config());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
const toolMessages = $derived<ToolMessageLike[]>(
|
||||
(() => {
|
||||
const ids = new SvelteSet<string>();
|
||||
if (toolCalls) {
|
||||
for (const tc of toolCalls) {
|
||||
if (tc.id) ids.add(tc.id);
|
||||
}
|
||||
}
|
||||
const collected = toolMessagesCollected ?? [];
|
||||
return conversationsStore.activeMessages
|
||||
.filter(
|
||||
(m) =>
|
||||
m.role === 'tool' &&
|
||||
(toolParentIds.includes(m.parent) || (m.toolCallId && ids.has(m.toolCallId)))
|
||||
)
|
||||
.concat(
|
||||
collected.map((c) => ({
|
||||
role: 'tool',
|
||||
content: JSON.stringify(c.parsed),
|
||||
toolCallId: c.toolCallId ?? undefined,
|
||||
parent: toolParentIds[0]
|
||||
}))
|
||||
);
|
||||
})()
|
||||
);
|
||||
const toolMessagesById = $derived<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;
|
||||
}
|
||||
|
||||
function advanceToolResult(toolCall: ApiChatCompletionToolCall) {
|
||||
return getToolResult(toolCall) ?? null;
|
||||
}
|
||||
let displayedModel = $derived((): string | null => {
|
||||
if (message.model) {
|
||||
return message.model;
|
||||
|
|
@ -116,56 +207,63 @@
|
|||
}
|
||||
});
|
||||
|
||||
function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
|
||||
const callNumber = index + 1;
|
||||
const functionName = toolCall.function?.name?.trim();
|
||||
const label = functionName || `Call #${callNumber}`;
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
const id = toolCall.id?.trim();
|
||||
if (id) {
|
||||
payload.id = id;
|
||||
}
|
||||
|
||||
const type = toolCall.type?.trim();
|
||||
if (type) {
|
||||
payload.type = type;
|
||||
}
|
||||
|
||||
if (toolCall.function) {
|
||||
const fnPayload: Record<string, unknown> = {};
|
||||
|
||||
const name = toolCall.function.name?.trim();
|
||||
if (name) {
|
||||
fnPayload.name = name;
|
||||
}
|
||||
|
||||
const rawArguments = toolCall.function.arguments?.trim();
|
||||
if (rawArguments) {
|
||||
try {
|
||||
fnPayload.arguments = JSON.parse(rawArguments);
|
||||
} catch {
|
||||
fnPayload.arguments = rawArguments;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(fnPayload).length > 0) {
|
||||
payload.function = fnPayload;
|
||||
function parseArguments(
|
||||
toolCall: ApiChatCompletionToolCall
|
||||
): { pairs: { key: string; value: string }[] } | { raw: string } | null {
|
||||
const rawArguments = toolCall.function?.arguments?.trim();
|
||||
if (!rawArguments) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(rawArguments);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const pairs = Object.entries(parsed).map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
}));
|
||||
return { pairs };
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors, fall back to raw
|
||||
}
|
||||
|
||||
const formattedPayload = JSON.stringify(payload, null, 2);
|
||||
|
||||
return {
|
||||
label,
|
||||
tooltip: formattedPayload,
|
||||
copyValue: formattedPayload
|
||||
};
|
||||
return { raw: rawArguments };
|
||||
}
|
||||
|
||||
function handleCopyToolCall(payload: string) {
|
||||
void copyToClipboard(payload, 'Tool call copied to clipboard');
|
||||
function parseToolMessage(msg: ToolMessageLike): ToolParsed | null {
|
||||
if (!msg.content) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(msg.content);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const duration =
|
||||
typeof parsed.duration_ms === 'number' ? (parsed.duration_ms as number) : undefined;
|
||||
return {
|
||||
expression: parsed.expression ?? undefined,
|
||||
result: parsed.result ?? undefined,
|
||||
duration_ms: duration
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// not JSON; fall back
|
||||
}
|
||||
return { result: msg.content };
|
||||
}
|
||||
|
||||
function formatDurationSeconds(durationMs?: number): string | null {
|
||||
if (durationMs === undefined) return null;
|
||||
if (!Number.isFinite(durationMs)) return null;
|
||||
return `${(durationMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function toFencedCodeBlock(code: string, language: string): string {
|
||||
const matches = code.match(/`+/g) ?? [];
|
||||
const maxBackticks = matches.reduce((max, s) => Math.max(max, s.length), 0);
|
||||
const fence = '`'.repeat(Math.max(3, maxBackticks + 1));
|
||||
return `${fence}${language}\n${code}\n${fence}`;
|
||||
}
|
||||
|
||||
function getToolLabel(toolCall: ApiChatCompletionToolCall, index: number) {
|
||||
const name = toolCall.function?.name ?? '';
|
||||
if (name === 'calculator') return 'Calculator';
|
||||
if (name === 'code_interpreter_javascript') return 'Code Interpreter (JavaScript)';
|
||||
return name || `Call #${index + 1}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -176,10 +274,153 @@
|
|||
>
|
||||
{#if thinkingContent}
|
||||
<ChatMessageThinkingBlock
|
||||
reasoningContent={thinkingContent}
|
||||
isStreaming={!message.timestamp}
|
||||
reasoningContent={segments && segments.length ? null : thinkingContent}
|
||||
isStreaming={!message.timestamp || isLoading()}
|
||||
hasRegularContent={!!messageContent?.trim()}
|
||||
/>
|
||||
>
|
||||
{#if segments && segments.length}
|
||||
{#each 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 ?? `${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}
|
||||
</ChatMessageThinkingBlock>
|
||||
{/if}
|
||||
|
||||
{#if !thinkingContent && segments && segments.length}
|
||||
{#each segments as segment, segIndex (segIndex)}
|
||||
{#if segment.kind === 'tool'}
|
||||
{#each segment.toolCalls as toolCall, index (toolCall.id ?? `${segIndex}-${index}`)}
|
||||
{@const argsParsed = parseArguments(toolCall)}
|
||||
{@const parsed = advanceToolResult(toolCall)}
|
||||
{@const collectedResult = toolMessagesCollected
|
||||
? toolMessagesCollected.find((c) => c.toolCallId === toolCall.id)?.parsed?.result
|
||||
: undefined}
|
||||
{@const collectedDurationMs = toolMessagesCollected
|
||||
? toolMessagesCollected.find((c) => c.toolCallId === toolCall.id)?.parsed?.duration_ms
|
||||
: undefined}
|
||||
{@const durationMs = parsed?.duration_ms ?? collectedDurationMs}
|
||||
{@const durationText = formatDurationSeconds(durationMs)}
|
||||
<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}
|
||||
|
||||
{#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
|
||||
|
|
@ -232,9 +473,9 @@
|
|||
</div>
|
||||
{:else if message.role === 'assistant'}
|
||||
{#if config().disableReasoningFormat}
|
||||
<pre class="raw-output">{messageContent || ''}</pre>
|
||||
<pre class="raw-output">{messageContent}</pre>
|
||||
{:else}
|
||||
<MarkdownContent content={messageContent || ''} />
|
||||
<MarkdownContent content={messageContent ?? ''} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm whitespace-pre-wrap">
|
||||
|
|
@ -264,48 +505,6 @@
|
|||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if config().showToolCalls}
|
||||
{#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
|
||||
<span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Wrench class="h-3.5 w-3.5" />
|
||||
|
||||
<span>Tool calls:</span>
|
||||
</span>
|
||||
|
||||
{#if toolCalls && toolCalls.length > 0}
|
||||
{#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
|
||||
{@const badge = formatToolCallBadge(toolCall, index)}
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
title={badge.tooltip}
|
||||
aria-label={`Copy tool call ${badge.label}`}
|
||||
onclick={() => handleCopyToolCall(badge.copyValue)}
|
||||
>
|
||||
{badge.label}
|
||||
<CopyToClipboardIcon
|
||||
text={badge.copyValue}
|
||||
ariaLabel={`Copy tool call ${badge.label}`}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if fallbackToolCalls}
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-badge tool-call-badge--fallback inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
title={fallbackToolCalls}
|
||||
aria-label="Copy tool call payload"
|
||||
onclick={() => handleCopyToolCall(fallbackToolCalls)}
|
||||
>
|
||||
{fallbackToolCalls}
|
||||
<CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if message.timestamp && !isEditing}
|
||||
|
|
|
|||
|
|
@ -22,18 +22,31 @@
|
|||
|
||||
const currentConfig = config();
|
||||
|
||||
let isExpanded = $state(currentConfig.showThoughtInProgress);
|
||||
// Expand automatically only while streaming when "Show thought in progress" is enabled.
|
||||
const initialAutoExpand = isStreaming && currentConfig.showThoughtInProgress;
|
||||
let isExpanded = $state(initialAutoExpand);
|
||||
let autoExpanded = $state(initialAutoExpand);
|
||||
|
||||
$effect(() => {
|
||||
if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) {
|
||||
if (isStreaming && currentConfig.showThoughtInProgress && !isExpanded && !autoExpanded) {
|
||||
isExpanded = true;
|
||||
autoExpanded = true;
|
||||
} else if (!isStreaming && autoExpanded) {
|
||||
// Only collapse if this session auto-opened it; user manual toggles stay respected.
|
||||
isExpanded = false;
|
||||
autoExpanded = false;
|
||||
}
|
||||
});
|
||||
</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 +72,9 @@
|
|||
<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 ?? ''}
|
||||
<slot>
|
||||
{reasoningContent ?? ''}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts">
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
}
|
||||
|
||||
let { class: className = '', message }: Props = $props();
|
||||
|
||||
const parseContent = () => {
|
||||
try {
|
||||
return JSON.parse(message.content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parsed = $derived(parseContent());
|
||||
</script>
|
||||
|
||||
<div class={`rounded-md border bg-muted/40 p-3 text-sm text-foreground ${className}`}>
|
||||
<div class="mb-1 text-xs font-semibold text-muted-foreground uppercase">Tool • Calculator</div>
|
||||
{#if parsed && typeof parsed === 'object'}
|
||||
{#if parsed.expression}
|
||||
<div class="mb-1 text-xs tracking-wide text-muted-foreground uppercase">Expression</div>
|
||||
<div class="rounded-sm bg-background/70 px-2 py-1 font-mono text-xs">
|
||||
{parsed.expression}
|
||||
</div>
|
||||
{/if}
|
||||
{#if parsed.result !== undefined}
|
||||
<div class="mt-2 mb-1 text-xs tracking-wide text-muted-foreground uppercase">Result</div>
|
||||
<div class="rounded-sm bg-background/70 px-2 py-1 font-mono text-xs">
|
||||
{parsed.result}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="rounded-sm bg-background/70 px-2 py-1 font-mono text-xs break-words whitespace-pre-wrap"
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -4,6 +4,12 @@
|
|||
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { getMessageSiblings } from '$lib/utils';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type {
|
||||
ApiChatCompletionToolCall,
|
||||
ChatMessageSiblingInfo,
|
||||
DatabaseMessage
|
||||
} from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -13,6 +19,11 @@
|
|||
|
||||
let { class: className, messages = [], onUserAction }: Props = $props();
|
||||
|
||||
// Prefer live store messages; fall back to the provided prop (e.g. initial render/tests).
|
||||
const sourceMessages = $derived(
|
||||
conversationsStore.activeMessages.length ? conversationsStore.activeMessages : messages
|
||||
);
|
||||
|
||||
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
||||
const currentConfig = config();
|
||||
|
||||
|
|
@ -37,31 +48,195 @@
|
|||
}
|
||||
});
|
||||
|
||||
let displayMessages = $derived.by(() => {
|
||||
if (!messages.length) {
|
||||
return [];
|
||||
}
|
||||
type ToolSegment =
|
||||
| { kind: 'thinking'; content: string }
|
||||
| { kind: 'tool'; toolCalls: ApiChatCompletionToolCall[]; parentId: string };
|
||||
type CollectedToolMessage = {
|
||||
toolCallId?: string | null;
|
||||
parsed: { expression?: string; result?: string; duration_ms?: number };
|
||||
};
|
||||
type AssistantDisplayMessage = DatabaseMessage & {
|
||||
_toolParentIds?: string[];
|
||||
_segments?: ToolSegment[];
|
||||
_toolMessagesCollected?: CollectedToolMessage[];
|
||||
_actionTargetId?: string;
|
||||
};
|
||||
type DisplayEntry = {
|
||||
message: DatabaseMessage | AssistantDisplayMessage;
|
||||
siblingInfo: ChatMessageSiblingInfo;
|
||||
};
|
||||
|
||||
let displayMessages = $derived.by((): DisplayEntry[] => {
|
||||
// Force reactivity on message field changes (important for streaming updates)
|
||||
const signature = sourceMessages
|
||||
.map(
|
||||
(m) =>
|
||||
`${m.id}-${m.role}-${m.parent ?? ''}-${m.timestamp ?? ''}-${m.thinking ?? ''}-${
|
||||
m.toolCalls ?? ''
|
||||
}-${m.content ?? ''}`
|
||||
)
|
||||
.join('|');
|
||||
// signature is unused but ensures Svelte tracks the above fields
|
||||
void signature;
|
||||
|
||||
if (!sourceMessages.length) return [];
|
||||
|
||||
// Filter out system messages if showSystemMessage is false
|
||||
const filteredMessages = currentConfig.showSystemMessage
|
||||
? messages
|
||||
: messages.filter((msg) => msg.type !== 'system');
|
||||
? sourceMessages
|
||||
: sourceMessages.filter((msg) => msg.type !== 'system');
|
||||
|
||||
return filteredMessages.map((message) => {
|
||||
const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
|
||||
const visited = new SvelteSet<string>();
|
||||
const result: DisplayEntry[] = [];
|
||||
|
||||
const getChildren = (parentId: string, role?: string) =>
|
||||
filteredMessages.filter((m) => m.parent === parentId && (!role || m.role === role));
|
||||
|
||||
const normalizeToolParsed = (
|
||||
value: unknown
|
||||
): { expression?: string; result?: string; duration_ms?: number } | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return {
|
||||
message,
|
||||
siblingInfo: siblingInfo || {
|
||||
message,
|
||||
siblingIds: [message.id],
|
||||
expression: typeof obj.expression === 'string' ? obj.expression : undefined,
|
||||
result: typeof obj.result === 'string' ? obj.result : undefined,
|
||||
duration_ms: typeof obj.duration_ms === 'number' ? obj.duration_ms : undefined
|
||||
};
|
||||
};
|
||||
|
||||
for (const msg of filteredMessages) {
|
||||
if (visited.has(msg.id)) continue;
|
||||
// Don't render tools directly, but keep them for collection; skip marking visited here
|
||||
|
||||
// Skip tool messages (rendered inline)
|
||||
if (msg.role === 'tool') continue;
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
// Collapse consecutive assistant/tool chains into one display message
|
||||
const toolParentIds: string[] = [];
|
||||
const thinkingParts: string[] = [];
|
||||
const toolCallsCombined: ApiChatCompletionToolCall[] = [];
|
||||
const segments: ToolSegment[] = [];
|
||||
const toolMessagesCollected: CollectedToolMessage[] = [];
|
||||
const toolCallIds = new SvelteSet<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 });
|
||||
}
|
||||
let thisAssistantToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
if (currentAssistant.toolCalls) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(currentAssistant.toolCalls);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const tc of parsed as ApiChatCompletionToolCall[]) {
|
||||
if (tc?.id && toolCallIds.has(tc.id)) continue;
|
||||
if (tc?.id) toolCallIds.add(tc.id);
|
||||
toolCallsCombined.push(tc);
|
||||
thisAssistantToolCalls.push(tc);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
if (thisAssistantToolCalls.length) {
|
||||
segments.push({
|
||||
kind: 'tool',
|
||||
toolCalls: thisAssistantToolCalls,
|
||||
parentId: currentAssistant.id
|
||||
});
|
||||
}
|
||||
|
||||
const toolChildren = getChildren(currentAssistant.id, 'tool');
|
||||
for (const t of toolChildren) {
|
||||
visited.add(t.id);
|
||||
// capture parsed tool message for inline use
|
||||
try {
|
||||
const parsedUnknown: unknown = t.content ? JSON.parse(t.content) : null;
|
||||
const normalized = normalizeToolParsed(parsedUnknown);
|
||||
toolMessagesCollected.push({
|
||||
toolCallId: t.toolCallId,
|
||||
parsed: normalized ?? { result: t.content }
|
||||
});
|
||||
} catch {
|
||||
const p = { result: t.content };
|
||||
toolMessagesCollected.push({ toolCallId: t.toolCallId, parsed: p });
|
||||
}
|
||||
}
|
||||
|
||||
// Assume at most one assistant child chained after tools
|
||||
let nextAssistant = toolChildren
|
||||
.map((t) => getChildren(t.id, 'assistant')[0])
|
||||
.find((a) => a !== undefined);
|
||||
|
||||
// Also allow direct assistant->assistant continuation (no intervening tool)
|
||||
if (!nextAssistant) {
|
||||
nextAssistant = getChildren(currentAssistant.id, 'assistant')[0];
|
||||
}
|
||||
|
||||
if (nextAssistant) {
|
||||
currentAssistant = nextAssistant;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const siblingInfo: ChatMessageSiblingInfo = getMessageSiblings(
|
||||
allConversationMessages,
|
||||
msg.id
|
||||
) || {
|
||||
message: msg,
|
||||
siblingIds: [msg.id],
|
||||
currentIndex: 0,
|
||||
totalSiblings: 1
|
||||
}
|
||||
};
|
||||
|
||||
const mergedAssistant: AssistantDisplayMessage = {
|
||||
...(currentAssistant ?? msg),
|
||||
content: currentAssistant?.content ?? '',
|
||||
thinking: thinkingParts.filter(Boolean).join('\n\n'),
|
||||
toolCalls: toolCallsCombined.length ? JSON.stringify(toolCallsCombined) : '',
|
||||
_toolParentIds: toolParentIds,
|
||||
_segments: segments,
|
||||
_actionTargetId: msg.id,
|
||||
_toolMessagesCollected: toolMessagesCollected
|
||||
};
|
||||
|
||||
result.push({ message: mergedAssistant, siblingInfo });
|
||||
continue;
|
||||
}
|
||||
|
||||
// user/system messages
|
||||
const siblingInfo: ChatMessageSiblingInfo = getMessageSiblings(
|
||||
allConversationMessages,
|
||||
msg.id
|
||||
) || {
|
||||
message: msg,
|
||||
siblingIds: [msg.id],
|
||||
currentIndex: 0,
|
||||
totalSiblings: 1
|
||||
};
|
||||
});
|
||||
result.push({ message: msg, siblingInfo });
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
function getToolParentIdsForMessage(msg: DisplayEntry['message']): string[] | undefined {
|
||||
return (msg as AssistantDisplayMessage)._toolParentIds;
|
||||
}
|
||||
|
||||
function getDisplayKeyForMessage(msg: DisplayEntry['message']): string {
|
||||
return (msg as AssistantDisplayMessage)._actionTargetId ?? msg.id;
|
||||
}
|
||||
|
||||
async function handleNavigateToSibling(siblingId: string) {
|
||||
await conversationsStore.navigateToSibling(siblingId);
|
||||
}
|
||||
|
|
@ -121,11 +296,12 @@
|
|||
</script>
|
||||
|
||||
<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
|
||||
{#each displayMessages as { message, siblingInfo } (message.id)}
|
||||
{#each displayMessages as { message, siblingInfo } (getDisplayKeyForMessage(message))}
|
||||
<ChatMessage
|
||||
class="mx-auto w-full max-w-[48rem]"
|
||||
{message}
|
||||
{siblingInfo}
|
||||
toolParentIds={getToolParentIdsForMessage(message)}
|
||||
onDelete={handleDeleteMessage}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onEditWithBranching={handleEditWithBranching}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -38,6 +38,8 @@
|
|||
let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
|
||||
let autoScrollEnabled = $state(true);
|
||||
let chatScrollContainer: HTMLDivElement | undefined = $state();
|
||||
// Always hand ChatMessages a fresh array so mutations in the store trigger rerenders/merges
|
||||
const liveMessages = $derived.by(() => [...conversationsStore.activeMessages]);
|
||||
let dragCounter = $state(0);
|
||||
let isDragOver = $state(false);
|
||||
let lastScrollTop = $state(0);
|
||||
|
|
@ -345,6 +347,14 @@
|
|||
scrollInterval = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// Keep view pinned to bottom across message merges while auto-scroll is enabled.
|
||||
$effect(() => {
|
||||
void liveMessages;
|
||||
if (!disableAutoScroll && autoScrollEnabled) {
|
||||
queueMicrotask(() => scrollChatToBottom('instant'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isDragOver}
|
||||
|
|
@ -369,7 +379,7 @@
|
|||
>
|
||||
<ChatMessages
|
||||
class="mb-16 md:mb-24"
|
||||
messages={activeMessages()}
|
||||
messages={liveMessages}
|
||||
onUserAction={() => {
|
||||
if (!disableAutoScroll) {
|
||||
userScrolledUp = false;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
Moon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Database
|
||||
Database,
|
||||
Wrench
|
||||
} from '@lucide/svelte';
|
||||
import {
|
||||
ChatSettingsFooter,
|
||||
|
|
@ -20,6 +21,8 @@
|
|||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import type { Component } from 'svelte';
|
||||
import '$lib/services/tools'; // ensure built-in tools register
|
||||
import { getAllTools } from '$lib/services/tools';
|
||||
|
||||
interface Props {
|
||||
onSave?: () => void;
|
||||
|
|
@ -27,259 +30,286 @@
|
|||
|
||||
let { onSave }: Props = $props();
|
||||
|
||||
const settingSections: Array<{
|
||||
fields: SettingsFieldConfig[];
|
||||
icon: Component;
|
||||
title: string;
|
||||
}> = [
|
||||
{
|
||||
title: 'General',
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
key: 'theme',
|
||||
label: 'Theme',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon }
|
||||
]
|
||||
},
|
||||
{ key: 'apiKey', label: 'API Key', type: 'input' },
|
||||
{
|
||||
key: 'systemMessage',
|
||||
label: 'System Message',
|
||||
type: 'textarea'
|
||||
},
|
||||
{
|
||||
key: 'pasteLongTextToFileLen',
|
||||
label: 'Paste long text to file length',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'copyTextAttachmentsAsPlainText',
|
||||
label: 'Copy text attachments as plain text',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'enableContinueGeneration',
|
||||
label: 'Enable "Continue" button',
|
||||
type: 'checkbox',
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: 'pdfAsImage',
|
||||
label: 'Parse PDF as image',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'askForTitleConfirmation',
|
||||
label: 'Ask for confirmation before changing conversation title',
|
||||
type: 'checkbox'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
icon: Monitor,
|
||||
fields: [
|
||||
{
|
||||
key: 'showMessageStats',
|
||||
label: 'Show message generation statistics',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'showThoughtInProgress',
|
||||
label: 'Show thought in progress',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'keepStatsVisible',
|
||||
label: 'Keep stats visible after generation',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'autoMicOnEmpty',
|
||||
label: 'Show microphone on empty input',
|
||||
type: 'checkbox',
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: 'renderUserContentAsMarkdown',
|
||||
label: 'Render user content as Markdown',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'disableAutoScroll',
|
||||
label: 'Disable automatic scroll',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'alwaysShowSidebarOnDesktop',
|
||||
label: 'Always show sidebar on desktop',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'autoShowSidebarOnNewChat',
|
||||
label: 'Auto-show sidebar on new chat',
|
||||
type: 'checkbox'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Sampling',
|
||||
icon: Funnel,
|
||||
fields: [
|
||||
{
|
||||
key: 'temperature',
|
||||
label: 'Temperature',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dynatemp_range',
|
||||
label: 'Dynamic temperature range',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dynatemp_exponent',
|
||||
label: 'Dynamic temperature exponent',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'top_k',
|
||||
label: 'Top K',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'top_p',
|
||||
label: 'Top P',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'min_p',
|
||||
label: 'Min P',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'xtc_probability',
|
||||
label: 'XTC probability',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'xtc_threshold',
|
||||
label: 'XTC threshold',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'typ_p',
|
||||
label: 'Typical P',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'max_tokens',
|
||||
label: 'Max tokens',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'samplers',
|
||||
label: 'Samplers',
|
||||
type: 'input'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Penalties',
|
||||
icon: AlertTriangle,
|
||||
fields: [
|
||||
{
|
||||
key: 'repeat_last_n',
|
||||
label: 'Repeat last N',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'repeat_penalty',
|
||||
label: 'Repeat penalty',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'presence_penalty',
|
||||
label: 'Presence penalty',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'frequency_penalty',
|
||||
label: 'Frequency penalty',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dry_multiplier',
|
||||
label: 'DRY multiplier',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dry_base',
|
||||
label: 'DRY base',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dry_allowed_length',
|
||||
label: 'DRY allowed length',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dry_penalty_last_n',
|
||||
label: 'DRY penalty last N',
|
||||
type: 'input'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Import/Export',
|
||||
icon: Database,
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: 'Developer',
|
||||
icon: Code,
|
||||
fields: [
|
||||
{
|
||||
key: 'showToolCalls',
|
||||
label: 'Show tool call labels',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'disableReasoningFormat',
|
||||
label: 'Show raw LLM output',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'custom',
|
||||
label: 'Custom JSON',
|
||||
type: 'textarea'
|
||||
}
|
||||
]
|
||||
}
|
||||
// TODO: Experimental features section will be implemented after initial release
|
||||
// This includes Python interpreter (Pyodide integration) and other experimental features
|
||||
// {
|
||||
// title: 'Experimental',
|
||||
// icon: Beaker,
|
||||
// fields: [
|
||||
// {
|
||||
// key: 'pyInterpreterEnabled',
|
||||
// label: 'Enable Python interpreter',
|
||||
// type: 'checkbox'
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
];
|
||||
let localConfig: SettingsConfigType = $state({ ...config() });
|
||||
|
||||
function getToolFields(cfg: SettingsConfigType): SettingsFieldConfig[] {
|
||||
return getAllTools().flatMap((tool) => {
|
||||
const enableField: SettingsFieldConfig = {
|
||||
key: tool.enableConfigKey,
|
||||
label: tool.label,
|
||||
type: 'checkbox',
|
||||
help: tool.description
|
||||
};
|
||||
|
||||
const enabled = Boolean(cfg[tool.enableConfigKey]);
|
||||
const settingsFields = (tool.settings ?? []).map((s) => ({
|
||||
...s,
|
||||
disabled: !enabled
|
||||
}));
|
||||
|
||||
return [enableField, ...settingsFields];
|
||||
});
|
||||
}
|
||||
|
||||
const settingSections = $derived.by(
|
||||
(): Array<{
|
||||
fields: SettingsFieldConfig[];
|
||||
icon: Component;
|
||||
title: string;
|
||||
}> => [
|
||||
{
|
||||
title: 'General',
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
key: 'theme',
|
||||
label: 'Theme',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon }
|
||||
]
|
||||
},
|
||||
{ key: 'apiKey', label: 'API Key', type: 'input' },
|
||||
{
|
||||
key: 'systemMessage',
|
||||
label: 'System Message',
|
||||
type: 'textarea'
|
||||
},
|
||||
{
|
||||
key: 'pasteLongTextToFileLen',
|
||||
label: 'Paste long text to file length',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'copyTextAttachmentsAsPlainText',
|
||||
label: 'Copy text attachments as plain text',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'enableContinueGeneration',
|
||||
label: 'Enable "Continue" button',
|
||||
type: 'checkbox',
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: 'pdfAsImage',
|
||||
label: 'Parse PDF as image',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'askForTitleConfirmation',
|
||||
label: 'Ask for confirmation before changing conversation title',
|
||||
type: 'checkbox'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
icon: Monitor,
|
||||
fields: [
|
||||
{
|
||||
key: 'showMessageStats',
|
||||
label: 'Show message generation statistics',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'showThoughtInProgress',
|
||||
label: 'Show thought in progress',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'keepStatsVisible',
|
||||
label: 'Keep stats visible after generation',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'autoMicOnEmpty',
|
||||
label: 'Show microphone on empty input',
|
||||
type: 'checkbox',
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: 'renderUserContentAsMarkdown',
|
||||
label: 'Render user content as Markdown',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'disableAutoScroll',
|
||||
label: 'Disable automatic scroll',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'alwaysShowSidebarOnDesktop',
|
||||
label: 'Always show sidebar on desktop',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'autoShowSidebarOnNewChat',
|
||||
label: 'Auto-show sidebar on new chat',
|
||||
type: 'checkbox'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Sampling',
|
||||
icon: Funnel,
|
||||
fields: [
|
||||
{
|
||||
key: 'temperature',
|
||||
label: 'Temperature',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dynatemp_range',
|
||||
label: 'Dynamic temperature range',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dynatemp_exponent',
|
||||
label: 'Dynamic temperature exponent',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'top_k',
|
||||
label: 'Top K',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'top_p',
|
||||
label: 'Top P',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'min_p',
|
||||
label: 'Min P',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'xtc_probability',
|
||||
label: 'XTC probability',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'xtc_threshold',
|
||||
label: 'XTC threshold',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'typ_p',
|
||||
label: 'Typical P',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'max_tokens',
|
||||
label: 'Max tokens',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'samplers',
|
||||
label: 'Samplers',
|
||||
type: 'input'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Penalties',
|
||||
icon: AlertTriangle,
|
||||
fields: [
|
||||
{
|
||||
key: 'repeat_last_n',
|
||||
label: 'Repeat last N',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'repeat_penalty',
|
||||
label: 'Repeat penalty',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'presence_penalty',
|
||||
label: 'Presence penalty',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'frequency_penalty',
|
||||
label: 'Frequency penalty',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dry_multiplier',
|
||||
label: 'DRY multiplier',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dry_base',
|
||||
label: 'DRY base',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dry_allowed_length',
|
||||
label: 'DRY allowed length',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'dry_penalty_last_n',
|
||||
label: 'DRY penalty last N',
|
||||
type: 'input'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Import/Export',
|
||||
icon: Database,
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: 'Developer',
|
||||
icon: Code,
|
||||
fields: [
|
||||
{
|
||||
key: 'showToolCalls',
|
||||
label: 'Show tool call labels',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'disableReasoningFormat',
|
||||
label: 'Show raw LLM output',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'custom',
|
||||
label: 'Custom JSON',
|
||||
type: 'textarea'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
icon: Wrench,
|
||||
fields: getToolFields(localConfig)
|
||||
}
|
||||
// TODO: Experimental features section will be implemented after initial release
|
||||
// This includes Python interpreter (Pyodide integration) and other experimental features
|
||||
// {
|
||||
// title: 'Experimental',
|
||||
// icon: Beaker,
|
||||
// fields: [
|
||||
// {
|
||||
// key: 'pyInterpreterEnabled',
|
||||
// label: 'Enable Python interpreter',
|
||||
// type: 'checkbox'
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
]
|
||||
);
|
||||
|
||||
let activeSection = $state('General');
|
||||
let currentSection = $derived(
|
||||
settingSections.find((section) => section.title === activeSection) || settingSections[0]
|
||||
);
|
||||
let localConfig: SettingsConfigType = $state({ ...config() });
|
||||
|
||||
let canScrollLeft = $state(false);
|
||||
let canScrollRight = $state(false);
|
||||
|
|
@ -333,7 +363,8 @@
|
|||
'dry_multiplier',
|
||||
'dry_base',
|
||||
'dry_allowed_length',
|
||||
'dry_penalty_last_n'
|
||||
'dry_penalty_last_n',
|
||||
'codeInterpreterTimeoutSeconds'
|
||||
];
|
||||
|
||||
for (const field of numericFields) {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,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);
|
||||
|
|
@ -110,6 +111,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"
|
||||
|
|
@ -165,6 +167,7 @@
|
|||
<Select.Root
|
||||
type="single"
|
||||
value={currentValue}
|
||||
disabled={Boolean(field.disabled)}
|
||||
onValueChange={(value) => {
|
||||
if (field.key === 'theme' && value && onThemeChange) {
|
||||
onThemeChange(value);
|
||||
|
|
@ -227,6 +230,7 @@
|
|||
<Checkbox
|
||||
id={field.key}
|
||||
checked={Boolean(localConfig[field.key])}
|
||||
disabled={Boolean(field.disabled)}
|
||||
onCheckedChange={(checked) => onConfigChange(field.key, checked)}
|
||||
class="mt-1"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -296,19 +296,32 @@
|
|||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (content) {
|
||||
processMarkdown(content)
|
||||
.then((result) => {
|
||||
processedHtml = result;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to process markdown:', error);
|
||||
processedHtml = content.replace(/\n/g, '<br>');
|
||||
});
|
||||
} else {
|
||||
if (!content) {
|
||||
processedHtml = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render a safe, synchronous fallback immediately so streaming updates are visible
|
||||
const fallback = escapeHtml(content);
|
||||
processedHtml = fallback;
|
||||
|
||||
processMarkdown(content)
|
||||
.then((result) => {
|
||||
processedHtml = result;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to process markdown:', error);
|
||||
processedHtml = fallback;
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> = {
|
||||
// Ensure all built-in tools are registered before deriving defaults
|
||||
import '$lib/services/tools';
|
||||
import { getToolConfigDefaults, getToolSettingDefaults } from '$lib/services/tools/registry';
|
||||
|
||||
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: '',
|
||||
|
|
@ -45,6 +49,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.',
|
||||
|
|
@ -89,6 +99,10 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
|
|||
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.',
|
||||
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.',
|
||||
disableReasoningFormat:
|
||||
'Show raw LLM output without backend parsing and frontend Markdown rendering to inspect streaming across different models.',
|
||||
keepStatsVisible: 'Keep processing statistics visible after generation finishes.',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { getJsonHeaders } from '$lib/utils';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type { ChatRole, ApiToolDefinition, ApiChatCompletionRequestMessage } from '$lib/types';
|
||||
|
||||
/**
|
||||
* ChatService - Low-level API communication layer for Chat Completions
|
||||
|
|
@ -88,6 +89,8 @@ export class ChatService {
|
|||
samplers,
|
||||
custom,
|
||||
timings_per_token,
|
||||
tools,
|
||||
tool_choice,
|
||||
// Config options
|
||||
disableReasoningFormat
|
||||
} = options;
|
||||
|
|
@ -115,7 +118,11 @@ export class ChatService {
|
|||
const requestBody: ApiChatCompletionRequest = {
|
||||
messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
content: msg.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
|
||||
};
|
||||
|
|
@ -169,6 +176,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',
|
||||
|
|
@ -474,12 +488,8 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -576,10 +586,25 @@ export class ChatService {
|
|||
static convertDbMessageToApiChatMessageData(
|
||||
message: DatabaseMessage & { extra?: DatabaseMessageExtra[] }
|
||||
): ApiChatMessageData {
|
||||
let toolCalls: ApiChatCompletionToolCallDelta[] | undefined;
|
||||
if (message.role === 'assistant' && message.toolCalls) {
|
||||
try {
|
||||
const parsed = JSON.parse(message.toolCalls);
|
||||
if (Array.isArray(parsed)) {
|
||||
toolCalls = parsed as ApiChatCompletionToolCallDelta[];
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed toolCalls; UI will still show raw string if needed
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.extra || message.extra.length === 0) {
|
||||
return {
|
||||
role: message.role as 'user' | 'assistant' | 'system',
|
||||
content: message.content
|
||||
role: message.role as ChatRole,
|
||||
content: message.content,
|
||||
// tool_call_id is only relevant for tool role messages
|
||||
...(message.toolCallId ? { tool_call_id: message.toolCallId } : {}),
|
||||
...(toolCalls ? { tool_calls: toolCalls } : {})
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -666,8 +691,10 @@ export class ChatService {
|
|||
}
|
||||
|
||||
return {
|
||||
role: message.role as 'user' | 'assistant' | 'system',
|
||||
content: contentParts
|
||||
role: message.role as ChatRole,
|
||||
content: contentParts,
|
||||
...(message.toolCallId ? { tool_call_id: message.toolCallId } : {}),
|
||||
...(toolCalls ? { tool_calls: toolCalls } : {})
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 environment. Returns console output and the final evaluated value.',
|
||||
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)';
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
@ -13,8 +13,14 @@ import {
|
|||
findDescendantMessages,
|
||||
findLeafNode
|
||||
} from '$lib/utils';
|
||||
import type { ApiChatCompletionToolCall } from '$lib/types';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
|
||||
import {
|
||||
getEnabledToolDefinitions,
|
||||
findToolByName,
|
||||
isToolEnabled
|
||||
} from '$lib/services/tools/registry';
|
||||
|
||||
/**
|
||||
* chatStore - Active AI interaction and streaming state management
|
||||
|
|
@ -462,6 +468,38 @@ class ChatStore {
|
|||
);
|
||||
}
|
||||
|
||||
private async createToolMessage(
|
||||
convId: string,
|
||||
content: string,
|
||||
toolCallId?: string,
|
||||
parentId?: string | null,
|
||||
durationMs?: number
|
||||
): Promise<DatabaseMessage | null> {
|
||||
// Store tool I/O as JSON for richer UI rendering
|
||||
let storedContent = content;
|
||||
if (durationMs !== undefined) {
|
||||
storedContent = JSON.stringify({
|
||||
result: content,
|
||||
...(durationMs !== undefined ? { duration_ms: durationMs } : {})
|
||||
});
|
||||
}
|
||||
|
||||
return await DatabaseService.createMessageBranch(
|
||||
{
|
||||
convId,
|
||||
type: 'tool',
|
||||
role: 'tool',
|
||||
content: storedContent,
|
||||
timestamp: Date.now(),
|
||||
thinking: '',
|
||||
toolCalls: '',
|
||||
toolCallId,
|
||||
children: []
|
||||
},
|
||||
parentId || null
|
||||
);
|
||||
}
|
||||
|
||||
private async streamChatCompletion(
|
||||
allMessages: DatabaseMessage[],
|
||||
assistantMessage: DatabaseMessage,
|
||||
|
|
@ -552,10 +590,12 @@ class ChatStore {
|
|||
) => {
|
||||
this.stopStreaming();
|
||||
|
||||
const finalToolCalls = toolCallContent || streamedToolCallContent || '';
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
content: finalContent || streamedContent,
|
||||
thinking: reasoningContent || streamedReasoningContent,
|
||||
toolCalls: toolCallContent || streamedToolCallContent,
|
||||
toolCalls: finalToolCalls,
|
||||
timings
|
||||
};
|
||||
if (resolvedModel && !modelPersisted) {
|
||||
|
|
@ -566,8 +606,15 @@ class ChatStore {
|
|||
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
|
||||
const uiUpdate: Partial<DatabaseMessage> = {
|
||||
content: updateData.content as string,
|
||||
toolCalls: updateData.toolCalls as string
|
||||
toolCalls: finalToolCalls
|
||||
};
|
||||
if (
|
||||
updateData.thinking !== undefined &&
|
||||
updateData.thinking !== null &&
|
||||
String(updateData.thinking).length > 0
|
||||
) {
|
||||
uiUpdate.thinking = updateData.thinking as string;
|
||||
}
|
||||
if (timings) uiUpdate.timings = timings;
|
||||
if (resolvedModel) uiUpdate.model = resolvedModel;
|
||||
|
||||
|
|
@ -582,6 +629,15 @@ class ChatStore {
|
|||
if (isRouterMode()) {
|
||||
modelsStore.fetchRouterModels().catch(console.error);
|
||||
}
|
||||
|
||||
// If the model emitted tool calls, execute any enabled tools and continue the exchange.
|
||||
if (finalToolCalls) {
|
||||
await this.processToolCallsAndContinue(
|
||||
finalToolCalls,
|
||||
assistantMessage,
|
||||
modelOverride || null
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
this.stopStreaming();
|
||||
|
|
@ -1348,6 +1404,85 @@ class ChatStore {
|
|||
return Array.from(this.chatStreamingStates.keys());
|
||||
}
|
||||
|
||||
private async processToolCallsAndContinue(
|
||||
toolCallContent: string,
|
||||
sourceAssistant: DatabaseMessage,
|
||||
modelOverride?: string | null
|
||||
): Promise<void> {
|
||||
const currentConfig = config();
|
||||
|
||||
let toolCalls: ApiChatCompletionToolCall[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(toolCallContent);
|
||||
if (Array.isArray(parsed)) {
|
||||
toolCalls = parsed as ApiChatCompletionToolCall[];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse tool calls', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const relevantCalls = toolCalls.filter((call) => {
|
||||
const fnName = call.function?.name;
|
||||
return Boolean(fnName && call.id && isToolEnabled(fnName, currentConfig));
|
||||
});
|
||||
if (relevantCalls.length === 0) return;
|
||||
|
||||
const activeConv = conversationsStore.activeConversation;
|
||||
if (!activeConv) return;
|
||||
|
||||
const toolMessages: DatabaseMessage[] = [];
|
||||
|
||||
const nowMs = () =>
|
||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
||||
for (const call of relevantCalls) {
|
||||
const args = call.function?.arguments ?? '';
|
||||
const fnName = call.function?.name;
|
||||
if (!fnName) continue;
|
||||
const registration = findToolByName(fnName);
|
||||
if (!registration?.execute) continue;
|
||||
|
||||
const startedAt = nowMs();
|
||||
const { content } = await registration.execute(args, currentConfig);
|
||||
const durationMs = Math.max(0, Math.round(nowMs() - startedAt));
|
||||
const toolMsg = await this.createToolMessage(
|
||||
activeConv.id,
|
||||
content || '(no output)',
|
||||
call.id,
|
||||
sourceAssistant.id,
|
||||
durationMs
|
||||
);
|
||||
|
||||
if (toolMsg) {
|
||||
toolMessages.push(toolMsg);
|
||||
conversationsStore.addMessageToActive(toolMsg);
|
||||
}
|
||||
}
|
||||
|
||||
if (toolMessages.length === 0) return;
|
||||
|
||||
// Create a new assistant message to continue the conversation
|
||||
const newAssistant = await this.createAssistantMessage(
|
||||
toolMessages[toolMessages.length - 1]?.id || sourceAssistant.id
|
||||
);
|
||||
if (!newAssistant) return;
|
||||
|
||||
conversationsStore.addMessageToActive(newAssistant);
|
||||
this.setChatLoading(activeConv.id, true);
|
||||
this.clearChatStreaming(activeConv.id);
|
||||
|
||||
await this.streamChatCompletion(
|
||||
conversationsStore.activeMessages.slice(0, -1),
|
||||
newAssistant,
|
||||
undefined,
|
||||
undefined,
|
||||
modelOverride || null
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -1403,6 +1538,12 @@ class ChatStore {
|
|||
if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers;
|
||||
if (currentConfig.custom) apiOptions.custom = currentConfig.custom;
|
||||
|
||||
const tools = getEnabledToolDefinitions(currentConfig);
|
||||
if (tools.length) {
|
||||
apiOptions.tools = tools;
|
||||
apiOptions.tool_choice = 'auto';
|
||||
}
|
||||
|
||||
return apiOptions;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -245,8 +245,7 @@ class ConversationsStore {
|
|||
|
||||
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
|
||||
|
||||
this.activeMessages.length = 0;
|
||||
this.activeMessages.push(...currentPath);
|
||||
this.activeMessages = [...currentPath];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -560,7 +559,7 @@ class ConversationsStore {
|
|||
* @param message - The message to add
|
||||
*/
|
||||
addMessageToActive(message: DatabaseMessage): void {
|
||||
this.activeMessages.push(message);
|
||||
this.activeMessages = [...this.activeMessages, message];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -572,7 +571,12 @@ class ConversationsStore {
|
|||
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
|
||||
if (index !== -1 && this.activeMessages[index]) {
|
||||
// Create new object to trigger Svelte 5 reactivity
|
||||
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
|
||||
const updated = { ...this.activeMessages[index], ...updates };
|
||||
this.activeMessages = [
|
||||
...this.activeMessages.slice(0, index),
|
||||
updated,
|
||||
...this.activeMessages.slice(index + 1)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -600,7 +604,12 @@ class ConversationsStore {
|
|||
*/
|
||||
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
|
||||
if (index !== -1) {
|
||||
return this.activeMessages.splice(index, 1)[0];
|
||||
const removed = this.activeMessages[index];
|
||||
this.activeMessages = [
|
||||
...this.activeMessages.slice(0, index),
|
||||
...this.activeMessages.slice(index + 1)
|
||||
];
|
||||
return removed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export interface ApiChatMessageData {
|
|||
role: ChatRole;
|
||||
content: string | ApiChatMessageContentPart[];
|
||||
timestamp?: number;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ApiChatCompletionToolCallDelta[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -178,13 +180,19 @@ export interface ApiLlamaCppServerProps {
|
|||
build_info: string;
|
||||
}
|
||||
|
||||
export interface ApiChatCompletionRequestMessage {
|
||||
role: ChatRole;
|
||||
content: string | ApiChatMessageContentPart[];
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ApiChatCompletionToolCallDelta[];
|
||||
}
|
||||
|
||||
export interface ApiChatCompletionRequest {
|
||||
messages: Array<{
|
||||
role: ChatRole;
|
||||
content: string | ApiChatMessageContentPart[];
|
||||
}>;
|
||||
messages: Array<ApiChatCompletionRequestMessage>;
|
||||
stream?: boolean;
|
||||
model?: string;
|
||||
tools?: ApiToolDefinition[];
|
||||
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
||||
// Reasoning parameters
|
||||
reasoning_format?: string;
|
||||
// Generation parameters
|
||||
|
|
@ -231,6 +239,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,5 @@
|
|||
export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
|
||||
export type ChatRole = 'user' | 'assistant' | 'system';
|
||||
export type ChatMessageType = 'root' | 'text' | 'think' | 'system' | 'tool';
|
||||
export type ChatRole = 'user' | 'assistant' | 'system' | 'tool';
|
||||
|
||||
export interface ChatUploadedFile {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export interface DatabaseMessage {
|
|||
timestamp: number;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
toolCallId?: string;
|
||||
parent: string;
|
||||
thinking: string;
|
||||
toolCalls?: string;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export type {
|
|||
ApiChatCompletionToolCall,
|
||||
ApiChatCompletionStreamChunk,
|
||||
ApiChatCompletionResponse,
|
||||
ApiChatCompletionRequestMessage,
|
||||
ApiToolDefinition,
|
||||
ApiSlotData,
|
||||
ApiProcessingState,
|
||||
ApiRouterModelMeta,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface SettingsFieldConfig {
|
|||
key: string;
|
||||
label: string;
|
||||
type: 'input' | 'textarea' | 'checkbox' | 'select';
|
||||
disabled?: boolean;
|
||||
isExperimental?: boolean;
|
||||
help?: string;
|
||||
options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>;
|
||||
|
|
@ -45,6 +46,9 @@ export interface SettingsChatServiceOptions {
|
|||
samplers?: string | string[];
|
||||
// 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,114 @@
|
|||
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,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,68 @@
|
|||
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 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 Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// With a stale snapshot, final-answer will be missing; this expectation enforces correct behavior.
|
||||
expect(container.textContent || '').toContain('final-answer');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
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
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 Promise.resolve();
|
||||
await Promise.resolve();
|
||||
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 Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
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 Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
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,106 @@
|
|||
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';
|
||||
|
||||
// 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', '', 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 }
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,17 @@
|
|||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||
import ChatMessage from '$lib/components/app/chat/ChatMessages/ChatMessage.svelte';
|
||||
|
||||
const props = $props<{
|
||||
message: DatabaseMessage;
|
||||
onDelete: (message: DatabaseMessage) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Sidebar.Provider open={false}>
|
||||
<ChatMessage message={props.message} onDelete={props.onDelete} />
|
||||
</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.getByPlaceholder('Ask anything...');
|
||||
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>(
|
||||
'textarea[placeholder="Ask anything..."]'
|
||||
);
|
||||
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.getByPlaceholder('Ask anything...');
|
||||
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,140 @@
|
|||
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.getByPlaceholder('Ask anything...');
|
||||
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');
|
||||
});
|
||||
|
|
@ -89,6 +89,9 @@ export default defineConfig({
|
|||
'katex-fonts': resolve('node_modules/katex/dist/fonts')
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['acorn']
|
||||
},
|
||||
build: {
|
||||
assetsInlineLimit: MAX_ASSET_SIZE,
|
||||
chunkSizeWarningLimit: 3072,
|
||||
|
|
@ -116,9 +119,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