Working on tool call display
This commit is contained in:
parent
d25f6c19a3
commit
769475cecc
5 changed files with 280 additions and 73 deletions
|
|
@ -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 ${
|
||||
|
|
|
|||
53
examples/chat-ui/src/components/ToolCallMessage.tsx
Normal file
53
examples/chat-ui/src/components/ToolCallMessage.tsx
Normal 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
|
||||
53
examples/chat-ui/src/components/ToolResultMessage.tsx
Normal file
53
examples/chat-ui/src/components/ToolResultMessage.tsx
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue