From ba845e78524d8769e901d589d498370b1fb2d352 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Wed, 25 Jun 2025 19:01:30 +1000 Subject: [PATCH] Big sigh, finally getting control of my markup --- .../chat-ui/src/components/ChatMessage.tsx | 64 ---- .../src/components/ConversationThread.tsx | 41 +-- .../components/messages/AssistantMessage.tsx | 31 ++ .../src/components/messages/ChatMessage.tsx | 53 +++ .../{ => messages}/ReasoningBlock.tsx | 0 .../components/messages/ReasoningMessage.tsx | 25 ++ .../{ => messages}/ToolCallMessage.tsx | 2 +- .../{ => messages}/ToolResultMessage.tsx | 2 +- .../chat-ui/src/hooks/useStreamResponse.ts | 313 ++++++++---------- examples/chat-ui/src/types/index.ts | 4 +- 10 files changed, 252 insertions(+), 283 deletions(-) delete mode 100644 examples/chat-ui/src/components/ChatMessage.tsx create mode 100644 examples/chat-ui/src/components/messages/AssistantMessage.tsx create mode 100644 examples/chat-ui/src/components/messages/ChatMessage.tsx rename examples/chat-ui/src/components/{ => messages}/ReasoningBlock.tsx (100%) create mode 100644 examples/chat-ui/src/components/messages/ReasoningMessage.tsx rename examples/chat-ui/src/components/{ => messages}/ToolCallMessage.tsx (97%) rename examples/chat-ui/src/components/{ => messages}/ToolResultMessage.tsx (97%) diff --git a/examples/chat-ui/src/components/ChatMessage.tsx b/examples/chat-ui/src/components/ChatMessage.tsx deleted file mode 100644 index 4ea473b..0000000 --- a/examples/chat-ui/src/components/ChatMessage.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react' -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' -import ReasoningBlock from './ReasoningBlock' - -interface ChatMessageProps { - message: Message -} - -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 ( -
-
- {/* Show reasoning block for assistant messages with reasoning */} - {message.role === 'assistant' && 'reasoning' in message && (message.reasoning || message.isReasoningStreaming) && ( - - )} - - {/* Only render content div if there's actual content */} - {message.content && message.content.trim() && ( -
-
{children}
, - }} - > - {message.content} -
-
- )} -
-
- ) -} - -export default ChatMessage diff --git a/examples/chat-ui/src/components/ConversationThread.tsx b/examples/chat-ui/src/components/ConversationThread.tsx index 424440d..1912abf 100644 --- a/examples/chat-ui/src/components/ConversationThread.tsx +++ b/examples/chat-ui/src/components/ConversationThread.tsx @@ -6,7 +6,7 @@ import { type Conversation, type Message } from '../types' import { type Model } from '../types/models' import { type IDBPDatabase } from 'idb' import { type Tool } from 'use-mcp/react' -import ChatMessage from './ChatMessage' +import ChatMessage from './messages/ChatMessage.tsx' import ChatInput from './ChatInput' import ModelSelectionModal from './ModelSelectionModal' import McpServerModal from './McpServerModal' @@ -180,43 +180,12 @@ const ConversationThread: React.FC = ({ >
{currentConversation.messages.length === 0 ? ( -
- {/* Title and description text hidden but kept in codebase */} - {false && ( - <> -

What do you want to know?

-
-

- AI chat template built with React, Vite and Cloudflare Workers AI. -
- Find the source code on{' '} - - GitHub - - . -
-

-
- - )} -
+
{/*

use-mcp

*/}
) : (
- {currentConversation.messages - .filter((message) => { - // Filter out empty assistant messages (no content and no reasoning) - if ( - message.role === 'assistant' && - (!message.content || !message.content.trim()) && - !('reasoning' in message && (message.reasoning || message.isReasoningStreaming)) - ) { - return false - } - return true - }) - .map((message, index) => ( - - ))} + {currentConversation.messages.map((message, index) => ( + + ))} {isLoading && !streamStarted &&
Thinking...
}
diff --git a/examples/chat-ui/src/components/messages/AssistantMessage.tsx b/examples/chat-ui/src/components/messages/AssistantMessage.tsx new file mode 100644 index 0000000..ccddfdb --- /dev/null +++ b/examples/chat-ui/src/components/messages/AssistantMessage.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { type AssistantMessage } from '../../types' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeHighlight from 'rehype-highlight' + +interface AssistantMessageProps { + message: AssistantMessage +} + +const AssistantMessage: React.FC = ({ message }) => { + return ( +
+
+
+
{children}
, + }} + > + {message.content} +
+
+
+
+ ) +} + +export default AssistantMessage diff --git a/examples/chat-ui/src/components/messages/ChatMessage.tsx b/examples/chat-ui/src/components/messages/ChatMessage.tsx new file mode 100644 index 0000000..cc459ec --- /dev/null +++ b/examples/chat-ui/src/components/messages/ChatMessage.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeHighlight from 'rehype-highlight' +import { type Message } from '../../types' +import ToolCallMessage from './ToolCallMessage.tsx' +import ToolResultMessage from './ToolResultMessage.tsx' +import AssistantMessage from './AssistantMessage.tsx' +import ReasoningMessage from './ReasoningMessage.tsx' + +interface ChatMessageProps { + message: Message +} + +const ChatMessage: React.FC = ({ message }) => { + // Handle tool calls and results differently + if (message.role === 'tool-call') { + return + } + + if (message.role === 'tool-result') { + return + } + + if (message.role === 'assistant') { + if (message.type === 'reasoning') { + return + } else { + return + } + } + + // Handle regular messages (user, assistant, system) + return ( +
+
+
+
{children}
, + }} + > + {message.content} +
+
+
+
+ ) +} + +export default ChatMessage diff --git a/examples/chat-ui/src/components/ReasoningBlock.tsx b/examples/chat-ui/src/components/messages/ReasoningBlock.tsx similarity index 100% rename from examples/chat-ui/src/components/ReasoningBlock.tsx rename to examples/chat-ui/src/components/messages/ReasoningBlock.tsx diff --git a/examples/chat-ui/src/components/messages/ReasoningMessage.tsx b/examples/chat-ui/src/components/messages/ReasoningMessage.tsx new file mode 100644 index 0000000..7dae8d0 --- /dev/null +++ b/examples/chat-ui/src/components/messages/ReasoningMessage.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { type ReasoningMessage } from '../../types' +import ReasoningBlock from './ReasoningBlock.tsx' + +interface ReasoningMessageProps { + message: ReasoningMessage +} + +const ReasoningMessage: React.FC = ({ message }) => { + console.log({ ReasoningMessage: message }) + return ( +
+
+ +
+
+ ) +} + +export default ReasoningMessage diff --git a/examples/chat-ui/src/components/ToolCallMessage.tsx b/examples/chat-ui/src/components/messages/ToolCallMessage.tsx similarity index 97% rename from examples/chat-ui/src/components/ToolCallMessage.tsx rename to examples/chat-ui/src/components/messages/ToolCallMessage.tsx index ad545ad..0c66642 100644 --- a/examples/chat-ui/src/components/ToolCallMessage.tsx +++ b/examples/chat-ui/src/components/messages/ToolCallMessage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { ChevronDown, ChevronRight, Wrench } from 'lucide-react' -import { type ToolCallMessage } from '../types' +import { type ToolCallMessage } from '../../types' interface ToolCallMessageProps { message: ToolCallMessage diff --git a/examples/chat-ui/src/components/ToolResultMessage.tsx b/examples/chat-ui/src/components/messages/ToolResultMessage.tsx similarity index 97% rename from examples/chat-ui/src/components/ToolResultMessage.tsx rename to examples/chat-ui/src/components/messages/ToolResultMessage.tsx index 1d6e8e5..484ce3a 100644 --- a/examples/chat-ui/src/components/ToolResultMessage.tsx +++ b/examples/chat-ui/src/components/messages/ToolResultMessage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { ChevronDown, ChevronRight, CheckCircle } from 'lucide-react' -import { type ToolResultMessage } from '../types' +import { type ToolResultMessage } from '../../types' interface ToolResultMessageProps { message: ToolResultMessage diff --git a/examples/chat-ui/src/hooks/useStreamResponse.ts b/examples/chat-ui/src/hooks/useStreamResponse.ts index 0eecb56..a4d81ff 100644 --- a/examples/chat-ui/src/hooks/useStreamResponse.ts +++ b/examples/chat-ui/src/hooks/useStreamResponse.ts @@ -113,9 +113,6 @@ export const useStreamResponse = ({ } const streamResponse = async (messages: Message[]) => { - let aiResponse = '' - let currentReasoning = '' - // Check if API key is available let apiKey = getApiKey(selectedModel.provider.id) if (!apiKey) { @@ -138,7 +135,7 @@ export const useStreamResponse = ({ const aiTools = convertMcpToolsToAiTools(mcpTools) // Prepare stream options - const streamOptions: any = { + const streamOptions = { model: modelInstance, messages: messages .filter((msg) => ['user', 'assistant', 'system'].includes(msg.role) && hasContent(msg)) @@ -198,155 +195,127 @@ export const useStreamResponse = ({ } if (event.type === 'tool-call') { - // End reasoning phase immediately when we see a tool call for linear flow - if (currentReasoningStartTime && !currentReasoningEndTime) { - currentReasoningEndTime = Date.now() - - // Update reasoning end time and stop streaming immediately - setConversations((prev) => { - const updated = [...prev] - const conv = updated.find((c) => c.id === conversationId) - if (conv && assistantMessageIndex >= 0) { - const assistantMessage = conv.messages[assistantMessageIndex] - if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') { - assistantMessage.reasoningEndTime = currentReasoningEndTime - assistantMessage.isReasoningStreaming = false - } - } - return updated - }) - - // Reset reasoning state for potential new reasoning session - currentReasoningStartTime = undefined - currentReasoningEndTime = undefined - assistantMessageCreated = false - } - if (event.toolName) { - setConversations((prev) => { - const updated = [...prev] - const conv = updated.find((c) => c.id === conversationId) - if (conv) { - conv.messages.push({ - role: 'tool-call', - toolName: event.toolName, - toolArgs: event.args || {}, - callId: event.toolCallId || 'unknown', - }) - debugMessages('Added new tool-call message:', event.toolName, 'callId:', event.toolCallId) - debugMessages('Current messages:', JSON.stringify(conv.messages)) - } - return updated + conv.messages.push({ + role: 'tool-call', + toolName: event.toolName, + toolArgs: event.args || {}, + callId: event.toolCallId || 'unknown', }) + debugMessages('Added new tool-call message:', event.toolName, 'callId:', event.toolCallId) + debugMessages('Current messages:', JSON.stringify(conv.messages)) scrollToBottom(true) } else { console.warn('Tool call event missing toolName:', event) } - } else if ((event as any).type === 'tool-result') { - if ((event as any).toolName && (event as any).toolCallId) { - setConversations((prev) => { - const updated = [...prev] - const conv = updated.find((c) => c.id === conversationId) - if (conv) { - conv.messages.push({ - role: 'tool-result', - toolName: (event as any).toolName, - toolArgs: (event as any).args || {}, - toolResult: (event as any).result, - callId: (event as any).toolCallId, - }) - debugMessages('Added new tool-result message:', (event as any).toolName, 'callId:', (event as any).toolCallId) - debugMessages('Current messages:', JSON.stringify(conv.messages)) - } - return updated - }) - scrollToBottom(true) - } else { - console.warn('Tool result event missing toolName or toolCallId:', event) - } + } else if (event.type === 'tool-result') { + console.info(`TODO TOOL RESULT`, event) + // if (event.toolName && event.toolCallId) { + // setConversations((prev) => { + // const updated = [...prev] + // const conv = updated.find((c) => c.id === conversationId) + // if (conv) { + // conv.messages.push({ + // role: 'tool-result', + // toolName: event.toolName, + // toolArgs: event.args || {}, + // toolResult: event.result, + // callId: event.toolCallId, + // }) + // debugMessages('Added new tool-result message:', event.toolName, 'callId:', event.toolCallId) + // debugMessages('Current messages:', JSON.stringify(conv.messages)) + // } + // return updated + // }) + // scrollToBottom(true) + // } else { + // console.warn('Tool result event missing toolName or toolCallId:', event) + // } } else if (event.type === 'text-delta') { - // Check if we have an existing reasoning message that needs to be finalized - let needsNewAssistantMessage = false - if (currentReasoningStartTime && !currentReasoningEndTime) { - currentReasoningEndTime = Date.now() + console.info(`TODO TEXT DELTA`, event) - // Update reasoning end time and stop streaming - setConversations((prev) => { - const updated = [...prev] - const conv = updated.find((c) => c.id === conversationId) - if (conv && assistantMessageIndex >= 0) { - const assistantMessage = conv.messages[assistantMessageIndex] - if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') { - assistantMessage.isReasoningStreaming = false - assistantMessage.reasoningEndTime = currentReasoningEndTime - } - } - return updated - }) - - // Reset reasoning state after reasoning ends - currentReasoningStartTime = undefined - currentReasoningEndTime = undefined - // Force creation of new assistant message for content - needsNewAssistantMessage = true - assistantMessageCreated = false - } - - // Create assistant message on first text delta if not already created OR if we just finished reasoning - if (!assistantMessageCreated || needsNewAssistantMessage) { - setConversations((prev) => { - const updated = [...prev] - const conv = updated.find((c) => c.id === conversationId) - if (conv) { - conv.messages.push({ role: 'assistant', content: '' }) - assistantMessageIndex = conv.messages.length - 1 - debugMessages( - 'Added new content assistant message at index', - assistantMessageIndex, - '(after reasoning:', - needsNewAssistantMessage, - ')' - ) - debugMessages('Current messages:', JSON.stringify(conv.messages)) - } - return updated - }) - assistantMessageCreated = true - } - - aiResponse += event.textDelta - aiResponseRef.current = aiResponse - - //custom extraction of tag - const titleMatch = aiResponse.match(/(.*?)<\/chat-title>/) - if (titleMatch) { - const title = titleMatch[1].trim() - 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() - } - - setConversations((prev) => { - const updated = [...prev] - const conv = updated.find((c) => c.id === conversationId) - if (conv) { - // Update the specific assistant message we created, not just the last message - const assistantMessage = conv.messages[assistantMessageIndex] - if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') { - assistantMessage.content = aiResponse - } - } else { - } - return updated - }) - - scrollToBottom(true) + // // Check if we have an existing reasoning message that needs to be finalized + // let needsNewAssistantMessage = false + // if (currentReasoningStartTime && !currentReasoningEndTime) { + // currentReasoningEndTime = Date.now() + // + // // Update reasoning end time and stop streaming + // setConversations((prev) => { + // const updated = [...prev] + // const conv = updated.find((c) => c.id === conversationId) + // if (conv && assistantMessageIndex >= 0) { + // const assistantMessage = conv.messages[assistantMessageIndex] + // if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') { + // assistantMessage.isReasoningStreaming = false + // assistantMessage.reasoningEndTime = currentReasoningEndTime + // } + // } + // return updated + // }) + // + // // Reset reasoning state after reasoning ends + // currentReasoningStartTime = undefined + // currentReasoningEndTime = undefined + // // Force creation of new assistant message for content + // needsNewAssistantMessage = true + // assistantMessageCreated = false + // } + // + // // Create assistant message on first text delta if not already created OR if we just finished reasoning + // if (!assistantMessageCreated || needsNewAssistantMessage) { + // setConversations((prev) => { + // const updated = [...prev] + // const conv = updated.find((c) => c.id === conversationId) + // if (conv) { + // conv.messages.push({ role: 'assistant', content: '' }) + // assistantMessageIndex = conv.messages.length - 1 + // debugMessages( + // 'Added new content assistant message at index', + // assistantMessageIndex, + // '(after reasoning:', + // needsNewAssistantMessage, + // ')' + // ) + // debugMessages('Current messages:', JSON.stringify(conv.messages)) + // } + // return updated + // }) + // assistantMessageCreated = true + // } + // + // aiResponse += event.textDelta + // aiResponseRef.current = aiResponse + // + // //custom extraction of tag + // const titleMatch = aiResponse.match(/(.*?)<\/chat-title>/) + // if (titleMatch) { + // const title = titleMatch[1].trim() + // 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() + // } + // + // setConversations((prev) => { + // const updated = [...prev] + // const conv = updated.find((c) => c.id === conversationId) + // if (conv) { + // // Update the specific assistant message we created, not just the last message + // const assistantMessage = conv.messages[assistantMessageIndex] + // if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') { + // assistantMessage.content = aiResponse + // } + // } else { + // } + // return updated + // }) + // + // scrollToBottom(true) } } catch (e) { console.error('Error in full stream processing:', e) @@ -355,8 +324,6 @@ export const useStreamResponse = ({ }) } - debugMessages('Stream processing completed. Final message count:', assistantMessageCreated ? 'created' : 'none') - // Show final conversation state setConversations((prev) => { const conv = prev.find((c) => c.id === conversationId) @@ -365,48 +332,36 @@ export const useStreamResponse = ({ } return prev }) - - // Final cleanup - ensure reasoning streaming is stopped - if (assistantMessageCreated && currentReasoningStartTime) { - if (!currentReasoningEndTime) { - currentReasoningEndTime = Date.now() - } - - setConversations((prev) => { - const updated = [...prev] - const conv = updated.find((c) => c.id === conversationId) - if (conv && assistantMessageIndex >= 0) { - const assistantMessage = conv.messages[assistantMessageIndex] - if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') { - assistantMessage.isReasoningStreaming = false - assistantMessage.reasoningEndTime = currentReasoningEndTime - } - } - return updated - }) - } + // + // // Final cleanup - ensure reasoning streaming is stopped + // if (assistantMessageCreated && currentReasoningStartTime) { + // if (!currentReasoningEndTime) { + // currentReasoningEndTime = Date.now() + // } + // + // setConversations((prev) => { + // const updated = [...prev] + // const conv = updated.find((c) => c.id === conversationId) + // if (conv && assistantMessageIndex >= 0) { + // const assistantMessage = conv.messages[assistantMessageIndex] + // if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') { + // assistantMessage.isReasoningStreaming = false + // assistantMessage.reasoningEndTime = currentReasoningEndTime + // } + // } + // return updated + // }) + // } } catch (error: unknown) { if (controller.signal.aborted) { } else { console.error('Error generating response:', error) - // Remove the assistant message if there was an error and we created one - if (assistantMessageCreated) { - setConversations((prev) => { - const updated = [...prev] - const conv = updated.find((c) => c.id === conversationId) - if (conv && assistantMessageIndex >= 0 && conv.messages[assistantMessageIndex]?.role === 'assistant') { - conv.messages.splice(assistantMessageIndex, 1) - } - return updated - }) - } } } finally { setStreamStarted(false) setController(new AbortController()) debugMessages('Stream response function completed') } - return aiResponse } return { diff --git a/examples/chat-ui/src/types/index.ts b/examples/chat-ui/src/types/index.ts index 3ad1abb..023c17a 100644 --- a/examples/chat-ui/src/types/index.ts +++ b/examples/chat-ui/src/types/index.ts @@ -15,7 +15,7 @@ export interface AssistantMessage extends BaseMessage { type: 'content' } -export interface AssistantReasoningMessage extends BaseMessage { +export interface ReasoningMessage extends BaseMessage { role: 'assistant' type: 'reasoning' reasoningStartTime: number @@ -38,7 +38,7 @@ export interface ToolResultMessage { callId: string } -export type Message = UserMessage | SystemMessage | AssistantMessage | AssistantReasoningMessage | ToolCallMessage | ToolResultMessage +export type Message = UserMessage | SystemMessage | AssistantMessage | ReasoningMessage | ToolCallMessage | ToolResultMessage export interface Conversation { id?: number