Working on tool call display

This commit is contained in:
Glen Maddern 2025-06-19 19:34:28 +10:00
commit 769475cecc
5 changed files with 280 additions and 73 deletions

View file

@ -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<ChatMessageProps> = ({
message,
}) => {
// Handle tool calls and results differently
if (message.role === "tool-call") {
return <ToolCallMessage message={message} />
}
if (message.role === "tool-result") {
return <ToolResultMessage message={message} />
}
// Handle regular messages (user, assistant, system)
return (
<div
className={`flex ${

View file

@ -0,0 +1,53 @@
import { useState } from 'react'
import { ChevronDown, ChevronRight, Wrench } from 'lucide-react'
import { type ToolCallMessage } from '../types'
interface ToolCallMessageProps {
message: ToolCallMessage
}
const ToolCallMessage: React.FC<ToolCallMessageProps> = ({ message }) => {
const [expanded, setExpanded] = useState(false)
const argsPreview = JSON.stringify(message.toolArgs).substring(0, 100)
const shouldTruncate = JSON.stringify(message.toolArgs).length > 100
return (
<div className="flex gap-3 py-3 px-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex-shrink-0 mt-1">
<Wrench size={16} className="text-blue-600" />
</div>
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-blue-800 text-sm">Tool Call</span>
<span className="text-blue-600 text-sm font-mono bg-blue-100 px-2 py-0.5 rounded">
{message.toolName}
</span>
</div>
<div
className={`text-sm text-blue-700 font-mono bg-blue-100 p-2 rounded ${shouldTruncate ? 'cursor-pointer hover:bg-blue-200' : ''}`}
onClick={() => shouldTruncate && setExpanded(!expanded)}
>
<div className="flex items-start justify-between">
<span className={expanded ? '' : 'truncate'}>
{expanded ? JSON.stringify(message.toolArgs, null, 2) : `${argsPreview}${shouldTruncate ? '...' : ''}`}
</span>
{shouldTruncate && (
<button className="ml-2 flex-shrink-0">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
)}
</div>
{expanded && shouldTruncate && (
<pre className="mt-2 whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{JSON.stringify(message.toolArgs, null, 2)}
</pre>
)}
</div>
</div>
</div>
)
}
export default ToolCallMessage

View file

@ -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<ToolResultMessageProps> = ({ message }) => {
const [expanded, setExpanded] = useState(false)
const resultPreview = JSON.stringify(message.toolResult).substring(0, 100)
const shouldTruncate = JSON.stringify(message.toolResult).length > 100
return (
<div className="flex gap-3 py-3 px-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex-shrink-0 mt-1">
<CheckCircle size={16} className="text-green-600" />
</div>
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-green-800 text-sm">Tool Result</span>
<span className="text-green-600 text-sm font-mono bg-green-100 px-2 py-0.5 rounded">
{message.toolName}
</span>
</div>
<div
className={`text-sm text-green-700 font-mono bg-green-100 p-2 rounded ${shouldTruncate ? 'cursor-pointer hover:bg-green-200' : ''}`}
onClick={() => shouldTruncate && setExpanded(!expanded)}
>
<div className="flex items-start justify-between">
<span className={expanded ? '' : 'truncate'}>
{expanded ? JSON.stringify(message.toolResult, null, 2) : `${resultPreview}${shouldTruncate ? '...' : ''}`}
</span>
{shouldTruncate && (
<button className="ml-2 flex-shrink-0">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
)}
</div>
{expanded && shouldTruncate && (
<pre className="mt-2 whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{JSON.stringify(message.toolResult, null, 2)}
</pre>
)}
</div>
</div>
</div>
)
}
export default ToolResultMessage

View file

@ -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<string>() // 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 <chat-title> tag
const titleMatch = aiResponse.match(/<chat-title>(.*?)<\/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>.*?<\/chat-title>/, '').trim()
}
//custom extraction of <chat-title> tag
const titleMatch = aiResponse.match(/<chat-title>(.*?)<\/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>.*?<\/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')

View file

@ -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<string, unknown>;
callId: string;
}
export interface ToolResultMessage {
role: "tool-result";
toolName: string;
toolArgs: Record<string, unknown>;
toolResult: any;
callId: string;
}
export type Message = UserMessage | SystemMessage | AssistantMessage | ToolCallMessage | ToolResultMessage;
export interface Conversation {
id?: number;
title: string;