Skip to content

Commit b84ab36

Browse files
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
1 parent 9b17d74 commit b84ab36

File tree

10 files changed

+379
-12
lines changed

10 files changed

+379
-12
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
@@ -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: 121 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,17 @@
7679
shouldBranchAfterEdit = false,
7780
siblingInfo = null,
7881
textareaElement = $bindable(),
79-
thinkingContent
82+
thinkingContent,
83+
toolCallContent = null
8084
}: Props = $props();
8185
86+
const parsedToolCalls = $derived(() =>
87+
Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
88+
);
89+
const fallbackToolCallContent = $derived(() =>
90+
typeof toolCallContent === 'string' ? toolCallContent : null
91+
);
92+
8293
const processingState = useProcessingState();
8394
let currentConfig = $derived(config());
8495
let serverModel = $derived(serverModelName());
@@ -97,6 +108,58 @@
97108
98109
void copyToClipboard(model ?? '');
99110
}
111+
112+
function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
113+
const callNumber = index + 1;
114+
const functionName = toolCall.function?.name?.trim();
115+
const label = functionName || `Call #${callNumber}`;
116+
117+
const payload: Record<string, unknown> = {};
118+
119+
const id = toolCall.id?.trim();
120+
if (id) {
121+
payload.id = id;
122+
}
123+
124+
const type = toolCall.type?.trim();
125+
if (type) {
126+
payload.type = type;
127+
}
128+
129+
if (toolCall.function) {
130+
const fnPayload: Record<string, unknown> = {};
131+
132+
const name = toolCall.function.name?.trim();
133+
if (name) {
134+
fnPayload.name = name;
135+
}
136+
137+
const rawArguments = toolCall.function.arguments?.trim();
138+
if (rawArguments) {
139+
try {
140+
fnPayload.arguments = JSON.parse(rawArguments);
141+
} catch {
142+
fnPayload.arguments = rawArguments;
143+
}
144+
}
145+
146+
if (Object.keys(fnPayload).length > 0) {
147+
payload.function = fnPayload;
148+
}
149+
}
150+
151+
const formattedPayload = JSON.stringify(payload, null, 2);
152+
153+
return {
154+
label,
155+
tooltip: formattedPayload,
156+
copyValue: formattedPayload
157+
};
158+
}
159+
160+
function handleCopyToolCall(payload: string) {
161+
void copyToClipboard(payload, 'Tool call copied to clipboard');
162+
}
100163
</script>
101164

102165
<div
@@ -189,6 +252,49 @@
189252
</span>
190253
{/if}
191254

255+
{#if config().showToolCalls}
256+
{@const toolCalls = parsedToolCalls()}
257+
{@const fallbackToolCalls = fallbackToolCallContent()}
258+
{#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
259+
<span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
260+
<span class="inline-flex items-center gap-1">
261+
<Wrench class="h-3.5 w-3.5" />
262+
263+
<span>Tool calls:</span>
264+
</span>
265+
266+
{#if toolCalls && toolCalls.length > 0}
267+
{#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
268+
{@const badge = formatToolCallBadge(toolCall, index)}
269+
<button
270+
type="button"
271+
class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
272+
title={badge.tooltip}
273+
aria-label={`Copy tool call ${badge.label}`}
274+
onclick={() => handleCopyToolCall(badge.copyValue)}
275+
>
276+
{badge.label}
277+
278+
<Copy class="ml-1 h-3 w-3" />
279+
</button>
280+
{/each}
281+
{:else if fallbackToolCalls}
282+
<button
283+
type="button"
284+
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"
285+
title={fallbackToolCalls}
286+
aria-label="Copy tool call payload"
287+
onclick={() => handleCopyToolCall(fallbackToolCalls)}
288+
>
289+
{fallbackToolCalls}
290+
291+
<Copy class="ml-1 h-3 w-3" />
292+
</button>
293+
{/if}
294+
</span>
295+
{/if}
296+
{/if}
297+
192298
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
193299
{@const tokensPerSecond = (message.timings.predicted_n / message.timings.predicted_ms) * 1000}
194300
<span class="inline-flex items-center gap-2 text-xs text-muted-foreground">
@@ -287,4 +393,17 @@
287393
white-space: pre-wrap;
288394
word-break: break-word;
289395
}
396+
397+
.tool-call-badge {
398+
max-width: 12rem;
399+
white-space: nowrap;
400+
overflow: hidden;
401+
text-overflow: ellipsis;
402+
}
403+
404+
.tool-call-badge--fallback {
405+
max-width: 20rem;
406+
white-space: normal;
407+
word-break: break-word;
408+
}
290409
</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)