Skip to content

Commit 1411d92

Browse files
webui: add OAI-Compat Harmony tool-call streaming visualization and persistence in chat UI (#16618)
* webui: add OAI-Compat Harmony tool-call live streaming visualization and persistence in chat UI - Purely visual and diagnostic change, no effect on model context, prompt construction, or inference behavior - Captured assistant tool call payloads during streaming and non-streaming completions, and persisted them in chat state and storage for downstream use - Exposed parsed tool call labels beneath the assistant's model info line with graceful fallback when parsing fails - Added tool call badges beneath assistant responses that expose JSON tooltips and copy their payloads when clicked, matching the existing model badge styling - Added a user-facing setting to toggle tool call visibility to the Developer settings section directly under the model selector option * webui: remove scroll listener causing unnecessary layout updates (model selector) * Update tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte Co-authored-by: Aleksander Grygier <[email protected]> * Update tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte Co-authored-by: Aleksander Grygier <[email protected]> * chore: npm run format & update webui build output * chore: update webui build output --------- Co-authored-by: Aleksander Grygier <[email protected]>
1 parent 662192e commit 1411d92

File tree

12 files changed

+376
-19
lines changed

12 files changed

+376
-19
lines changed

tools/server/public/index.html.gz

-2.02 KB
Binary file not shown.

tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormModelSelector.svelte

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,6 @@
7272
}
7373
}
7474
75-
function handleScroll() {
76-
if (isOpen) {
77-
updateMenuPosition();
78-
}
79-
}
80-
8175
async function handleSelect(value: string | undefined) {
8276
if (!value) return;
8377
@@ -259,7 +253,7 @@
259253
}
260254
</script>
261255

262-
<svelte:window onresize={handleResize} onscroll={handleScroll} />
256+
<svelte:window onresize={handleResize} />
263257

264258
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
265259

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
@@ -2,6 +2,7 @@
22
import { getDeletionInfo } from '$lib/stores/chat.svelte';
33
import { copyToClipboard } from '$lib/utils/copy';
44
import { isIMEComposing } from '$lib/utils/is-ime-composing';
5+
import type { ApiChatCompletionToolCall } from '$lib/types/api';
56
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
67
import ChatMessageUser from './ChatMessageUser.svelte';
78
@@ -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: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
Gauge,
1212
Clock,
1313
WholeWord,
14-
ChartNoAxesColumn
14+
ChartNoAxesColumn,
15+
Wrench
1516
} from '@lucide/svelte';
1617
import { Button } from '$lib/components/ui/button';
1718
import { Checkbox } from '$lib/components/ui/checkbox';
@@ -21,6 +22,7 @@
2122
import { config } from '$lib/stores/settings.svelte';
2223
import { modelName as serverModelName } from '$lib/stores/server.svelte';
2324
import { copyToClipboard } from '$lib/utils/copy';
25+
import type { ApiChatCompletionToolCall } from '$lib/types/api';
2426
2527
interface Props {
2628
class?: string;
@@ -51,6 +53,7 @@
5153
siblingInfo?: ChatMessageSiblingInfo | null;
5254
textareaElement?: HTMLTextAreaElement;
5355
thinkingContent: string | null;
56+
toolCallContent: ApiChatCompletionToolCall[] | string | null;
5457
}
5558
5659
let {
@@ -76,9 +79,15 @@
7679
shouldBranchAfterEdit = false,
7780
siblingInfo = null,
7881
textareaElement = $bindable(),
79-
thinkingContent
82+
thinkingContent,
83+
toolCallContent = null
8084
}: Props = $props();
8185
86+
const toolCalls = $derived(
87+
Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
88+
);
89+
const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
90+
8291
const processingState = useProcessingState();
8392
let currentConfig = $derived(config());
8493
let serverModel = $derived(serverModelName());
@@ -97,6 +106,58 @@
97106
98107
void copyToClipboard(model ?? '');
99108
}
109+
110+
function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
111+
const callNumber = index + 1;
112+
const functionName = toolCall.function?.name?.trim();
113+
const label = functionName || `Call #${callNumber}`;
114+
115+
const payload: Record<string, unknown> = {};
116+
117+
const id = toolCall.id?.trim();
118+
if (id) {
119+
payload.id = id;
120+
}
121+
122+
const type = toolCall.type?.trim();
123+
if (type) {
124+
payload.type = type;
125+
}
126+
127+
if (toolCall.function) {
128+
const fnPayload: Record<string, unknown> = {};
129+
130+
const name = toolCall.function.name?.trim();
131+
if (name) {
132+
fnPayload.name = name;
133+
}
134+
135+
const rawArguments = toolCall.function.arguments?.trim();
136+
if (rawArguments) {
137+
try {
138+
fnPayload.arguments = JSON.parse(rawArguments);
139+
} catch {
140+
fnPayload.arguments = rawArguments;
141+
}
142+
}
143+
144+
if (Object.keys(fnPayload).length > 0) {
145+
payload.function = fnPayload;
146+
}
147+
}
148+
149+
const formattedPayload = JSON.stringify(payload, null, 2);
150+
151+
return {
152+
label,
153+
tooltip: formattedPayload,
154+
copyValue: formattedPayload
155+
};
156+
}
157+
158+
function handleCopyToolCall(payload: string) {
159+
void copyToClipboard(payload, 'Tool call copied to clipboard');
160+
}
100161
</script>
101162

102163
<div
@@ -189,6 +250,47 @@
189250
</span>
190251
{/if}
191252

253+
{#if config().showToolCalls}
254+
{#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
255+
<span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
256+
<span class="inline-flex items-center gap-1">
257+
<Wrench class="h-3.5 w-3.5" />
258+
259+
<span>Tool calls:</span>
260+
</span>
261+
262+
{#if toolCalls && toolCalls.length > 0}
263+
{#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
264+
{@const badge = formatToolCallBadge(toolCall, index)}
265+
<button
266+
type="button"
267+
class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
268+
title={badge.tooltip}
269+
aria-label={`Copy tool call ${badge.label}`}
270+
onclick={() => handleCopyToolCall(badge.copyValue)}
271+
>
272+
{badge.label}
273+
274+
<Copy class="ml-1 h-3 w-3" />
275+
</button>
276+
{/each}
277+
{:else if fallbackToolCalls}
278+
<button
279+
type="button"
280+
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"
281+
title={fallbackToolCalls}
282+
aria-label="Copy tool call payload"
283+
onclick={() => handleCopyToolCall(fallbackToolCalls)}
284+
>
285+
{fallbackToolCalls}
286+
287+
<Copy class="ml-1 h-3 w-3" />
288+
</button>
289+
{/if}
290+
</span>
291+
{/if}
292+
{/if}
293+
192294
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
193295
{@const tokensPerSecond = (message.timings.predicted_n / message.timings.predicted_ms) * 1000}
194296
<span class="inline-flex items-center gap-2 text-xs text-muted-foreground">
@@ -287,4 +389,17 @@
287389
white-space: pre-wrap;
288390
word-break: break-word;
289391
}
392+
393+
.tool-call-badge {
394+
max-width: 12rem;
395+
white-space: nowrap;
396+
overflow: hidden;
397+
text-overflow: ellipsis;
398+
}
399+
400+
.tool-call-badge--fallback {
401+
max-width: 20rem;
402+
white-space: normal;
403+
word-break: break-word;
404+
}
290405
</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: 'Enable model selector',
227227
type: 'checkbox'
228228
},
229+
{
230+
key: 'showToolCalls',
231+
label: 'Show tool call labels',
232+
type: 'checkbox'
233+
},
229234
{
230235
key: 'disableReasoningFormat',
231236
label: 'Show raw LLM output',

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
showMessageStats: true,
@@ -80,6 +81,8 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
8081
custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
8182
showTokensPerSecond: 'Display generation speed in tokens per second during streaming.',
8283
showThoughtInProgress: 'Expand thought process by default when generating messages.',
84+
showToolCalls:
85+
'Display tool call labels and payloads from Harmony-compatible delta.tool_calls data below assistant messages.',
8386
disableReasoningFormat:
8487
'Show raw LLM output without backend parsing and frontend Markdown rendering to inspect streaming across different models.',
8588
keepStatsVisible: 'Keep processing statistics visible after generation finishes.',

0 commit comments

Comments
 (0)