Skip to content

Commit 00d911d

Browse files
committed
merge assistant messages on tool use
1 parent ae32a9a commit 00d911d

File tree

6 files changed

+226
-94
lines changed

6 files changed

+226
-94
lines changed

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

Lines changed: 87 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { useMemo, useState } from 'react';
1+
import React, { useMemo, useState } from 'react';
22
import { useAppContext } from '../utils/app.context';
33
import { Message, PendingMessage } from '../utils/types';
44
import { classNames } from '../utils/misc';
55
import MarkdownDisplay, { CopyButton } from './MarkdownDisplay';
66
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
7+
import { ToolCallArgsDisplay } from './tool_calling/ToolCallArgsDisplay';
8+
import { ToolCallResultDisplay } from './tool_calling/ToolCallResultDisplay';
79

810
interface SplitMessage {
911
content: PendingMessage['content'];
@@ -13,6 +15,7 @@ interface SplitMessage {
1315

1416
export default function ChatMessage({
1517
msg,
18+
chainedParts,
1619
siblingLeafNodeIds,
1720
siblingCurrIdx,
1821
id,
@@ -22,6 +25,7 @@ export default function ChatMessage({
2225
isPending,
2326
}: {
2427
msg: Message | PendingMessage;
28+
chainedParts?: (Message | PendingMessage)[];
2529
siblingLeafNodeIds: Message['id'][];
2630
siblingCurrIdx: number;
2731
id?: string;
@@ -48,8 +52,11 @@ export default function ChatMessage({
4852
const nextSibling = siblingLeafNodeIds[siblingCurrIdx + 1];
4953
const prevSibling = siblingLeafNodeIds[siblingCurrIdx - 1];
5054

51-
// for reasoning model, we split the message into content, thought, and tool output
52-
const { content, thought, isThinking }: SplitMessage = useMemo(() => {
55+
const {
56+
content: mainDisplayableContent,
57+
thought,
58+
isThinking,
59+
}: SplitMessage = useMemo(() => {
5360
if (
5461
msg.content === null ||
5562
(msg.role !== 'assistant' && msg.role !== 'tool')
@@ -65,12 +72,10 @@ export default function ChatMessage({
6572
actualContent += thinkSplit[0];
6673

6774
while (thinkSplit[1] !== undefined) {
68-
// <think> tag found
6975
thinkSplit = thinkSplit[1].split('</think>', 2);
7076
thought += thinkSplit[0];
7177
isThinking = true;
7278
if (thinkSplit[1] !== undefined) {
73-
// </think> closing tag found
7479
isThinking = false;
7580
thinkSplit = thinkSplit[1].split('<think>', 2);
7681
actualContent += thinkSplit[0];
@@ -79,10 +84,19 @@ export default function ChatMessage({
7984

8085
return { content: actualContent, thought, isThinking };
8186
}, [msg]);
87+
8288
if (!viewingChat) return null;
8389

8490
const toolCalls = msg.tool_calls ?? null;
8591

92+
const hasContentInMainMsg =
93+
mainDisplayableContent && mainDisplayableContent.trim() !== '';
94+
const hasContentInChainedParts = chainedParts?.some(
95+
(part) => part.content && part.content.trim() !== ''
96+
);
97+
const entireTurnHasSomeDisplayableContent =
98+
hasContentInMainMsg || hasContentInChainedParts;
99+
86100
return (
87101
<div className="group" id={id}>
88102
<div
@@ -98,7 +112,6 @@ export default function ChatMessage({
98112
'chat-bubble-base-300': msg.role !== 'user',
99113
})}
100114
>
101-
{/* textarea for editing message */}
102115
{editingContent !== null && (
103116
<>
104117
<textarea
@@ -127,21 +140,16 @@ export default function ChatMessage({
127140
</button>
128141
</>
129142
)}
130-
{/* not editing content, render message */}
131143
{editingContent === null && (
132144
<>
133-
{content === null ? (
145+
{mainDisplayableContent === null &&
146+
!toolCalls &&
147+
!chainedParts?.length ? (
134148
<>
135-
{toolCalls ? null : (
136-
<>
137-
{/* show loading dots for pending message */}
138-
<span className="loading loading-dots loading-md"></span>
139-
</>
140-
)}
149+
<span className="loading loading-dots loading-md"></span>
141150
</>
142151
) : (
143152
<>
144-
{/* render message as markdown */}
145153
<div dir="auto">
146154
{thought && (
147155
<details
@@ -152,7 +160,6 @@ export default function ChatMessage({
152160
{isPending && isThinking ? (
153161
<span>
154162
<span
155-
v-if="isGenerating"
156163
className="loading loading-spinner loading-md mr-2"
157164
style={{ verticalAlign: 'middle' }}
158165
></span>
@@ -182,71 +189,69 @@ export default function ChatMessage({
182189
Extra content
183190
</summary>
184191
<div className="collapse-content">
185-
{msg.extra.map(
186-
(extra, i) =>
187-
extra.type === 'textFile' ? (
188-
<div key={extra.name}>
189-
<b>{extra.name}</b>
190-
<pre>{extra.content}</pre>
191-
</div>
192-
) : extra.type === 'context' ? (
193-
<div key={i}>
194-
<pre>{extra.content}</pre>
195-
</div>
196-
) : null // TODO: support other extra types
192+
{msg.extra.map((extra, i) =>
193+
extra.type === 'textFile' ? (
194+
<div key={extra.name}>
195+
<b>{extra.name}</b>
196+
<pre>{extra.content}</pre>
197+
</div>
198+
) : extra.type === 'context' ? (
199+
<div key={i}>
200+
<pre>{extra.content}</pre>
201+
</div>
202+
) : null
197203
)}
198204
</div>
199205
</details>
200206
)}
201207

202-
{msg.role === 'tool' ? (
203-
<details
204-
className="collapse bg-base-200 collapse-arrow mb-4"
205-
open={true}
206-
>
207-
<summary className="collapse-title">
208-
<b>Tool call result</b>
209-
</summary>
210-
<div className="collapse-content">
211-
<MarkdownDisplay
212-
content={content}
213-
isGenerating={false} // Tool results are not "generating"
214-
/>
215-
</div>
216-
</details>
208+
{msg.role === 'tool' && mainDisplayableContent ? (
209+
<ToolCallResultDisplay content={mainDisplayableContent} />
217210
) : (
218-
<MarkdownDisplay
219-
content={content}
220-
isGenerating={isPending}
221-
/>
211+
mainDisplayableContent &&
212+
mainDisplayableContent.trim() !== '' && (
213+
<MarkdownDisplay
214+
content={mainDisplayableContent}
215+
isGenerating={isPending}
216+
/>
217+
)
222218
)}
223219
</div>
224220
</>
225221
)}
226222
{toolCalls &&
227-
toolCalls.map((toolCall, i) => (
228-
<details
229-
key={i}
230-
className="collapse bg-base-200 collapse-arrow mb-4"
231-
open={false} // todo: make this configurable like showThoughtInProgress
232-
>
233-
<summary className="collapse-title">
234-
<b>Tool call:</b> {toolCall.function.name}
235-
</summary>
223+
toolCalls.map((toolCall) => (
224+
<ToolCallArgsDisplay key={toolCall.id} toolCall={toolCall} />
225+
))}
236226

237-
<div className="collapse-content">
238-
<div className="font-bold mb-1">Arguments:</div>
239-
<pre className="whitespace-pre-wrap bg-base-300 p-2 rounded">
240-
{JSON.stringify(
241-
JSON.parse(toolCall.function.arguments),
242-
null,
243-
2
244-
)}
245-
</pre>
227+
{chainedParts?.map((part) => (
228+
<React.Fragment key={part.id}>
229+
{part.role === 'tool' && part.content && (
230+
<ToolCallResultDisplay
231+
content={part.content}
232+
baseClassName="collapse bg-base-200 collapse-arrow mb-4 mt-2"
233+
/>
234+
)}
235+
236+
{part.role === 'assistant' && part.content && (
237+
<div dir="auto" className="mt-2">
238+
<MarkdownDisplay
239+
content={part.content}
240+
isGenerating={!!isPending}
241+
/>
246242
</div>
247-
</details>
248-
))}
249-
{/* render timings if enabled */}
243+
)}
244+
245+
{part.tool_calls &&
246+
part.tool_calls.map((toolCall) => (
247+
<ToolCallArgsDisplay
248+
key={toolCall.id}
249+
toolCall={toolCall}
250+
baseClassName="collapse bg-base-200 collapse-arrow mb-4 mt-2"
251+
/>
252+
))}
253+
</React.Fragment>
254+
))}
250255
{timings && config.showTokensPerSecond && (
251256
<div className="dropdown dropdown-hover dropdown-top mt-2">
252257
<div
@@ -275,8 +280,7 @@ export default function ChatMessage({
275280
</div>
276281
</div>
277282

278-
{/* actions for each message */}
279-
{msg.content !== null && (
283+
{(entireTurnHasSomeDisplayableContent || msg.role === 'user') && (
280284
<div
281285
className={classNames({
282286
'flex items-center gap-2 mx-4 mt-2 mb-2': true,
@@ -308,7 +312,6 @@ export default function ChatMessage({
308312
</button>
309313
</div>
310314
)}
311-
{/* user message */}
312315
{msg.role === 'user' && (
313316
<button
314317
className="badge btn-mini show-on-hover"
@@ -318,28 +321,34 @@ export default function ChatMessage({
318321
✍️ Edit
319322
</button>
320323
)}
321-
{/* assistant message */}
322324
{msg.role === 'assistant' && (
323325
<>
324326
{!isPending && (
325327
<button
326328
className="badge btn-mini show-on-hover mr-2"
327329
onClick={() => {
328-
if (msg.content !== null) {
330+
if (entireTurnHasSomeDisplayableContent) {
329331
onRegenerateMessage(msg as Message);
330332
}
331333
}}
332-
disabled={msg.content === null}
334+
disabled={!entireTurnHasSomeDisplayableContent}
333335
>
334336
🔄 Regenerate
335337
</button>
336338
)}
337339
</>
338340
)}
339-
<CopyButton
340-
className="badge btn-mini show-on-hover mr-2"
341-
content={msg.content}
342-
/>
341+
{entireTurnHasSomeDisplayableContent && (
342+
<CopyButton
343+
className="badge btn-mini show-on-hover mr-2"
344+
content={
345+
msg.content ??
346+
chainedParts?.find((p) => p.role === 'assistant' && p.content)
347+
?.content ??
348+
''
349+
}
350+
/>
351+
)}
343352
</div>
344353
)}
345354
</div>

0 commit comments

Comments
 (0)