diff --git a/examples/chat-ui/src/components/ChatMessage.tsx b/examples/chat-ui/src/components/ChatMessage.tsx index 6bcb3e3..b9984d9 100644 --- a/examples/chat-ui/src/components/ChatMessage.tsx +++ b/examples/chat-ui/src/components/ChatMessage.tsx @@ -3,6 +3,8 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; import { type Message } from "../types"; +import ToolCallMessage from "./ToolCallMessage"; +import ToolResultMessage from "./ToolResultMessage"; interface ChatMessageProps { message: Message; @@ -11,6 +13,16 @@ interface ChatMessageProps { const ChatMessage: React.FC = ({ message, }) => { + // Handle tool calls and results differently + if (message.role === "tool-call") { + return + } + + if (message.role === "tool-result") { + return + } + + // Handle regular messages (user, assistant, system) return (
= ({ message }) => { + const [expanded, setExpanded] = useState(false) + + const argsPreview = JSON.stringify(message.toolArgs).substring(0, 100) + const shouldTruncate = JSON.stringify(message.toolArgs).length > 100 + + return ( +
+
+ +
+
+
+ Tool Call + + {message.toolName} + +
+ +
shouldTruncate && setExpanded(!expanded)} + > +
+ + {expanded ? JSON.stringify(message.toolArgs, null, 2) : `${argsPreview}${shouldTruncate ? '...' : ''}`} + + {shouldTruncate && ( + + )} +
+ {expanded && shouldTruncate && ( +
+              {JSON.stringify(message.toolArgs, null, 2)}
+            
+ )} +
+
+
+ ) +} + +export default ToolCallMessage diff --git a/examples/chat-ui/src/components/ToolResultMessage.tsx b/examples/chat-ui/src/components/ToolResultMessage.tsx new file mode 100644 index 0000000..aa6496a --- /dev/null +++ b/examples/chat-ui/src/components/ToolResultMessage.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react' +import { ChevronDown, ChevronRight, CheckCircle } from 'lucide-react' +import { type ToolResultMessage } from '../types' + +interface ToolResultMessageProps { + message: ToolResultMessage +} + +const ToolResultMessage: React.FC = ({ message }) => { + const [expanded, setExpanded] = useState(false) + + const resultPreview = JSON.stringify(message.toolResult).substring(0, 100) + const shouldTruncate = JSON.stringify(message.toolResult).length > 100 + + return ( +
+
+ +
+
+
+ Tool Result + + {message.toolName} + +
+ +
shouldTruncate && setExpanded(!expanded)} + > +
+ + {expanded ? JSON.stringify(message.toolResult, null, 2) : `${resultPreview}${shouldTruncate ? '...' : ''}`} + + {shouldTruncate && ( + + )} +
+ {expanded && shouldTruncate && ( +
+              {JSON.stringify(message.toolResult, null, 2)}
+            
+ )} +
+
+
+ ) +} + +export default ToolResultMessage diff --git a/examples/chat-ui/src/hooks/useStreamResponse.ts b/examples/chat-ui/src/hooks/useStreamResponse.ts index eede3bd..4dac310 100644 --- a/examples/chat-ui/src/hooks/useStreamResponse.ts +++ b/examples/chat-ui/src/hooks/useStreamResponse.ts @@ -1,12 +1,23 @@ -import { useRef, useState } from 'react' -import { jsonSchema, streamText, tool } from 'ai' +import { useState, useRef } from 'react' +import { streamText, tool, jsonSchema } from 'ai' import { createGroq } from '@ai-sdk/groq' import { createAnthropic } from '@ai-sdk/anthropic' -import { type Conversation, type Message } from '../types' +import { type Message, type Conversation, type UserMessage, type AssistantMessage, type SystemMessage } from '../types' import { type Model } from '../types/models' import { getApiKey } from '../utils/apiKeys' import { type Tool } from 'use-mcp/react' -import { debugLog } from '../utils/debugLog.ts' + +// Debug logging helper +const debugLog = (...args: any[]) => { + if (typeof window !== 'undefined' && localStorage.getItem('USE_MCP_DEBUG') === 'true') { + console.log(...args) + } +} + +// Type guard for messages with content +const hasContent = (message: Message): message is UserMessage | AssistantMessage | SystemMessage => { + return 'content' in message +} interface UseStreamResponseProps { conversationId?: number @@ -132,98 +143,148 @@ export const useStreamResponse = ({ const aiTools = convertMcpToolsToAiTools(mcpTools) debugLog(`[useStreamResponse] Starting streamText with ${Object.keys(aiTools).length} tools available`) - debugLog(`[useStreamResponse] Messages:`, messages.map(m => ({ role: m.role, content: m.content.substring(0, 100) + '...' }))) + debugLog(`[useStreamResponse] Messages:`, messages.map(m => ({ + role: m.role, + content: hasContent(m) ? m.content.substring(0, 100) + '...' : 'no content' + }))) const result = await streamText({ model: modelInstance, - messages: messages.map((msg) => ({ - role: msg.role, - content: msg.content, - })), + messages: messages.filter(msg => + ['user', 'assistant', 'system'].includes(msg.role) && hasContent(msg) + ).map((msg) => { + const contentMsg = msg as UserMessage | AssistantMessage | SystemMessage + return { + role: contentMsg.role, + content: contentMsg.content, + } + }), tools: Object.keys(aiTools).length > 0 ? aiTools : undefined, maxSteps: 5, // Allow up to 5 steps for tool calling - onStepFinish({ text, toolCalls, toolResults, finishReason, usage }) { - debugLog(`[useStreamResponse] Step finished:`, { - text: text ? text.substring(0, 100) + '...' : 'no text', - toolCallsCount: toolCalls?.length || 0, - toolResultsCount: toolResults?.length || 0, - finishReason, - usage - }) - if (toolCalls && toolCalls.length > 0) { - debugLog(`[useStreamResponse] Tool calls in step:`, toolCalls) - } - if (toolResults && toolResults.length > 0) { - debugLog(`[useStreamResponse] Tool results in step:`, toolResults) - } - }, abortSignal: controller.signal, }) - debugLog(`[useStreamResponse] streamText result obtained, starting to process stream`) - debugLog(`[useStreamResponse] Result object:`, { - hasTextStream: !!result.textStream, - hasToolCalls: !!result.toolCalls, - hasToolResults: !!result.toolResults, - hasFullStream: !!result.fullStream - }) + debugLog(`[useStreamResponse] streamText result obtained, starting to process fullStream`) - // Log tool calls if they exist - if (result.toolCalls) { - debugLog(`[useStreamResponse] Tool calls detected:`, result.toolCalls) - } - - // Log tool results if they exist - if (result.toolResults) { - debugLog(`[useStreamResponse] Tool results detected:`, result.toolResults) - } - - let chunkCount = 0 - for await (const chunk of result.textStream) { + // Use fullStream to get all events including tool calls, results, and text + const processedEvents = new Set() // Track processed events to avoid duplicates + + for await (const event of result.fullStream) { try { - chunkCount++ - debugLog(`[useStreamResponse] Processing chunk ${chunkCount}:`, JSON.stringify(chunk)) + debugLog(`[useStreamResponse] Full stream event:`, event.type, event) - aiResponse += chunk - aiResponseRef.current = aiResponse + if (event.type === 'tool-call') { + const eventKey = `tool-call-${event.toolCallId}` + if (processedEvents.has(eventKey)) { + debugLog(`[useStreamResponse] Skipping duplicate tool call:`, eventKey) + continue + } + processedEvents.add(eventKey) + + debugLog(`[useStreamResponse] Tool call event:`, event) + if (event.toolName) { + setConversations((prev) => { + const updated = [...prev] + const conv = updated.find((c) => c.id === conversationId) + if (conv) { + // Check if this tool call already exists + const existingToolCall = conv.messages.find( + (msg) => msg.role === 'tool-call' && 'callId' in msg && msg.callId === event.toolCallId + ) + if (!existingToolCall) { + conv.messages.push({ + role: 'tool-call', + toolName: event.toolName, + toolArgs: event.args || {}, + callId: event.toolCallId || 'unknown', + }) + } + } + return updated + }) + scrollToBottom() + } else { + console.warn('[useStreamResponse] Tool call event missing toolName:', event) + } + } else if (event.type === 'tool-result') { + const eventKey = `tool-result-${event.toolCallId}` + if (processedEvents.has(eventKey)) { + debugLog(`[useStreamResponse] Skipping duplicate tool result:`, eventKey) + continue + } + processedEvents.add(eventKey) + + debugLog(`[useStreamResponse] Tool result event:`, event) + if (event.toolName) { + setConversations((prev) => { + const updated = [...prev] + const conv = updated.find((c) => c.id === conversationId) + if (conv) { + // Check if this tool result already exists + const existingToolResult = conv.messages.find( + (msg) => msg.role === 'tool-result' && 'callId' in msg && msg.callId === event.toolCallId + ) + if (!existingToolResult) { + conv.messages.push({ + role: 'tool-result', + toolName: event.toolName, + toolArgs: event.args || {}, + toolResult: event.result, + callId: event.toolCallId || 'unknown', + }) + } + } + return updated + }) + scrollToBottom() + } else { + console.warn('[useStreamResponse] Tool result event missing toolName:', event) + } + } else if (event.type === 'text-delta') { + debugLog(`[useStreamResponse] Text delta:`, event.textDelta) + + aiResponse += event.textDelta + aiResponseRef.current = aiResponse - debugLog(`[useStreamResponse] Updated aiResponse (length: ${aiResponse.length}):`, aiResponse.substring(0, 200) + '...') + //custom extraction of tag + const titleMatch = aiResponse.match(/(.*?)<\/chat-title>/) + if (titleMatch) { + const title = titleMatch[1].trim() + debugLog(`[useStreamResponse] Extracted title: ${title}`) + setConversations((prev) => { + const updated = [...prev] + const conv = updated.find((c) => c.id === conversationId) + if (conv) { + conv.title = title + } + return updated + }) + aiResponse = aiResponse.replace(/.*?<\/chat-title>/, '').trim() + } - //custom extraction of tag - const titleMatch = aiResponse.match(/(.*?)<\/chat-title>/) - if (titleMatch) { - const title = titleMatch[1].trim() - debugLog(`[useStreamResponse] Extracted title: ${title}`) setConversations((prev) => { const updated = [...prev] const conv = updated.find((c) => c.id === conversationId) if (conv) { - conv.title = title + debugLog(`[useStreamResponse] Updating conversation message content (length: ${aiResponse.length})`) + const lastMessage = conv.messages[conv.messages.length - 1] + if (hasContent(lastMessage)) { + lastMessage.content = aiResponse + } + } else { + debugLog(`[useStreamResponse] Could not find conversation with id: ${conversationId}`) } return updated }) - aiResponse = aiResponse.replace(/.*?<\/chat-title>/, '').trim() + + scrollToBottom() } - - setConversations((prev) => { - const updated = [...prev] - const conv = updated.find((c) => c.id === conversationId) - if (conv) { - debugLog(`[useStreamResponse] Updating conversation message content (length: ${aiResponse.length})`) - conv.messages[conv.messages.length - 1].content = aiResponse - } else { - debugLog(`[useStreamResponse] Could not find conversation with id: ${conversationId}`) - } - return updated - }) - - scrollToBottom() } catch (e) { - console.error('[useStreamResponse] Error in text chunk processing:', e) + console.error('[useStreamResponse] Error in full stream processing:', e) } } - debugLog(`[useStreamResponse] Finished processing ${chunkCount} chunks. Final response length: ${aiResponse.length}`) + debugLog(`[useStreamResponse] Finished processing full stream. Final response length: ${aiResponse.length}`) } catch (error: unknown) { if (controller.signal.aborted) { debugLog('[useStreamResponse] Stream aborted') diff --git a/examples/chat-ui/src/types/index.ts b/examples/chat-ui/src/types/index.ts index e0e4f98..79cbe7c 100644 --- a/examples/chat-ui/src/types/index.ts +++ b/examples/chat-ui/src/types/index.ts @@ -1,8 +1,36 @@ -export interface Message { - role: "user" | "system" | "assistant"; +export interface BaseMessage { content: string; } +export interface UserMessage extends BaseMessage { + role: "user"; +} + +export interface SystemMessage extends BaseMessage { + role: "system"; +} + +export interface AssistantMessage extends BaseMessage { + role: "assistant"; +} + +export interface ToolCallMessage { + role: "tool-call"; + toolName: string; + toolArgs: Record; + callId: string; +} + +export interface ToolResultMessage { + role: "tool-result"; + toolName: string; + toolArgs: Record; + toolResult: any; + callId: string; +} + +export type Message = UserMessage | SystemMessage | AssistantMessage | ToolCallMessage | ToolResultMessage; + export interface Conversation { id?: number; title: string;