Skip to content

Commit a5cff84

Browse files
webui: add OAI-Compat Harmony tool-call streaming visualization and persistence in chat UI
- Purely visual and diagnostic change, no effect on model context, prompt construction, or inference behavior - Introduces full support for delta.tool_calls streaming from Harmony-compatible models - Adds parsing, incremental merging, and live updates of tool-call deltas in ChatService - Extends chat store, database schema, and message model to persist toolCalls content alongside reasoning traces - Implements new Svelte components ChatMessageToolCallBlock and ChatMessageToolCallItem for structured display of tool-call payloads - Adds a fallback raw string mode for malformed or non-JSON tool-call chunks - Adds 'Show tool call chunks' toggle in chat settings and new config flag showToolCalls - Updates ApiChatCompletion types to define ToolCall and ToolCallDelta interfaces for typed Harmony compatibility - Integrates tool-call rendering in ChatMessageAssistant when showToolCalls is enabled
1 parent 9b9201f commit a5cff84

File tree

13 files changed

+468
-55
lines changed

13 files changed

+468
-55
lines changed

tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import type { ApiChatCompletionToolCall } from '$lib/types/api';
23
import { getDeletionInfo } from '$lib/stores/chat.svelte';
34
import { copyToClipboard } from '$lib/utils/copy';
45
import { isIMEComposing } from '$lib/utils/is-ime-composing';
@@ -54,6 +55,29 @@
5455
return null;
5556
});
5657
58+
let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
59+
if (message.role === 'assistant') {
60+
const trimmedToolCalls = message.toolCalls?.trim();
61+
62+
if (!trimmedToolCalls) {
63+
return null;
64+
}
65+
66+
try {
67+
const parsed = JSON.parse(trimmedToolCalls);
68+
69+
if (Array.isArray(parsed)) {
70+
return parsed as ApiChatCompletionToolCall[];
71+
}
72+
} catch {
73+
// Harmony-only path: fall back to the raw string so issues surface visibly.
74+
}
75+
76+
return trimmedToolCalls;
77+
}
78+
return null;
79+
});
80+
5781
function handleCancelEdit() {
5882
isEditing = false;
5983
editedContent = message.content;
@@ -171,5 +195,6 @@
171195
{showDeleteDialog}
172196
{siblingInfo}
173197
{thinkingContent}
198+
{toolCallContent}
174199
/>
175200
{/if}

tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
<script lang="ts">
2-
import { ChatMessageThinkingBlock, MarkdownContent } from '$lib/components/app';
2+
import {
3+
ChatMessageThinkingBlock,
4+
ChatMessageToolCallBlock,
5+
MarkdownContent
6+
} from '$lib/components/app';
37
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
48
import { isLoading } from '$lib/stores/chat.svelte';
59
import { fade } from 'svelte/transition';
@@ -12,6 +16,7 @@
1216
import { config } from '$lib/stores/settings.svelte';
1317
import { modelName as serverModelName } from '$lib/stores/server.svelte';
1418
import { copyToClipboard } from '$lib/utils/copy';
19+
import type { ApiChatCompletionToolCall } from '$lib/types/api';
1520
1621
interface Props {
1722
class?: string;
@@ -42,6 +47,7 @@
4247
siblingInfo?: ChatMessageSiblingInfo | null;
4348
textareaElement?: HTMLTextAreaElement;
4449
thinkingContent: string | null;
50+
toolCallContent: ApiChatCompletionToolCall[] | string | null;
4551
}
4652
4753
let {
@@ -67,7 +73,8 @@
6773
shouldBranchAfterEdit = false,
6874
siblingInfo = null,
6975
textareaElement = $bindable(),
70-
thinkingContent
76+
thinkingContent,
77+
toolCallContent
7178
}: Props = $props();
7279
7380
const processingState = useProcessingState();
@@ -103,6 +110,10 @@
103110
/>
104111
{/if}
105112

113+
{#if toolCallContent && config().showToolCalls}
114+
<ChatMessageToolCallBlock {toolCallContent} />
115+
{/if}
116+
106117
{#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
107118
<div class="mt-6 w-full max-w-[48rem]" in:fade>
108119
<div class="processing-container">
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<script lang="ts">
2+
import { Wrench } from '@lucide/svelte';
3+
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
4+
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
5+
import { buttonVariants } from '$lib/components/ui/button/index.js';
6+
import { Card } from '$lib/components/ui/card';
7+
import ChatMessageToolCallItem from './ChatMessageToolCallItem.svelte';
8+
import type { ApiChatCompletionToolCall } from '$lib/types/api';
9+
10+
interface Props {
11+
class?: string;
12+
toolCallContent: ApiChatCompletionToolCall[] | string | null;
13+
}
14+
15+
let { class: className = '', toolCallContent }: Props = $props();
16+
let fallbackExpanded = $state(false);
17+
18+
const toolCalls = $derived.by(() => (Array.isArray(toolCallContent) ? toolCallContent : null));
19+
const fallbackContent = $derived.by(() =>
20+
typeof toolCallContent === 'string' ? toolCallContent : null
21+
);
22+
</script>
23+
24+
{#if toolCalls && toolCalls.length > 0}
25+
<div class="mb-6 flex flex-col gap-3 {className}">
26+
{#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
27+
<ChatMessageToolCallItem {toolCall} {index} />
28+
{/each}
29+
</div>
30+
{:else if fallbackContent}
31+
<Collapsible.Root bind:open={fallbackExpanded} class="mb-6 {className}">
32+
<Card class="gap-0 border-muted bg-muted/30 py-0">
33+
<Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
34+
<div class="flex items-center gap-2 text-muted-foreground">
35+
<Wrench class="h-4 w-4" />
36+
37+
<span class="text-sm font-medium">Tool calls</span>
38+
</div>
39+
40+
<div
41+
class={buttonVariants({
42+
variant: 'ghost',
43+
size: 'sm',
44+
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
45+
})}
46+
>
47+
<ChevronsUpDownIcon class="h-4 w-4" />
48+
49+
<span class="sr-only">Toggle tool call content</span>
50+
</div>
51+
</Collapsible.Trigger>
52+
53+
<Collapsible.Content>
54+
<div class="border-t border-muted px-3 pb-3">
55+
<div class="pt-3">
56+
<pre class="tool-call-content">{fallbackContent}</pre>
57+
</div>
58+
</div>
59+
</Collapsible.Content>
60+
</Card>
61+
</Collapsible.Root>
62+
{/if}
63+
64+
<style>
65+
.tool-call-content {
66+
font-family: var(--font-mono);
67+
font-size: 0.75rem;
68+
line-height: 1.25rem;
69+
white-space: pre-wrap;
70+
word-break: break-word;
71+
margin: 0;
72+
}
73+
</style>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<script lang="ts">
2+
import { Wrench } from '@lucide/svelte';
3+
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
4+
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
5+
import { buttonVariants } from '$lib/components/ui/button/index.js';
6+
import { Card } from '$lib/components/ui/card';
7+
import type { ApiChatCompletionToolCall } from '$lib/types/api';
8+
9+
interface Props {
10+
class?: string;
11+
index: number;
12+
toolCall: ApiChatCompletionToolCall;
13+
}
14+
15+
let { class: className = '', index, toolCall }: Props = $props();
16+
17+
let isExpanded = $state(false);
18+
19+
const headerLabel = $derived.by(() => {
20+
const callNumber = index + 1;
21+
const functionName = toolCall.function?.name?.trim();
22+
23+
return functionName ? `Tool call #${callNumber} · ${functionName}` : `Tool call #${callNumber}`;
24+
});
25+
26+
const formattedPayload = $derived.by(() => {
27+
const payload: Record<string, unknown> = {};
28+
29+
if (toolCall.id) {
30+
payload.id = toolCall.id;
31+
}
32+
33+
if (toolCall.type) {
34+
payload.type = toolCall.type;
35+
}
36+
37+
if (toolCall.function) {
38+
const fnPayload: Record<string, unknown> = {};
39+
const { name, arguments: args } = toolCall.function;
40+
41+
if (name) {
42+
fnPayload.name = name;
43+
}
44+
45+
const trimmedArguments = args?.trim();
46+
if (trimmedArguments) {
47+
try {
48+
fnPayload.arguments = JSON.parse(trimmedArguments);
49+
} catch {
50+
fnPayload.arguments = trimmedArguments;
51+
}
52+
}
53+
54+
if (Object.keys(fnPayload).length > 0) {
55+
payload.function = fnPayload;
56+
}
57+
}
58+
59+
return JSON.stringify(payload, null, 2);
60+
});
61+
</script>
62+
63+
<Collapsible.Root bind:open={isExpanded} class="mb-3 last:mb-0 {className}">
64+
<Card class="gap-0 border-muted bg-muted/30 py-0">
65+
<Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
66+
<div class="flex items-center gap-2 text-muted-foreground">
67+
<Wrench class="h-4 w-4" />
68+
69+
<span class="text-sm font-medium">{headerLabel}</span>
70+
</div>
71+
72+
<div
73+
class={buttonVariants({
74+
variant: 'ghost',
75+
size: 'sm',
76+
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
77+
})}
78+
>
79+
<ChevronsUpDownIcon class="h-4 w-4" />
80+
81+
<span class="sr-only">Toggle tool call payload</span>
82+
</div>
83+
</Collapsible.Trigger>
84+
85+
<Collapsible.Content>
86+
<div class="border-t border-muted px-3 pb-3">
87+
<div class="pt-3">
88+
<pre class="tool-call-content">{formattedPayload}</pre>
89+
</div>
90+
</div>
91+
</Collapsible.Content>
92+
</Card>
93+
</Collapsible.Root>
94+
95+
<style>
96+
.tool-call-content {
97+
font-family: var(--font-mono);
98+
font-size: 0.75rem;
99+
line-height: 1.25rem;
100+
white-space: pre-wrap;
101+
word-break: break-word;
102+
margin: 0;
103+
}
104+
</style>

tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@
226226
label: 'Show raw LLM output',
227227
type: 'checkbox'
228228
},
229+
{
230+
key: 'showToolCalls',
231+
label: 'Show tool call chunks',
232+
type: 'checkbox'
233+
},
229234
{
230235
key: 'custom',
231236
label: 'Custom JSON',

tools/server/webui/src/lib/components/app/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormF
1515
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
1616
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
1717
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
18+
export { default as ChatMessageToolCallBlock } from './chat/ChatMessages/ChatMessageToolCallBlock.svelte';
1819
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
1920

2021
export { default as ChatProcessingInfo } from './chat/ChatProcessingInfo.svelte';

tools/server/webui/src/lib/constants/settings-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
66
theme: 'system',
77
showTokensPerSecond: false,
88
showThoughtInProgress: false,
9+
showToolCalls: false,
910
disableReasoningFormat: false,
1011
keepStatsVisible: false,
1112
askForTitleConfirmation: false,
@@ -79,6 +80,8 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
7980
custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
8081
showTokensPerSecond: 'Display generation speed in tokens per second during streaming.',
8182
showThoughtInProgress: 'Expand thought process by default when generating messages.',
83+
showToolCalls:
84+
'Display streamed tool call payloads from Harmony-compatible delta.tool_calls data inside assistant messages.',
8285
disableReasoningFormat:
8386
'Show raw LLM output without backend parsing and frontend Markdown rendering to inspect streaming across different models.',
8487
keepStatsVisible: 'Keep processing statistics visible after generation finishes.',

0 commit comments

Comments
 (0)