Skip to content

Commit a7f0b91

Browse files
committed
fix (chat): streaming display
1 parent cc45a87 commit a7f0b91

1 file changed

Lines changed: 45 additions & 39 deletions

File tree

src/client/components/ChatBubble.js

Lines changed: 45 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import IconGoogleDrive from "./icons/IconGoogleDrive"
2424
import IconGoogleMail from "./icons/IconGoogleMail"
2525
import toast from "react-hot-toast"
2626

27-
// LinkButton component to handle different types of links with custom icons
27+
// LinkButton component (no changes needed)
2828
const LinkButton = ({ href, children }) => {
2929
const toolMapping = {
3030
"drive.google.com": {
@@ -93,7 +93,7 @@ const LinkButton = ({ href, children }) => {
9393
)
9494
}
9595

96-
// ToolCodeBlock component to display tool calls in a collapsible format
96+
// ToolCodeBlock is no longer rendered, but we keep it for potential future use or debugging
9797
const ToolCodeBlock = ({ name, code, isExpanded, onToggle }) => {
9898
let formattedCode = code
9999
try {
@@ -171,20 +171,24 @@ const ChatBubble = ({
171171
isUser,
172172
memoryUsed,
173173
agentsUsed,
174-
internetUsed,
175-
isStreamDone
174+
internetUsed
176175
}) => {
177176
const [copied, setCopied] = useState(false)
178177
const [expandedStates, setExpandedStates] = useState({})
178+
const [renderedContent, setRenderedContent] = useState([])
179+
180+
// Memoize the parsed content to avoid re-parsing on every render
181+
React.useEffect(() => {
182+
setRenderedContent(renderMessageContent())
183+
}, [message, expandedStates]) // Rerun parsing if message or expansion state changes
179184

180185
// Function to copy message content to clipboard
181186
const handleCopyToClipboard = () => {
182-
// Filter out the tags and copy the plain text content.
183-
const plainText = message
184-
.replace(/<think>[\s\S]*?<\/think>/g, "")
185-
.replace(/<tool_code[^>]*>[\s\S]*?<\/tool_code>/g, "")
186-
.replace(/<tool_result[^>]*>[\s\S]*?<\/tool_result>/g, "")
187-
.replace(/<answer>([\s\S]*?)<\/answer>/g, "$1")
187+
// Build the text to copy from the parsed parts, ensuring we only copy the final answer
188+
const plainText = renderedContent
189+
.filter((part) => part.type === "answer")
190+
.map((part) => part.props.children)
191+
.join("")
188192
.trim()
189193

190194
navigator.clipboard
@@ -201,11 +205,14 @@ const ChatBubble = ({
201205
setExpandedStates((prev) => ({ ...prev, [id]: !prev[id] }))
202206
}
203207

204-
// Function to render message content, processing special tags and text
208+
// ***************************************************************
209+
// *** UPDATED LOGIC: Function to render message content ***
210+
// ***************************************************************
205211
const renderMessageContent = () => {
206212
if (isUser || typeof message !== "string" || !message) {
207-
return (
213+
return [
208214
<ReactMarkdown
215+
key="user-md"
209216
className="prose prose-invert"
210217
remarkPlugins={[remarkGfm]}
211218
children={message || ""}
@@ -215,18 +222,24 @@ const ChatBubble = ({
215222
)
216223
}}
217224
/>
218-
)
225+
]
219226
}
220227

221228
const contentParts = []
222229
const regex =
223-
/(<think>[\s\S]*?<\/think>|<tool_code name="[^"]+">[\s\S]*?<\/tool_code>|<tool_result tool_name="[^"]+">[\s\S]*?<\/tool_result>|<answer>[\s\S]*?<\/answer>)/g
230+
/(<think>[\s\S]*?<\/think>|<tool_code[^>]*>[\s\S]*?<\/tool_code>|<tool_result[^>]*>[\s\S]*?<\/tool_result>|<answer>[\s\S]*?<\/answer>)/g
224231
let lastIndex = 0
232+
let inToolCallPhase = false // State to track if we are between a tool_code and tool_result
225233

226-
// Parse the message into parts
227234
for (const match of message.matchAll(regex)) {
228-
// By not capturing the text between matches, we ignore "junk tokens"
235+
const precedingText = message.substring(lastIndex, match.index)
236+
237+
// 1. Add any text that came before the current tag, but only if we're not in the "ignore" phase
238+
if (precedingText.trim() && !inToolCallPhase) {
239+
contentParts.push({ type: "answer", content: precedingText })
240+
}
229241

242+
// 2. Process the matched tag
230243
const tag = match[0]
231244
let subMatch
232245

@@ -237,43 +250,46 @@ const ChatBubble = ({
237250
}
238251
} else if (
239252
(subMatch = tag.match(
240-
/<tool_code name="([^"]+)">([\s\S]*?)<\/tool_code>/
253+
/<tool_code name="([^"]+)">[\s\S]*?<\/tool_code>/
241254
))
242255
) {
243-
contentParts.push({
244-
type: "tool_code",
245-
name: subMatch[1],
246-
code: subMatch[2] ? subMatch[2].trim() : "{}"
247-
})
256+
// When we find a tool_code, we enter the "ignore" phase and do not render the code itself.
257+
inToolCallPhase = true
248258
} else if (
259+
// CORRECTED REGEX: Added ([\s\S]*?) to capture the result content
249260
(subMatch = tag.match(
250-
/<tool_result tool_name="([^"]+)">[\s\S]*?<\/tool_result>/
261+
/<tool_result tool_name="([^"]+)">([\s\S]*?)<\/tool_result>/
251262
))
252263
) {
264+
// When we find a tool_result, we exit the "ignore" phase and render the result.
265+
inToolCallPhase = false
253266
contentParts.push({
254267
type: "tool_result",
255268
name: subMatch[1],
256269
result: subMatch[2] ? subMatch[2].trim() : "{}"
257270
})
258271
} else if ((subMatch = tag.match(/<answer>([\s\S]*?)<\/answer>/))) {
259272
const answerContent = subMatch[1]
260-
// Don't trim, whitespace might be intentional
261273
if (answerContent) {
262274
contentParts.push({
263275
type: "answer",
264276
content: answerContent
265277
})
266278
}
267279
}
268-
269280
lastIndex = match.index + tag.length
270281
}
271282

272-
// By not capturing text after the last tag, we ignore trailing junk.
283+
// 3. Add any remaining text after the last tag (this is the final, streaming answer)
284+
const remainingText = message.substring(lastIndex)
285+
if (remainingText && !inToolCallPhase) {
286+
contentParts.push({ type: "answer", content: remainingText })
287+
}
273288

274-
// Render all parts
289+
// 4. Render all the collected parts into React components
275290
return contentParts.map((part, index) => {
276291
const partId = `${part.type}_${index}`
292+
277293
if (part.type === "think" && part.content) {
278294
return (
279295
<div
@@ -301,17 +317,6 @@ const ChatBubble = ({
301317
</div>
302318
)
303319
}
304-
if (part.type === "tool_code") {
305-
return (
306-
<ToolCodeBlock
307-
key={partId}
308-
name={part.name}
309-
code={part.code}
310-
isExpanded={!!expandedStates[partId]}
311-
onToggle={() => toggleExpansion(partId)}
312-
/>
313-
)
314-
}
315320
if (part.type === "tool_result") {
316321
return (
317322
<ToolResultBlock
@@ -338,6 +343,7 @@ const ChatBubble = ({
338343
/>
339344
)
340345
}
346+
// Note: tool_code parts are never rendered
341347
return null
342348
})
343349
}
@@ -351,7 +357,7 @@ const ChatBubble = ({
351357
} mb-2 relative`}
352358
style={{ wordBreak: "break-word" }}
353359
>
354-
{renderMessageContent()}
360+
{renderedContent}
355361
{!isUser && (
356362
<div className="flex justify-start items-center space-x-4 mt-6">
357363
<Tooltip id="chat-bubble-tooltip" />

0 commit comments

Comments
 (0)