diff --git a/frontend/src/components/UnifiedChat.tsx b/frontend/src/components/UnifiedChat.tsx index 11713e501..aa3e5e008 100644 --- a/frontend/src/components/UnifiedChat.tsx +++ b/frontend/src/components/UnifiedChat.tsx @@ -86,7 +86,14 @@ import type { import type { ResponseFunctionWebSearch, ResponseFunctionToolCall, - ResponseFunctionToolCallOutputItem + ResponseFunctionToolCallOutputItem, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseReasoningItem, + ResponseReasoningTextDeltaEvent, + ResponseReasoningTextDoneEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent } from "openai/resources/responses/responses.js"; import type { Message as OpenAIMessage } from "openai/resources/conversations/conversations.js"; @@ -100,22 +107,35 @@ type ConversationContent = | ComputerScreenshotContent | InputFileContent | ResponseFunctionWebSearch - | ResponseFunctionToolCall - | ResponseFunctionToolCallOutputItem; + | ToolCallItem + | ToolOutputItem; -// Extended message type with streaming status support -type ExtendedMessage = OpenAIMessage & { - status?: "completed" | "in_progress" | "incomplete" | "streaming" | "error"; +type MessageStatus = "completed" | "in_progress" | "incomplete" | "streaming" | "error"; +type ExtendedMessage = Omit & { + status?: MessageStatus; }; -// Reasoning item type for model thinking/reasoning (e.g., Kimi K2) -type ReasoningContentItem = { type: "text"; text: string }; -type ReasoningItem = { - type: "reasoning"; +type ReasoningContentItem = { type: "reasoning_text"; text: string }; +type ReasoningItem = Omit & { + content?: ReasoningContentItem[]; + status?: MessageStatus; +}; + +type ToolCallItem = { id: string; - content: ReasoningContentItem[]; - status?: "completed" | "in_progress" | "incomplete" | "streaming"; - created_at?: number; + type: "tool_call"; + call_id: string; + name: string; + arguments: string; + status?: MessageStatus; +}; + +type ToolOutputItem = { + id: string; + type: "tool_output"; + call_id: string; + output: string; + status?: MessageStatus; }; // Union type for all possible conversation items (messages, tool calls, tool outputs, web search, reasoning) @@ -123,8 +143,8 @@ type ReasoningItem = { type Message = | ExtendedMessage | (ResponseFunctionWebSearch & { id: string }) - | (ResponseFunctionToolCall & { id: string }) - | (ResponseFunctionToolCallOutputItem & { id: string }) + | ToolCallItem + | ToolOutputItem | ReasoningItem; // Helper function to merge messages while ensuring uniqueness by ID @@ -155,17 +175,528 @@ function MapleChatAvatar() { ); } +function updateMessageById( + messages: Message[], + messageId: string, + updater: (message: Message) => Message +): Message[] { + const messageToUpdate = messages.find((message) => message.id === messageId); + if (!messageToUpdate) return messages; + return mergeMessagesById(messages, [updater(messageToUpdate)]); +} + +function upsertAssistantTextContent( + message: ExtendedMessage, + contentIndex: number, + text: string, + status?: ExtendedMessage["status"] +): ExtendedMessage { + const content = [...(message.content ?? [])]; + const existingPart = content[contentIndex]; + + if ( + existingPart && + (existingPart.type === "input_text" || + existingPart.type === "output_text" || + existingPart.type === "text") && + "text" in existingPart + ) { + content[contentIndex] = { + ...existingPart, + text + }; + } else { + content[contentIndex] = { + type: "output_text", + text, + annotations: [] + }; + } + + return { + ...message, + content, + ...(status ? { status } : {}) + }; +} + +function normalizeReasoningItem(item: ResponseReasoningItem | ReasoningItem): ReasoningItem { + const summary = Array.isArray(item.summary) ? item.summary : []; + const contentItems = Array.isArray(item.content) ? item.content : []; + const content = (contentItems.length > 0 ? contentItems : summary) + .map((contentItem) => + typeof contentItem?.text === "string" + ? ({ + type: "reasoning_text", + text: contentItem.text + } as const) + : null + ) + .filter((contentItem): contentItem is ReasoningContentItem => contentItem !== null); + + return { + ...item, + summary, + content + }; +} + +function normalizeToolCallItem(item: unknown): ToolCallItem | null { + if (!item || typeof item !== "object" || !("type" in item)) return null; + + if ((item as { type?: string }).type === "tool_call") { + const toolCall = item as Partial; + if (typeof toolCall.id !== "string" || typeof toolCall.call_id !== "string") return null; + + return { + id: toolCall.id, + type: "tool_call", + call_id: toolCall.call_id, + name: typeof toolCall.name === "string" ? toolCall.name : "function", + arguments: + typeof toolCall.arguments === "string" + ? toolCall.arguments + : JSON.stringify(toolCall.arguments || {}), + status: toolCall.status + }; + } + + if ((item as { type?: string }).type === "function_call") { + const toolCall = item as ResponseFunctionToolCall & { id: string }; + if (!toolCall.id) return null; + + return { + id: toolCall.id, + type: "tool_call", + call_id: toolCall.call_id, + name: toolCall.name, + arguments: toolCall.arguments, + status: toolCall.status + }; + } + + return null; +} + +function normalizeToolOutputItem(item: unknown): ToolOutputItem | null { + if (!item || typeof item !== "object" || !("type" in item)) return null; + + if ((item as { type?: string }).type === "tool_output") { + const toolOutput = item as Partial; + if (typeof toolOutput.id !== "string" || typeof toolOutput.call_id !== "string") return null; + + return { + id: toolOutput.id, + type: "tool_output", + call_id: toolOutput.call_id, + output: + typeof toolOutput.output === "string" + ? toolOutput.output + : JSON.stringify(toolOutput.output || ""), + status: toolOutput.status + }; + } + + if ((item as { type?: string }).type === "function_call_output") { + const toolOutput = item as ResponseFunctionToolCallOutputItem & { id: string }; + if (!toolOutput.id) return null; + + return { + id: toolOutput.id, + type: "tool_output", + call_id: toolOutput.call_id, + output: toolOutput.output, + status: toolOutput.status + }; + } + + return null; +} + +function isToolCallItem(item: Message): item is ToolCallItem { + return item.type === "tool_call"; +} + +function isToolOutputItem(item: Message): item is ToolOutputItem { + return item.type === "tool_output"; +} + +function toolOutputHasResult(item: ToolOutputItem): boolean { + return item.status === "completed" || item.output.length > 0; +} + +function getReasoningContentLength(content?: ReasoningContentItem[]): number { + return (content ?? []).reduce((total, contentItem) => total + contentItem.text.length, 0); +} + +function getMessageContentLength(content?: ExtendedMessage["content"]): number { + return (content ?? []).reduce((total, contentItem) => { + if ( + (contentItem.type === "input_text" || + contentItem.type === "output_text" || + contentItem.type === "text") && + "text" in contentItem + ) { + return total + contentItem.text.length; + } + + if (contentItem.type === "input_image") { + return total + 1; + } + + return total; + }, 0); +} + +function upsertReasoningTextContent( + reasoning: ReasoningItem, + contentIndex: number, + text: string, + status?: ReasoningItem["status"] +): ReasoningItem { + const content = [...(reasoning.content ?? [])]; + content[contentIndex] = { + type: "reasoning_text", + text + }; + + return { + ...reasoning, + content, + ...(status ? { status } : {}) + }; +} + +function normalizeConversationItem(item: unknown): Message | null { + const toolCall = normalizeToolCallItem(item); + if (toolCall) return toolCall; + + const toolOutput = normalizeToolOutputItem(item); + if (toolOutput) return toolOutput; + + if (!item || typeof item !== "object" || !("id" in item) || !("type" in item)) { + return null; + } + + const typedItem = item as { type: string }; + + if (typedItem.type === "reasoning") { + return normalizeReasoningItem(item as ResponseReasoningItem | ReasoningItem); + } + + if (typedItem.type === "message" || typedItem.type === "web_search_call") { + return item as Message; + } + + return null; +} + +function isAssistantConversationItem(item: Message): boolean { + return item.type !== "message" || (item as ExtendedMessage).role !== "user"; +} + +function summarizeConversationItemForLog(item: unknown): Record { + const normalizedItem = normalizeConversationItem(item); + + if (!normalizedItem) { + return {}; + } + + if (normalizedItem.type === "reasoning") { + return { + itemType: normalizedItem.type, + itemId: normalizedItem.id, + status: normalizedItem.status, + contentLength: getReasoningContentLength(normalizedItem.content) + }; + } + + if (normalizedItem.type === "message") { + const message = normalizedItem as ExtendedMessage; + + return { + itemType: message.type, + itemId: message.id, + role: message.role, + status: message.status, + contentParts: message.content?.length ?? 0, + contentLength: getMessageContentLength(message.content) + }; + } + + if (normalizedItem.type === "web_search_call") { + return { + itemType: normalizedItem.type, + itemId: normalizedItem.id, + status: normalizedItem.status + }; + } + + if (normalizedItem.type === "tool_call") { + return { + itemType: normalizedItem.type, + itemId: normalizedItem.id, + callId: normalizedItem.call_id, + name: normalizedItem.name, + status: normalizedItem.status, + argumentsLength: normalizedItem.arguments.length + }; + } + + return { + itemType: normalizedItem.type, + itemId: normalizedItem.id, + callId: normalizedItem.call_id, + status: normalizedItem.status, + outputLength: normalizedItem.output.length + }; +} + +function summarizeStreamEventForLog(eventType: string, event: unknown): Record { + const summary: Record = {}; + const eventRecord = event as + | { + sequence_number?: number; + item_id?: string; + response?: { + id?: string; + status?: string; + output?: unknown[]; + }; + } + | undefined; + + if (typeof eventRecord?.sequence_number === "number") { + summary.sequenceNumber = eventRecord.sequence_number; + } + + if (typeof eventRecord?.item_id === "string") { + summary.itemId = eventRecord.item_id; + } + + switch (eventType) { + case "response.created": + case "response.completed": { + const response = eventRecord?.response; + + if (response?.id) { + summary.responseId = response.id; + } + + if (typeof response?.status === "string") { + summary.status = response.status; + } + + if (Array.isArray(response?.output)) { + summary.outputCount = response.output.length; + } + + return summary; + } + case "response.output_item.added": + case "response.output_item.done": { + const itemEvent = event as { output_index?: number; item?: unknown }; + + if (typeof itemEvent.output_index === "number") { + summary.outputIndex = itemEvent.output_index; + } + + return { + ...summary, + ...summarizeConversationItemForLog(itemEvent.item) + }; + } + case "response.reasoning_text.delta": { + const reasoningEvent = event as ResponseReasoningTextDeltaEvent; + + return { + ...summary, + contentIndex: reasoningEvent.content_index, + deltaLength: reasoningEvent.delta.length + }; + } + case "response.reasoning_text.done": { + const reasoningEvent = event as ResponseReasoningTextDoneEvent; + + return { + ...summary, + contentIndex: reasoningEvent.content_index, + textLength: reasoningEvent.text.length + }; + } + case "response.output_text.delta": { + const textEvent = event as ResponseTextDeltaEvent; + + return { + ...summary, + contentIndex: textEvent.content_index, + deltaLength: textEvent.delta.length + }; + } + case "response.output_text.done": { + const textEvent = event as ResponseTextDoneEvent; + + return { + ...summary, + contentIndex: textEvent.content_index, + textLength: textEvent.text.length + }; + } + case "tool_call.created": { + const toolCallEvent = event as { + tool_call_id?: string; + name?: string; + arguments?: string | Record; + }; + + return { + ...summary, + toolCallId: toolCallEvent.tool_call_id, + name: toolCallEvent.name, + ...(typeof toolCallEvent.arguments === "string" + ? { argumentsLength: toolCallEvent.arguments.length } + : toolCallEvent.arguments && typeof toolCallEvent.arguments === "object" + ? { argumentKeys: Object.keys(toolCallEvent.arguments) } + : {}) + }; + } + case "tool_output.created": { + const toolOutputEvent = event as { + tool_output_id?: string; + tool_call_id?: string; + output?: string; + }; + + return { + ...summary, + toolOutputId: toolOutputEvent.tool_output_id, + toolCallId: toolOutputEvent.tool_call_id, + outputLength: toolOutputEvent.output?.length ?? 0 + }; + } + default: + return summary; + } +} + +function updateActiveItemStatuses(messages: Message[], status: "error" | "incomplete"): Message[] { + const updatedMessages = messages + .filter((message) => { + const currentStatus = (message as { status?: string }).status; + return ( + currentStatus === "in_progress" || + currentStatus === "streaming" || + currentStatus === "searching" + ); + }) + .map((message) => ({ ...message, status }) as Message); + + return updatedMessages.length > 0 ? mergeMessagesById(messages, updatedMessages) : messages; +} + +function mergeStreamingConversationItem(messages: Message[], item: Message): Message[] { + if (item.type === "reasoning") { + const existingReasoning = messages.find( + (message): message is ReasoningItem => message.id === item.id && message.type === "reasoning" + ); + + if (!existingReasoning) { + return mergeMessagesById(messages, [item]); + } + + const incomingReasoning = item as ReasoningItem; + const existingContentLength = getReasoningContentLength(existingReasoning.content); + const incomingContentLength = getReasoningContentLength(incomingReasoning.content); + + return mergeMessagesById(messages, [ + { + ...existingReasoning, + ...incomingReasoning, + content: + incomingContentLength >= existingContentLength + ? incomingReasoning.content + : existingReasoning.content, + status: incomingReasoning.status ?? existingReasoning.status + } + ]); + } + + if (item.type === "message") { + const existingMessage = messages.find( + (message): message is ExtendedMessage => message.id === item.id && message.type === "message" + ); + + if (!existingMessage) { + return mergeMessagesById(messages, [item]); + } + + const incomingMessage = item as ExtendedMessage; + const existingContentLength = getMessageContentLength(existingMessage.content); + const incomingContentLength = getMessageContentLength(incomingMessage.content); + + return mergeMessagesById(messages, [ + { + ...existingMessage, + ...incomingMessage, + content: + incomingContentLength >= existingContentLength + ? incomingMessage.content + : existingMessage.content, + status: incomingMessage.status ?? existingMessage.status + } as Message + ]); + } + + if (isToolCallItem(item)) { + const existingToolCall = messages.find( + (message): message is ToolCallItem => message.id === item.id && isToolCallItem(message) + ); + + return mergeMessagesById(messages, [ + { + ...(existingToolCall ?? {}), + ...item, + arguments: + item.arguments.length >= (existingToolCall?.arguments.length ?? 0) + ? item.arguments + : (existingToolCall?.arguments ?? item.arguments), + status: item.status ?? existingToolCall?.status ?? "in_progress" + } + ]); + } + + if (isToolOutputItem(item)) { + const existingToolOutput = messages.find( + (message): message is ToolOutputItem => message.id === item.id && isToolOutputItem(message) + ); + + return mergeMessagesById(messages, [ + { + ...(existingToolOutput ?? {}), + ...item, + output: + item.output.length >= (existingToolOutput?.output.length ?? 0) + ? item.output + : (existingToolOutput?.output ?? item.output), + status: item.status ?? existingToolOutput?.status ?? "in_progress" + } + ]); + } + + return mergeMessagesById(messages, [item]); +} + // Helper function to convert conversation items - just returns them as-is (flat, no grouping) // The API already returns items in the correct format (ConversationItem union) function convertItemsToMessages(items: Array): Message[] { - return items.filter((item): item is Message => { - const isValid = item != null && typeof item === "object" && "id" in item && "type" in item; + return items.flatMap((item) => { + const normalizedItem = normalizeConversationItem(item); - if (!isValid && item != null) { + if (!normalizedItem && item != null) { console.warn("Invalid conversation item filtered from API response:", item); } - return isValid; + return normalizedItem ? [normalizedItem] : []; }); } @@ -301,13 +832,24 @@ interface Conversation { }; } +function getToolCallQuery(functionCall: ToolCallItem): string { + try { + const args = JSON.parse(functionCall.arguments); + return args.query || ""; + } catch { + return ""; + } +} + // Component to render tool calls function ToolCallRenderer({ tool, - toolOutput + toolOutputs, + relatedCall }: { tool: ConversationContent; - toolOutput?: ResponseFunctionToolCallOutputItem; + toolOutputs?: ToolOutputItem[]; + relatedCall?: ToolCallItem; }) { const [isExpanded, setIsExpanded] = useState(false); @@ -336,69 +878,46 @@ function ToolCallRenderer({ ); } - if (tool.type === "function_call") { - const functionCall = tool as ResponseFunctionToolCall; - - // Try to parse arguments to get query - let query = ""; - try { - const args = JSON.parse(functionCall.arguments); - query = args.query || ""; - } catch { - // Ignore parse errors - } - - // If we have a toolOutput, render them grouped together - if (toolOutput) { - const output = toolOutput.output || ""; - const preview = truncateMarkdownPreservingLinks(output, 150); - const hasMore = output.length > 150; - const isWebSearch = functionCall.name === "web_search"; - - // Web search specific rendering - if (isWebSearch) { - return ( -
- {/* Web search header with icon and query */} -
- - - {query ? `Searched: "${query}"` : "Web Search"} - -
- {/* Search result - indented to align with text, render as markdown for links */} -
- - {hasMore && ( - - )} -
-
- ); - } - - // Generic tool call rendering - we have output, so show completed state - const statusText = isWebSearch - ? query - ? `Searched for "${query}"` - : "Web search completed" - : `Tool "${functionCall.name}" completed`; + if (tool.type === "tool_call") { + const functionCall = tool as ToolCallItem; + const isWebSearch = functionCall.name === "web_search"; + const query = getToolCallQuery(functionCall); + const availableToolOutputs = toolOutputs ?? []; + const combinedOutput = availableToolOutputs + .map((toolOutput) => toolOutput.output || "") + .filter(Boolean) + .join("\n\n"); + const hasToolOutput = combinedOutput.length > 0; + const isCompleted = availableToolOutputs.some(toolOutputHasResult); + const isError = functionCall.status === "error"; + const isIncomplete = functionCall.status === "incomplete"; + const isFailed = isError || isIncomplete; + const isActive = !isCompleted && !isFailed; + + if (hasToolOutput) { + const preview = truncateMarkdownPreservingLinks(combinedOutput, 150); + const hasMore = combinedOutput.length > 150; return (
- {/* Tool call header */}
- - {statusText} + {isWebSearch ? ( + + ) : ( + + )} + + {isWebSearch + ? query + ? `Searched: "${query}"` + : "Web Search" + : query + ? `Ran "${functionCall.name}" for "${query}"` + : `Tool "${functionCall.name}" completed`} +
- {/* Tool output - indented, render as markdown for links */}
- + {hasMore && (