Skip to content

Commit 824bc7e

Browse files
committed
fix (chat): tool code rendering (WIP)
1 parent e912fa9 commit 824bc7e

1 file changed

Lines changed: 45 additions & 125 deletions

File tree

src/client/components/ChatBubble.js

Lines changed: 45 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,8 @@ import IconGoogleDrive from "./icons/IconGoogleDrive" // Importing custom icon c
2424
import IconGoogleMail from "./icons/IconGoogleMail" // Importing custom icon component for Google Mail
2525
import toast from "react-hot-toast" // Importing toast for displaying toast notifications
2626

27-
/**
28-
* LinkButton Component - Renders a styled button that opens a link in a new tab.
29-
*
30-
* This component is used within chat messages to display URLs in a button format.
31-
* It automatically detects the type of link (e.g., Google Docs, Gmail, generic link)
32-
* and displays an appropriate icon and name. Clicking the button opens the link in a new tab.
33-
*
34-
* @param {object} props - Component props.
35-
* @param {string} props.href - The URL to be opened when the button is clicked.
36-
* @param {React.ReactNode} props.children - The display text for the link, used as fallback name if tool name not found.
37-
* @returns {React.ReactNode} - The LinkButton component UI.
38-
*/
27+
// LinkButton component remains the same...
3928
const LinkButton = ({ href, children }) => {
40-
// Mapping of domain names to their respective icons and names for tool identification
4129
const toolMapping = {
4230
"drive.google.com": {
4331
icon: <IconGoogleDrive size={14} className="mr-1" />,
@@ -49,7 +37,7 @@ const LinkButton = ({ href, children }) => {
4937
},
5038
"gmail.com": {
5139
icon: <IconGoogleMail size={14} className="mr-1" />,
52-
name: children // Fallback name if specific tool name is not found
40+
name: children
5341
},
5442
"docs.google.com/spreadsheets": {
5543
icon: <IconGoogleSheets />,
@@ -69,62 +57,46 @@ const LinkButton = ({ href, children }) => {
6957
},
7058
"external-mail": {
7159
icon: <IconMail size={14} className="mr-1" />,
72-
name: children // Uses children as name for external mail links
60+
name: children
7361
},
7462
default: {
7563
icon: <IconLink size={14} className="mr-1" />,
76-
name: "Link" // Default name for generic links
64+
name: "Link"
7765
}
7866
}
7967

80-
/**
81-
* Determines the tool details (icon and name) based on the URL.
82-
*
83-
* Iterates through the `toolMapping` to find a matching domain in the given URL.
84-
* If a match is found, returns the corresponding icon and name.
85-
* If no specific domain is matched, defaults to a generic link icon and name.
86-
*
87-
* @function getToolDetails
88-
* @param {string} url - The URL to analyze.
89-
* @returns {{ icon: React.ReactNode, name: string }} - An object containing the icon and name for the tool.
90-
*/
9168
const getToolDetails = (url) => {
9269
for (const domain in toolMapping) {
9370
if (url.includes(domain)) {
94-
return toolMapping[domain] // Return tool details if domain is found in URL
71+
return toolMapping[domain]
9572
} else if (url.match(/^[^@]+@[\w.-]+\.[a-z]{2,}$/i)) {
96-
return toolMapping["external-mail"] // Return external mail tool details if URL matches email format
73+
return toolMapping["external-mail"]
9774
}
9875
}
99-
return toolMapping["default"] // Return default tool details for unmatched URLs
76+
return toolMapping["default"]
10077
}
10178

102-
const { icon, name } = getToolDetails(href) // Get tool icon and name based on href
79+
const { icon, name } = getToolDetails(href)
10380

10481
return (
10582
<span
10683
onClick={() => window.open(href, "_blank", "noopener noreferrer")}
10784
className="bg-[var(--color-primary-surface)] text-[var(--color-text-primary)] border border-[var(--color-primary-surface-elevated)] hover:border-[var(--color-accent-blue)] py-1 px-2 rounded-md items-center cursor-pointer inline-flex"
108-
// Styling for the link button: background, text color, border, padding, rounded corners, cursor, inline-flex display
10985
style={{
110-
display: "inline-flex", // Ensure inline-flex for proper alignment
111-
verticalAlign: "middle" // Vertical alignment to middle
86+
display: "inline-flex",
87+
verticalAlign: "middle"
11288
}}
11389
>
114-
{icon} {/* Render the icon determined by getToolDetails */}
90+
{icon}
11591
<span>{name}</span>{" "}
116-
{/* Render the name determined by getToolDetails */}
11792
</span>
11893
)
11994
}
12095

121-
/**
122-
* A dedicated component to render tool code blocks neatly.
123-
*/
96+
// ToolCodeBlock component remains the same...
12497
const ToolCodeBlock = ({ name, code, isExpanded, onToggle }) => {
12598
let formattedCode = code
12699
try {
127-
// Pretty-print if it's a JSON string
128100
const parsed = JSON.parse(code)
129101
formattedCode = JSON.stringify(parsed, null, 2)
130102
} catch (e) {
@@ -157,14 +129,10 @@ const ToolCodeBlock = ({ name, code, isExpanded, onToggle }) => {
157129
)
158130
}
159131

160-
/**
161-
* A dedicated component to render tool result blocks neatly.
162-
* NOTE: This component is now being used to display the tool result.
163-
*/
132+
// ToolResultBlock component remains the same...
164133
const ToolResultBlock = ({ name, result, isExpanded, onToggle }) => {
165134
let formattedResult = result
166135
try {
167-
// Pretty-print if it's a JSON string
168136
const parsed = JSON.parse(result)
169137
formattedResult = JSON.stringify(parsed, null, 2)
170138
} catch (e) {
@@ -197,47 +165,18 @@ const ToolResultBlock = ({ name, result, isExpanded, onToggle }) => {
197165
)
198166
}
199167

200-
/**
201-
* ChatBubble Component - Displays a single chat message bubble.
202-
*
203-
* This component renders a chat message, distinguishing between user and AI messages with different styles.
204-
* It supports rendering Markdown content, detects and renders URLs as LinkButtons, and provides functionality
205-
* to copy message text to the clipboard. For AI messages, it also conditionally displays icons indicating
206-
* if memory, agents, or internet were used in generating the response, along with tooltips for these icons.
207-
*
208-
* @param {object} props - Component props.
209-
* @param {string} props.message - The text content of the chat message, can be Markdown or JSON.
210-
* @param {boolean} props.isUser - Boolean indicating if the message is from the user or AI.
211-
* @param {boolean} props.memoryUsed - Boolean indicating if memory was used to generate the response (AI messages only).
212-
* @param {boolean} props.agentsUsed - Boolean indicating if agents were used (AI messages only).
213-
* @param {boolean} props.boolean - Boolean indicating if internet was used (AI messages only).
214-
* @returns {React.ReactNode} - The ChatBubble component UI.
215-
*/
216168
const ChatBubble = ({
217-
message, // Text content of the message, can be Markdown or JSON - message: string
218-
isUser, // Boolean, true if message is from user, false if from AI - isUser: boolean
219-
memoryUsed, // Boolean, true if memory was used in response generation (AI only) - memoryUsed: boolean
220-
agentsUsed, // Boolean, true if agents were used in response generation (AI only) - agentsUsed: boolean
221-
internetUsed // Boolean, true if internet was used in response generation (AI only) - internetUsed: boolean
169+
message,
170+
isUser,
171+
memoryUsed,
172+
agentsUsed,
173+
internetUsed
222174
}) => {
223-
// State to manage the 'copied' status for the copy button, indicating if the message text has been copied.
224175
const [copied, setCopied] = useState(false)
225176
const [expandedStates, setExpandedStates] = useState({})
226177

227-
/**
228-
* Handles copying the message text to the clipboard.
229-
*
230-
* When the copy button is clicked, this function attempts to write the message text to the clipboard.
231-
* On success, it sets the 'copied' state to true to update the button icon to a checkmark,
232-
* and then resets it back to false after a short delay (2 seconds) to revert the icon.
233-
* If copying fails, it displays an error toast notification to inform the user.
234-
*
235-
* @function handleCopyToClipboard
236-
* @returns {void}
237-
*/
238178
const handleCopyToClipboard = () => {
239179
let textToCopy = message
240-
// If message is a JSON string (e.g., from old tool results), stringify it for copying
241180
try {
242181
const parsed = JSON.parse(message)
243182
textToCopy = JSON.stringify(parsed, null, 2)
@@ -258,16 +197,8 @@ const ChatBubble = ({
258197
setExpandedStates((prev) => ({ ...prev, [id]: !prev[id] }))
259198
}
260199

261-
/**
262-
* Renders the content of the chat bubble.
263-
*
264-
* This function parses the incoming message string for special tags like <think>,
265-
* <tool_code>, and <tool_result>. It splits the message into parts and renders
266-
* each part accordingly: text as Markdown, and special tags as collapsible blocks.
267-
*/
268200
const renderMessageContent = () => {
269201
if (isUser || typeof message !== "string" || !message) {
270-
// Fallback for user messages or non-string AI messages
271202
return (
272203
<ReactMarkdown
273204
className="prose prose-invert"
@@ -287,26 +218,27 @@ const ChatBubble = ({
287218
/(<think>[\s\S]*?<\/think>|<tool_code name="[^"]+">[\s\S]*?<\/tool_code>|<tool_result tool_name="[^"]+">[\s\S]*?<\/tool_result>)/g
288219
let lastIndex = 0
289220

290-
// --- START OF THE ROBUST FIX ---
291-
292-
// Helper function to check for and filter out junk tokens
293-
const isJunk = (text) => {
294-
const trimmed = text.trim()
295-
if (trimmed === "") return true // Ignore whitespace-only strings
296-
297-
// This regex identifies common junk patterns seen in logs.
298-
// It looks for fragments of closing tags or orphaned tag-like words.
299-
const junkRegex = /^(<\/(\w+>)?|_code>|code>|ode>|>)$/
300-
return junkRegex.test(trimmed)
301-
}
221+
// --- START OF THE ROBUST CONTEXTUAL FIX ---
302222

303223
for (const match of message.matchAll(regex)) {
304224
// 1. Process the text *before* the current valid tag
305225
if (match.index > lastIndex) {
306226
const textContent = message.substring(lastIndex, match.index)
307-
// Only add the text if it's NOT junk
308-
if (!isJunk(textContent)) {
309-
contentParts.push({ type: "text", content: textContent })
227+
const lastPart =
228+
contentParts.length > 0
229+
? contentParts[contentParts.length - 1]
230+
: null
231+
232+
// This is the key: Only add text if it's at the start of the message
233+
// or if it follows another text block. This filters out any text
234+
// sandwiched between special tags (e.g., </tool_code>...JUNK...<tool_result>).
235+
if (!lastPart || lastPart.type === "text") {
236+
if (textContent.trim()) {
237+
contentParts.push({
238+
type: "text",
239+
content: textContent
240+
})
241+
}
310242
}
311243
}
312244

@@ -319,54 +251,43 @@ const ChatBubble = ({
319251
/<tool_code name="([^"]+)">([\s\S]*?)<\/tool_code>/
320252
))
321253
) {
322-
const toolName = subMatch[1]
323-
// Handle empty tool_code blocks gracefully
324-
const toolCode = subMatch[2] ? subMatch[2].trim() : "{}"
325254
contentParts.push({
326255
type: "tool_code",
327-
name: toolName,
328-
code: toolCode
256+
name: subMatch[1],
257+
code: subMatch[2] ? subMatch[2].trim() : "{}"
329258
})
330259
} else if (
331260
(subMatch = tag.match(
332261
/<tool_result tool_name="([^"]+)">([\s\S]*?)<\/tool_result>/
333262
))
334263
) {
335-
const toolName = subMatch[1]
336-
const toolResult = subMatch[2] ? subMatch[2].trim() : "{}"
337264
contentParts.push({
338265
type: "tool_result",
339-
name: toolName,
340-
result: toolResult
266+
name: subMatch[1],
267+
result: subMatch[2] ? subMatch[2].trim() : "{}"
341268
})
342269
} else if ((subMatch = tag.match(/<think>([\s\S]*?)<\/think>/))) {
343270
const thinkContent = subMatch[1].trim()
344271
if (thinkContent) {
345-
contentParts.push({
346-
type: "think",
347-
content: thinkContent
348-
})
272+
contentParts.push({ type: "think", content: thinkContent })
349273
}
350274
}
351275

352276
// 3. Update our position in the message string
353277
lastIndex = match.index + tag.length
354278
}
355279

356-
// 4. Process any remaining text after the last valid tag
280+
// 4. Process any remaining text after the last valid tag.
281+
// This is often the final answer from the assistant.
357282
if (lastIndex < message.length) {
358283
const remainingText = message.substring(lastIndex)
359-
// Also check the final part for junk or incomplete streaming tags
360-
const openBrackets = (message.match(/</g) || []).length
361-
const closeBrackets = (message.match(/>/g) || []).length
362-
363-
if (!isJunk(remainingText) && openBrackets <= closeBrackets) {
284+
if (remainingText.trim()) {
364285
contentParts.push({ type: "text", content: remainingText })
365286
}
366287
}
367-
// --- END OF THE ROBUST FIX ---
368288

369-
// The rest of the rendering logic remains the same
289+
// --- END OF THE ROBUST CONTEXTUAL FIX ---
290+
370291
return contentParts.map((part, index) => {
371292
const partId = `${part.type}_${index}`
372293
if (part.type === "think" && part.content) {
@@ -409,7 +330,6 @@ const ChatBubble = ({
409330
/>
410331
)
411332
}
412-
// ADDED THIS BLOCK TO RENDER THE TOOL RESULT
413333
if (part.type === "tool_result") {
414334
return (
415335
<ToolResultBlock
@@ -501,4 +421,4 @@ const ChatBubble = ({
501421
)
502422
}
503423

504-
export default ChatBubble
424+
export default ChatBubble

0 commit comments

Comments
 (0)