Skip to content

Commit f2175cb

Browse files
committed
fix: Use structured tool_calls for tool handling
1 parent f6b1386 commit f2175cb

File tree

4 files changed

+239
-198
lines changed

4 files changed

+239
-198
lines changed

tools/server/webui/src/components/ChatMessage.tsx

Lines changed: 56 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ interface SplitMessage {
99
content: PendingMessage['content'];
1010
thought?: string;
1111
isThinking?: boolean;
12-
toolOutput?: string;
13-
toolTitle?: string;
1412
}
1513

1614
export default function ChatMessage({
@@ -51,71 +49,40 @@ export default function ChatMessage({
5149
const prevSibling = siblingLeafNodeIds[siblingCurrIdx - 1];
5250

5351
// for reasoning model, we split the message into content, thought, and tool output
54-
const { content, thought, isThinking, toolOutput, toolTitle }: SplitMessage =
55-
useMemo(() => {
56-
if (msg.content === null || msg.role !== 'assistant') {
57-
return { content: msg.content };
58-
}
59-
let currentContent = msg.content;
60-
let extractedThought: string | undefined = undefined;
61-
let isCurrentlyThinking = false;
62-
let extractedToolOutput: string | undefined = undefined;
63-
let extractedToolTitle: string | undefined = 'Tool Output';
64-
65-
// Process <think> tags
66-
const thinkParts = currentContent.split('<think>');
67-
currentContent = thinkParts[0];
68-
if (thinkParts.length > 1) {
69-
isCurrentlyThinking = true;
70-
const tempThoughtArray: string[] = [];
71-
for (let i = 1; i < thinkParts.length; i++) {
72-
const thinkSegment = thinkParts[i].split('</think>');
73-
tempThoughtArray.push(thinkSegment[0]);
74-
if (thinkSegment.length > 1) {
75-
isCurrentlyThinking = false; // Closing tag found
76-
currentContent += thinkSegment[1];
77-
}
78-
}
79-
extractedThought = tempThoughtArray.join('\n');
80-
}
52+
const { content, thought, isThinking }: SplitMessage = useMemo(() => {
53+
if (
54+
msg.content === null ||
55+
(msg.role !== 'assistant' && msg.role !== 'tool')
56+
) {
57+
return { content: msg.content };
58+
}
8159

82-
// Process <tool> tags (after thoughts are processed)
83-
const toolParts = currentContent.split('<tool>');
84-
currentContent = toolParts[0];
85-
if (toolParts.length > 1) {
86-
const tempToolOutputArray: string[] = [];
87-
for (let i = 1; i < toolParts.length; i++) {
88-
const toolSegment = toolParts[i].split('</tool>');
89-
const toolContent = toolSegment[0].trim();
60+
let actualContent = '';
61+
let thought = '';
62+
let isThinking = false;
63+
let thinkSplit = msg.content.split('<think>', 2);
9064

91-
const firstLineEnd = toolContent.indexOf('\n');
92-
if (firstLineEnd !== -1) {
93-
extractedToolTitle = toolContent.substring(0, firstLineEnd);
94-
tempToolOutputArray.push(
95-
toolContent.substring(firstLineEnd + 1).trim()
96-
);
97-
} else {
98-
// If no newline, extractedToolTitle keeps its default; toolContent is pushed as is.
99-
tempToolOutputArray.push(toolContent);
100-
}
65+
actualContent += thinkSplit[0];
10166

102-
if (toolSegment.length > 1) {
103-
currentContent += toolSegment[1];
104-
}
105-
}
106-
extractedToolOutput = tempToolOutputArray.join('\n\n');
67+
while (thinkSplit[1] !== undefined) {
68+
// <think> tag found
69+
thinkSplit = thinkSplit[1].split('</think>', 2);
70+
thought += thinkSplit[0];
71+
isThinking = true;
72+
if (thinkSplit[1] !== undefined) {
73+
// </think> closing tag found
74+
isThinking = false;
75+
thinkSplit = thinkSplit[1].split('<think>', 2);
76+
actualContent += thinkSplit[0];
10777
}
78+
}
10879

109-
return {
110-
content: currentContent.trim(),
111-
thought: extractedThought,
112-
isThinking: isCurrentlyThinking,
113-
toolOutput: extractedToolOutput,
114-
toolTitle: extractedToolTitle,
115-
};
116-
}, [msg]);
80+
return { content: actualContent, thought, isThinking };
81+
}, [msg]);
11782
if (!viewingChat) return null;
11883

84+
const toolCalls = msg.tool_calls ?? null;
85+
11986
return (
12087
<div className="group" id={id}>
12188
<div
@@ -165,8 +132,12 @@ export default function ChatMessage({
165132
<>
166133
{content === null ? (
167134
<>
168-
{/* show loading dots for pending message */}
169-
<span className="loading loading-dots loading-md"></span>
135+
{toolCalls ? null : (
136+
<>
137+
{/* show loading dots for pending message */}
138+
<span className="loading loading-dots loading-md"></span>
139+
</>
140+
)}
170141
</>
171142
) : (
172143
<>
@@ -232,27 +203,32 @@ export default function ChatMessage({
232203
content={content}
233204
isGenerating={isPending}
234205
/>
235-
236-
{toolOutput && (
237-
<details
238-
className="collapse bg-base-200 collapse-arrow mb-4"
239-
open={true} // todo: make this configurable like showThoughtInProgress
240-
>
241-
<summary className="collapse-title">
242-
<b>{toolTitle || 'Tool Output'}</b>
243-
</summary>
244-
<div className="collapse-content">
245-
<MarkdownDisplay
246-
content={toolOutput}
247-
// Tool output is not "generating" in the same way
248-
isGenerating={false}
249-
/>
250-
</div>
251-
</details>
252-
)}
253206
</div>
254207
</>
255208
)}
209+
{toolCalls &&
210+
toolCalls.map((toolCall, i) => (
211+
<details
212+
key={i}
213+
className="collapse bg-base-200 collapse-arrow mb-4"
214+
open={false} // todo: make this configurable like showThoughtInProgress
215+
>
216+
<summary className="collapse-title">
217+
<b>Tool call:</b> {toolCall.function.name}
218+
</summary>
219+
220+
<div className="collapse-content">
221+
<div className="font-bold mb-1">Arguments:</div>
222+
<pre className="whitespace-pre-wrap bg-base-300 p-2 rounded">
223+
{JSON.stringify(
224+
JSON.parse(toolCall.function.arguments),
225+
null,
226+
2
227+
)}
228+
</pre>
229+
</div>
230+
</details>
231+
))}
256232
{/* render timings if enabled */}
257233
{timings && config.showTokensPerSecond && (
258234
<div className="dropdown dropdown-hover dropdown-top mt-2">

0 commit comments

Comments
 (0)