diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 31bc9ae1b5e1..3be1e7900a07 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -1,12 +1,13 @@ import { useEffect, useMemo, useRef } from 'react'; import LinkPreview from './LinkPreview'; import ImagePreview from './ImagePreview'; -import GooseResponseForm from './GooseResponseForm'; import { extractUrls } from '../utils/urlUtils'; import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils'; import { formatMessageTimestamp } from '../utils/timeUtils'; import MarkdownContent from './MarkdownContent'; import ToolCallWithResponse from './ToolCallWithResponse'; +import ToolCallChain from './ToolCallChain'; +import { identifyConsecutiveToolCalls, shouldHideMessage, getChainForMessage } from '../utils/toolCallChaining'; import { Message, getTextContent, @@ -35,39 +36,21 @@ interface GooseMessageProps { export default function GooseMessage({ messageHistoryIndex, message, - metadata, messages, + metadata: _metadata, toolCallNotifications, - append, + append: _append, appendMessage, isStreaming = false, }: GooseMessageProps) { - const contentRef = useRef(null); - // Track which tool confirmations we've already handled to prevent infinite loops + const contentRef = useRef(null); const handledToolConfirmations = useRef>(new Set()); - // Extract text content from the message - let textContent = getTextContent(message); - - // Utility to split Chain-of-Thought (CoT) from the visible assistant response. - // If the text contains a ... block, everything inside is treated as the - // CoT and removed from the user-visible text. - const splitChainOfThought = (text: string): { visibleText: string; cotText: string | null } => { - const regex = /([\s\S]*?)<\/think>/i; - const match = text.match(regex); - if (!match) { - return { visibleText: text, cotText: null }; - } - - const cotRaw = match[1].trim(); - const visible = text.replace(match[0], '').trim(); - return { visibleText: visible, cotText: cotRaw.length > 0 ? cotRaw : null }; - }; + // Extract image paths from the message content + const imagePaths = extractImagePaths(getTextContent(message)); - const { visibleText: textWithoutCot, cotText } = splitChainOfThought(textContent); - - // Extract image paths from the visible part of the message (exclude CoT) - const imagePaths = extractImagePaths(textWithoutCot); + // Get text content without Chain of Thought + const textWithoutCot = getTextContent(message); // Remove image paths from text for display const displayText = @@ -79,11 +62,53 @@ export default function GooseMessage({ // Get tool requests from the message const toolRequests = getToolRequests(message); + // Get current message index + const messageIndex = messages.findIndex((msg) => msg.id === message.id); + + // Enhanced chain detection that works during streaming + const toolCallChains = useMemo(() => { + // Always run chain detection, but handle streaming messages specially + const chains = identifyConsecutiveToolCalls(messages); + + // If this message is streaming and has tool calls but no text, + // check if it should extend an existing chain + if (isStreaming && toolRequests.length > 0 && !displayText.trim()) { + // Look for an existing chain that this message could extend + const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null; + if (previousMessage) { + const prevToolRequests = getToolRequests(previousMessage); + + // If previous message has tool calls (with or without text), extend its chain + if (prevToolRequests.length > 0) { + // Find if previous message is part of a chain + const prevChain = chains.find(chain => chain.includes(messageIndex - 1)); + if (prevChain) { + // Extend the existing chain to include this streaming message + const extendedChains = chains.map(chain => + chain === prevChain ? [...chain, messageIndex] : chain + ); + return extendedChains; + } else { + // Create a new chain with previous and current message + return [...chains, [messageIndex - 1, messageIndex]]; + } + } + } + } + + return chains; + }, [messages, isStreaming, messageIndex, toolRequests, displayText]); + + // Check if this message should be hidden (part of chain but not first) + const shouldHide = shouldHideMessage(messageIndex, toolCallChains); + + // Get the chain this message belongs to + const messageChain = getChainForMessage(messageIndex, toolCallChains); + // Extract URLs under a few conditions // 1. The message is purely text // 2. The link wasn't also present in the previous message // 3. The message contains the explicit http:// or https:// protocol at the beginning - const messageIndex = messages?.findIndex((msg) => msg.id === message.id); const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null; const previousUrls = previousMessage ? extractUrls(getTextContent(previousMessage)) : []; const urls = toolRequests.length === 0 ? extractUrls(displayText, previousUrls) : []; @@ -132,7 +157,10 @@ export default function GooseMessage({ handledToolConfirmations.current.add(toolConfirmationContent.id); appendMessage( - createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.') + createToolErrorResponseMessage( + toolConfirmationContent.id, + 'Tool execution was cancelled or interrupted.' + ) ); } } @@ -145,76 +173,97 @@ export default function GooseMessage({ appendMessage, ]); - return ( -
-
- {/* Chain-of-Thought (hidden by default) */} - {cotText && ( -
- - Show thinking - -
- -
-
- )} + // If this message should be hidden (part of chain but not first), don't render it + if (shouldHide) { + return null; + } + + // Determine rendering logic based on chain membership and content + const isFirstInChain = messageChain && messageChain[0] === messageIndex; + const hasText = displayText.trim().length > 0; - {/* Visible assistant response */} + return ( +
+
+ {/* Regular text content - ALWAYS show if present */} {displayText && ( -
-
-
{}
+
+
+
- {/* Render images if any */} + {/* Image previews */} {imagePaths.length > 0 && ( -
+
{imagePaths.map((imagePath, index) => ( - + ))}
)} - {/* Only show timestamp and copy link when not streaming */} -
- {toolRequests.length === 0 && !isStreaming && ( -
- {timestamp} -
- )} - {displayText && - message.content.every((content) => content.type === 'text') && - !isStreaming && ( + {/* URLs */} + {urls.length > 0 && ( +
+ {urls.map((url, index) => ( + + ))} +
+ )} + + {/* Show timestamp for text-only messages */} + {toolRequests.length === 0 && ( +
+ {!isStreaming && ( +
+ {timestamp} +
+ )} + {message.content.every((content) => content.type === 'text') && !isStreaming && (
)} -
+
+ )}
)} + {/* Tool calls - either as chain or individual */} {toolRequests.length > 0 && ( -
- {toolRequests.map((toolRequest) => ( -
- + <> + {isFirstInChain ? ( + // This is the first message in a chain - render the entire chain + + ) : !messageChain ? ( + // This message is not part of any chain - render individual tool calls +
+ {toolRequests.map((toolRequest) => ( +
+ +
+ ))} +
+ {!isStreaming && timestamp} +
- ))} -
- {!isStreaming && timestamp} -
-
+ ) : null} + )} {hasToolConfirmation && ( @@ -226,25 +275,6 @@ export default function GooseMessage({ /> )}
- - {/* TODO(alexhancock): Re-enable link previews once styled well again */} - {/* eslint-disable-next-line no-constant-binary-expression */} - {false && urls.length > 0 && ( -
- {urls.map((url, index) => ( - - ))} -
- )} - - {/* enable or disable prompts here */} - {/* NOTE from alexhancock on 1/14/2025 - disabling again temporarily due to non-determinism in when the forms show up */} - {/* eslint-disable-next-line no-constant-binary-expression */} - {false && metadata && ( -
- -
- )}
); } diff --git a/ui/desktop/src/components/GooseMessage.tsx.backup b/ui/desktop/src/components/GooseMessage.tsx.backup new file mode 100644 index 000000000000..31bc9ae1b5e1 --- /dev/null +++ b/ui/desktop/src/components/GooseMessage.tsx.backup @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useRef } from 'react'; +import LinkPreview from './LinkPreview'; +import ImagePreview from './ImagePreview'; +import GooseResponseForm from './GooseResponseForm'; +import { extractUrls } from '../utils/urlUtils'; +import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils'; +import { formatMessageTimestamp } from '../utils/timeUtils'; +import MarkdownContent from './MarkdownContent'; +import ToolCallWithResponse from './ToolCallWithResponse'; +import { + Message, + getTextContent, + getToolRequests, + getToolResponses, + getToolConfirmationContent, + createToolErrorResponseMessage, +} from '../types/message'; +import ToolCallConfirmation from './ToolCallConfirmation'; +import MessageCopyLink from './MessageCopyLink'; +import { NotificationEvent } from '../hooks/useMessageStream'; + +interface GooseMessageProps { + // messages up to this index are presumed to be "history" from a resumed session, this is used to track older tool confirmation requests + // anything before this index should not render any buttons, but anything after should + messageHistoryIndex: number; + message: Message; + messages: Message[]; + metadata?: string[]; + toolCallNotifications: Map; + append: (value: string) => void; + appendMessage: (message: Message) => void; + isStreaming?: boolean; // Whether this message is currently being streamed +} + +export default function GooseMessage({ + messageHistoryIndex, + message, + metadata, + messages, + toolCallNotifications, + append, + appendMessage, + isStreaming = false, +}: GooseMessageProps) { + const contentRef = useRef(null); + // Track which tool confirmations we've already handled to prevent infinite loops + const handledToolConfirmations = useRef>(new Set()); + + // Extract text content from the message + let textContent = getTextContent(message); + + // Utility to split Chain-of-Thought (CoT) from the visible assistant response. + // If the text contains a ... block, everything inside is treated as the + // CoT and removed from the user-visible text. + const splitChainOfThought = (text: string): { visibleText: string; cotText: string | null } => { + const regex = /([\s\S]*?)<\/think>/i; + const match = text.match(regex); + if (!match) { + return { visibleText: text, cotText: null }; + } + + const cotRaw = match[1].trim(); + const visible = text.replace(match[0], '').trim(); + return { visibleText: visible, cotText: cotRaw.length > 0 ? cotRaw : null }; + }; + + const { visibleText: textWithoutCot, cotText } = splitChainOfThought(textContent); + + // Extract image paths from the visible part of the message (exclude CoT) + const imagePaths = extractImagePaths(textWithoutCot); + + // Remove image paths from text for display + const displayText = + imagePaths.length > 0 ? removeImagePathsFromText(textWithoutCot, imagePaths) : textWithoutCot; + + // Memoize the timestamp + const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]); + + // Get tool requests from the message + const toolRequests = getToolRequests(message); + + // Extract URLs under a few conditions + // 1. The message is purely text + // 2. The link wasn't also present in the previous message + // 3. The message contains the explicit http:// or https:// protocol at the beginning + const messageIndex = messages?.findIndex((msg) => msg.id === message.id); + const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null; + const previousUrls = previousMessage ? extractUrls(getTextContent(previousMessage)) : []; + const urls = toolRequests.length === 0 ? extractUrls(displayText, previousUrls) : []; + + const toolConfirmationContent = getToolConfirmationContent(message); + const hasToolConfirmation = toolConfirmationContent !== undefined; + + // Find tool responses that correspond to the tool requests in this message + const toolResponsesMap = useMemo(() => { + const responseMap = new Map(); + + // Look for tool responses in subsequent messages + if (messageIndex !== undefined && messageIndex >= 0) { + for (let i = messageIndex + 1; i < messages.length; i++) { + const responses = getToolResponses(messages[i]); + + for (const response of responses) { + // Check if this response matches any of our tool requests + const matchingRequest = toolRequests.find((req) => req.id === response.id); + if (matchingRequest) { + responseMap.set(response.id, response); + } + } + } + } + + return responseMap; + }, [messages, messageIndex, toolRequests]); + + useEffect(() => { + // If the message is the last message in the resumed session and has tool confirmation, it means the tool confirmation + // is broken or cancelled, to contonue use the session, we need to append a tool response to avoid mismatch tool result error. + if ( + messageIndex === messageHistoryIndex - 1 && + hasToolConfirmation && + toolConfirmationContent && + !handledToolConfirmations.current.has(toolConfirmationContent.id) + ) { + // Only append the error message if there isn't already a response for this tool confirmation + const hasExistingResponse = messages.some((msg) => + getToolResponses(msg).some((response) => response.id === toolConfirmationContent.id) + ); + + if (!hasExistingResponse) { + // Mark this tool confirmation as handled to prevent infinite loop + handledToolConfirmations.current.add(toolConfirmationContent.id); + + appendMessage( + createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.') + ); + } + } + }, [ + messageIndex, + messageHistoryIndex, + hasToolConfirmation, + toolConfirmationContent, + messages, + appendMessage, + ]); + + return ( +
+
+ {/* Chain-of-Thought (hidden by default) */} + {cotText && ( +
+ + Show thinking + +
+ +
+
+ )} + + {/* Visible assistant response */} + {displayText && ( +
+
+
{}
+
+ + {/* Render images if any */} + {imagePaths.length > 0 && ( +
+ {imagePaths.map((imagePath, index) => ( + + ))} +
+ )} + + {/* Only show timestamp and copy link when not streaming */} +
+ {toolRequests.length === 0 && !isStreaming && ( +
+ {timestamp} +
+ )} + {displayText && + message.content.every((content) => content.type === 'text') && + !isStreaming && ( +
+ +
+ )} +
+
+ )} + + {toolRequests.length > 0 && ( +
+ {toolRequests.map((toolRequest) => ( +
+ +
+ ))} +
+ {!isStreaming && timestamp} +
+
+ )} + + {hasToolConfirmation && ( + + )} +
+ + {/* TODO(alexhancock): Re-enable link previews once styled well again */} + {/* eslint-disable-next-line no-constant-binary-expression */} + {false && urls.length > 0 && ( +
+ {urls.map((url, index) => ( + + ))} +
+ )} + + {/* enable or disable prompts here */} + {/* NOTE from alexhancock on 1/14/2025 - disabling again temporarily due to non-determinism in when the forms show up */} + {/* eslint-disable-next-line no-constant-binary-expression */} + {false && metadata && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/desktop/src/components/ToolCallChain.tsx b/ui/desktop/src/components/ToolCallChain.tsx new file mode 100644 index 000000000000..cebbeff70d3c --- /dev/null +++ b/ui/desktop/src/components/ToolCallChain.tsx @@ -0,0 +1,63 @@ +import { formatMessageTimestamp } from '../utils/timeUtils'; +import { Message, getToolRequests } from '../types/message'; +import { NotificationEvent } from '../hooks/useMessageStream'; +import ToolCallWithResponse from './ToolCallWithResponse'; + +interface ToolCallChainProps { + messages: Message[]; + chainIndices: number[]; + toolCallNotifications: Map; + toolResponsesMap: Map; + messageHistoryIndex: number; + isStreaming?: boolean; +} + +/** + * Component that renders a chain of consecutive tool call messages with a single timestamp + */ +export default function ToolCallChain({ + messages, + chainIndices, + toolCallNotifications, + toolResponsesMap, + messageHistoryIndex, + isStreaming = false +}: ToolCallChainProps) { + // Get the timestamp from the last message in the chain + const lastMessageIndex = chainIndices[chainIndices.length - 1]; + const lastMessage = messages[lastMessageIndex]; + const timestamp = lastMessage ? formatMessageTimestamp(lastMessage.created) : ''; + + return ( +
+ {/* Render each message's tool calls in the chain */} + {chainIndices.map((messageIndex) => { + const message = messages[messageIndex]; + const toolRequests = getToolRequests(message); + + return toolRequests.map((toolRequest) => ( +
+ +
+ )); + })} + + {/* Single timestamp for the entire chain */} +
+ {!isStreaming && timestamp} +
+
+ ); +} diff --git a/ui/desktop/src/utils/toolCallChaining.ts b/ui/desktop/src/utils/toolCallChaining.ts new file mode 100644 index 000000000000..2959f7f88e67 --- /dev/null +++ b/ui/desktop/src/utils/toolCallChaining.ts @@ -0,0 +1,87 @@ +import { Message, getToolRequests, getTextContent, getToolResponses } from '../types/message'; + +/** + * Enhanced function to detect tool call chains including mixed content messages + * @param messages - Array of all messages + * @returns Array of message indices that should be chained together + */ +export function identifyConsecutiveToolCalls(messages: Message[]): number[][] { + const chains: number[][] = []; + let currentChain: number[] = []; + + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + const toolRequests = getToolRequests(message); + const toolResponses = getToolResponses(message); + const textContent = getTextContent(message); + const hasText = textContent.trim().length > 0; + + // Skip tool response messages - they don't break chains + if (toolResponses.length > 0 && toolRequests.length === 0) { + continue; + } + + // This message has tool calls + if (toolRequests.length > 0) { + if (hasText) { + // Message with text + tools - start a new chain or add to existing + if (currentChain.length > 0) { + // End current chain and start new one + if (currentChain.length > 1) { + chains.push([...currentChain]); + } + } + // Start new chain with this mixed content message + currentChain = [i]; + } else { + // Pure tool call message - add to chain + currentChain.push(i); + } + } else if (hasText) { + // Pure text message - end current chain + if (currentChain.length > 1) { + chains.push([...currentChain]); + } + currentChain = []; + } else { + // Empty or other content - end current chain + if (currentChain.length > 1) { + chains.push([...currentChain]); + } + currentChain = []; + } + } + + // Don't forget the last chain + if (currentChain.length > 1) { + chains.push(currentChain); + } + + return chains; +} + +/** + * Check if a message at given index should be hidden (part of chain but not first) + * @param messageIndex - Index of the message to check + * @param chains - Array of chains (arrays of message indices) + * @returns True if message should be hidden + */ +export function shouldHideMessage(messageIndex: number, chains: number[][]): boolean { + for (const chain of chains) { + if (chain.includes(messageIndex)) { + // Hide if it's in a chain but not the first message + return chain[0] !== messageIndex; + } + } + return false; +} + +/** + * Get the chain that contains the given message index + * @param messageIndex - Index of the message + * @param chains - Array of chains + * @returns The chain containing this message, or null + */ +export function getChainForMessage(messageIndex: number, chains: number[][]): number[] | null { + return chains.find(chain => chain.includes(messageIndex)) || null; +}