diff --git a/.gitignore b/.gitignore index dba0c85..f1164d6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ DEPLOYMENT.md # Editor directories and files !.vscode/extensions.json .idea +.claude .DS_Store *.suo *.ntvs* diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index e6a0c97..20b4e2b 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -8,23 +8,18 @@ import { GeminiProvider } from '../agent/llm/GeminiProvider'; import { LLMProvider } from '../agent/llm/LLMProvider'; import { ConfigManager } from '../core/config/ConfigManager'; import { useProjectStore } from '../stores/projectStore'; -import { XMLToolExecutor } from '../agent/core/XMLToolExecutor'; -import { extractXMLFromString } from '../util/xmlUtil'; import { SystemPrompts } from '../agent/core/SystemPrompts'; import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chatUtil'; import { processUserMessage } from '../util/messageFilter/UserMessageFilter'; +import { useStreamProcessor } from '../hooks/useStreamProcessor'; +import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils'; +import { extractActionableTools, executeAllTools } from '../utils/toolExecutionUtils'; + +import type { ChatMessage } from '../types/projectTypes'; // Module-level guard to avoid duplicate welcome in React StrictMode dev remounts let hasShownWelcomeOnceInRuntime = false; -interface ChatMessage { - id: string; - role: 'user' | 'assistant'; - content: string; - isStreaming?: boolean; - tokenCount?: number; -} - /** * Create the appropriate LLM provider based on configuration */ @@ -55,7 +50,6 @@ const ChatBox: React.FC = ({ isVisible }) => { // Initialize with empty messages const [messages, setMessages] = useState([]); const [isProcessing, setIsProcessing] = useState(false); - const [abortController, setAbortController] = useState(null); const [lastUserMessage, setLastUserMessage] = useState(''); // Tool execution state @@ -66,9 +60,25 @@ const ChatBox: React.FC = ({ isVisible }) => { // Track if this is the first message (for system prompt logging) const [isFirstMessage, setIsFirstMessage] = useState(true); - const generateMessageId = (): string => { - return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; - }; + // Message update callbacks for stream processor + const handleMessageUpdate = useCallback((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => { + setMessages(prev => prev.map(msg => msg.id === messageId ? updater(msg) : msg)); + }, []); + + const handleMessageAdd = useCallback((message: ChatMessage) => { + setMessages(prev => [...prev, message]); + }, []); + + const handleProcessingChange = useCallback((processing: boolean) => { + setIsProcessing(processing); + }, []); + + // Stream processor hook + const streamProcessor = useStreamProcessor({ + onMessageUpdate: handleMessageUpdate, + onMessageAdd: handleMessageAdd, + onProcessingChange: handleProcessingChange + }); const clearChatUI = useCallback(async () => { // Clear UI state @@ -78,18 +88,9 @@ const ChatBox: React.FC = ({ isVisible }) => { setIsFirstMessage(true); // Auto-show welcome message after clearing (like on app startup) - try { - const result = await processUserMessage('/welcome'); - if (result.pseudoAssistantResponse) { - const pseudoId = generateMessageId(); - setMessages(prev => [...prev, { - id: pseudoId, - role: 'assistant', - content: result.pseudoAssistantResponse!, - }]); - } - } catch { - // ignore errors, just don't show welcome if it fails + const welcomeMessage = await addWelcomeMessage(); + if (welcomeMessage) { + setMessages([welcomeMessage]); } }, []); @@ -117,29 +118,20 @@ const ChatBox: React.FC = ({ isVisible }) => { // Auto-trigger welcome on first launch (guard against React StrictMode double-invoke only) (async () => { - try { - if (hasShownWelcomeOnceInRuntime) return; - hasShownWelcomeOnceInRuntime = true; + if (hasShownWelcomeOnceInRuntime) return; + hasShownWelcomeOnceInRuntime = true; - const result = await processUserMessage('/welcome'); - if (result.pseudoAssistantResponse) { - const pseudoId = generateMessageId(); - setMessages(prev => [...prev, { - id: pseudoId, - role: 'assistant', - content: result.pseudoAssistantResponse!, - }]); - } - } catch { - // ignore + const welcomeMessage = await addWelcomeMessage(); + if (welcomeMessage) { + setMessages([welcomeMessage]); } })(); }, [clearChatUI]); const handleAbort = () => { - if (abortController) { - abortController.abort(); - setAbortController(null); + const controller = streamProcessor.abortController; + if (controller) { + controller.abort(); // Use AgentCore to clean up the data model and get the user message content const agentCore = AgentCore.instance(); @@ -160,28 +152,9 @@ const ChatBox: React.FC = ({ isVisible }) => { clearChatHistoryAndUI(setStatus); }; - - const addToolResultMessage = (toolName: string, success: boolean, result: string) => { - const toolMsgId = generateMessageId(); - const friendlyDisplay = `${success ? '✅' : '❌'} __**${toolName}**__ \n\n └── ${result}`; - - setMessages(prev => [...prev, { - id: toolMsgId, - role: 'user', - content: friendlyDisplay - }]); - }; - const executeToolsFromResponse = async (response: string): Promise => { try { - // Check if response contains XML tool invocations - const xmlBlocks = extractXMLFromString(response); - // Consider only actionable tools (exclude think/thinking) - const actionableBlocks = xmlBlocks.filter((block) => { - const match = block.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/); - const name = match ? match[1].toLowerCase() : ''; - return name !== 'think' && name !== 'thinking'; - }); + const actionableBlocks = extractActionableTools(response); if (actionableBlocks.length === 0) { // No actionable tools to execute, stop the loop @@ -194,46 +167,12 @@ const ChatBox: React.FC = ({ isVisible }) => { setCurrentToolIndex(0); const { setStatus } = useProjectStore.getState(); - setStatus(`Executing ${actionableBlocks.length} tool(s)...`); - - const executor = XMLToolExecutor.instance(); - let accumulatedResults = ''; - - // Execute tools sequentially with real-time updates - for (let i = 0; i < actionableBlocks.length; i++) { - setCurrentToolIndex(i + 1); - setStatus(`Executing tool ${i + 1} of ${actionableBlocks.length}...`); - - // Determine tool name from XML block - const toolNameMatch = actionableBlocks[i].match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/); - const toolName = toolNameMatch ? toolNameMatch[1] : 'unknown_tool'; - - try { - // Execute single XML block - const results = await executor.executeXMLTools(actionableBlocks[i]); - const result = results[0]; // Single block should give single result - - if (result) { - // Add friendly display message - addToolResultMessage(toolName, result.success, result.result); - - // Accumulate formatted result for LLM (skip thinking tools) - if (toolName !== 'thinking' && toolName !== 'think') { - const formattedResult = `tool: ${toolName}\nsuccess: ${result.success}\nresult:\n${result.result}\n------------\n`; - accumulatedResults += formattedResult; - } - } - } catch (error) { - // Handle individual tool error - addToolResultMessage(toolName, false, `Tool execution failed: ${error}`); - - // Accumulate error result for LLM (skip thinking tools) - if (toolName !== 'thinking' && toolName !== 'think') { - const formattedResult = `tool: ${toolName}\nsuccess: false\nresult:\nTool execution failed: ${error}\n------------\n`; - accumulatedResults += formattedResult; - } - } - } + + // Execute all tools and get accumulated results + const accumulatedResults = await executeAllTools(actionableBlocks, { + onMessageAdd: handleMessageAdd, + onStatusUpdate: setStatus + }); // Store accumulated results setToolResults(accumulatedResults); @@ -264,90 +203,16 @@ const ChatBox: React.FC = ({ isVisible }) => { }; const sendToolResultsToLLM = async (toolResultsString: string): Promise => { - // Send tool results as hidden user input to LLM - setIsProcessing(true); - - // Create abort controller for this request - const controller = new AbortController(); - setAbortController(controller); - - // Add streaming assistant message for the response - const assistantMsgId = generateMessageId(); - setMessages(prev => [...prev, { - id: assistantMsgId, - role: 'assistant', - content: 'Processing... 0 tokens received. click here to abort.', - isStreaming: true, - tokenCount: 0 - }]); - - try { + // Process tool results through the stream processor + const assistantResponse = await streamProcessor.processStream(toolResultsString, 'TOOL_RESULTS'); + + // Check if the new response contains more tools + const hasMoreTools = await executeToolsFromResponse(assistantResponse); + + // If no more tools were found, set working flag to false + if (!hasMoreTools) { const agentCore = AgentCore.instance(); - let assistantResponse = ''; - let tokenCount = 0; - - // Log the tool results being sent to LLM - console.log('------------ USER ------------'); - console.log(toolResultsString); - console.log('------------------------------'); - - for await (const chunk of agentCore.processUserInput(toolResultsString)) { - // Check if request was aborted - if (controller.signal.aborted) { - return; - } - - if (chunk.type === 'text') { - assistantResponse += chunk.content; - tokenCount++; - - // Update streaming message with token count and abort link - setMessages(prev => prev.map(msg => - msg.id === assistantMsgId - ? { ...msg, content: `Processing... ${tokenCount} tokens received. click here to abort.`, tokenCount } - : msg - )); - } else if (chunk.type === 'done') { - // Replace with final response - setMessages(prev => prev.map(msg => - msg.id === assistantMsgId - ? { ...msg, content: assistantResponse, isStreaming: false, tokenCount: undefined } - : msg - )); - - // Log the complete assistant response - console.log('------------ ASSISTANT ------------'); - console.log(assistantResponse); - console.log('-----------------------------------'); - - // Check if the new response contains more tools - const hasMoreTools = await executeToolsFromResponse(assistantResponse); - - // If no more tools were found, set working flag to false - if (!hasMoreTools) { - const agentCore = AgentCore.instance(); - agentCore.getAgentState().setIsWorkingOnTask(false); - } - - break; - } - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - // Request was aborted, don't show error - return; - } - - console.error('Error processing tool results:', error); - // Update with error message - setMessages(prev => prev.map(msg => - msg.id === assistantMsgId - ? { ...msg, content: 'Error: Failed to process tool results', isStreaming: false, tokenCount: undefined } - : msg - )); - } finally { - setAbortController(null); - setIsProcessing(false); + agentCore.getAgentState().setIsWorkingOnTask(false); } }; @@ -362,22 +227,14 @@ const ChatBox: React.FC = ({ isVisible }) => { // Conditionally show the user message bubble if (filterResult.displayUserMessage) { - const userMsgId = generateMessageId(); - setMessages(prev => [...prev, { - id: userMsgId, - role: 'user', - content: userMessage - }]); + const userMsgObject = createMessage('user', userMessage); + handleMessageAdd(userMsgObject); } // If we have a pseudo assistant response, show it immediately if (filterResult.pseudoAssistantResponse) { - const pseudoId = generateMessageId(); - setMessages(prev => [...prev, { - id: pseudoId, - role: 'assistant', - content: filterResult.pseudoAssistantResponse!, - }]); + const pseudoMessage = createMessage('assistant', filterResult.pseudoAssistantResponse); + handleMessageAdd(pseudoMessage); } // If we shouldn't send anything to LLM, stop here @@ -385,105 +242,33 @@ const ChatBox: React.FC = ({ isVisible }) => { return; } - setIsProcessing(true); + // Set working on task flag when user sends a message + const agentCore = AgentCore.instance(); + agentCore.getAgentState().setIsWorkingOnTask(true); - // Create abort controller for this request - const controller = new AbortController(); - setAbortController(controller); - - // Add streaming assistant message - const assistantMsgId = generateMessageId(); - setMessages(prev => [...prev, { - id: assistantMsgId, - role: 'assistant', - content: 'Processing... 0 tokens received. click here to abort.', - isStreaming: true, - tokenCount: 0 - }]); - - try { - const agentCore = AgentCore.instance(); - let assistantResponse = ''; - let tokenCount = 0; - - // Set working on task flag when user sends a message - agentCore.getAgentState().setIsWorkingOnTask(true); - - // Log system prompt only for first message or first message after clear - if (isFirstMessage) { - try { - const systemPrompt = await SystemPrompts.getSystemPromptWithContext(); - console.log('------------ SYSTEM ------------'); - console.log(systemPrompt); - console.log('--------------------------------'); - } catch (error) { - console.error('Failed to log system prompt:', error); - } - // Mark that we've logged the system prompt for this conversation - setIsFirstMessage(false); + // Log system prompt only for first message or first message after clear + if (isFirstMessage) { + try { + const systemPrompt = await SystemPrompts.getSystemPromptWithContext(); + console.log('------------ SYSTEM ------------'); + console.log(systemPrompt); + console.log('--------------------------------'); + } catch (error) { + console.error('Failed to log system prompt:', error); } + // Mark that we've logged the system prompt for this conversation + setIsFirstMessage(false); + } - // Log the final user message being sent to LLM - console.log('------------ USER ------------'); - console.log(filterResult.finalMessageForLLM); - console.log('------------------------------'); - - for await (const chunk of agentCore.processUserInput(filterResult.finalMessageForLLM)) { - // Check if request was aborted - if (controller.signal.aborted) { - return; - } - - if (chunk.type === 'text') { - assistantResponse += chunk.content; - tokenCount++; - - // Update streaming message with token count and abort link - setMessages(prev => prev.map(msg => - msg.id === assistantMsgId - ? { ...msg, content: `Processing... ${tokenCount} tokens received. click here to abort.`, tokenCount } - : msg - )); - } else if (chunk.type === 'done') { - // Replace with final response - setMessages(prev => prev.map(msg => - msg.id === assistantMsgId - ? { ...msg, content: assistantResponse, isStreaming: false, tokenCount: undefined } - : msg - )); - - // Log the complete assistant response - console.log('------------ ASSISTANT ------------'); - console.log(assistantResponse); - console.log('-----------------------------------'); - - // Check if response contains tools to execute - const hasTools = await executeToolsFromResponse(assistantResponse); - - // If no tools were found, set working flag to false and return control to user - if (!hasTools) { - agentCore.getAgentState().setIsWorkingOnTask(false); - } - - break; - } - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - // Request was aborted, don't show error - return; - } - - console.error('Error processing message:', error); - // Update with error message - setMessages(prev => prev.map(msg => - msg.id === assistantMsgId - ? { ...msg, content: 'Error: Failed to process message', isStreaming: false, tokenCount: undefined } - : msg - )); - } finally { - setAbortController(null); - setIsProcessing(false); + // Process user input through the stream processor + const assistantResponse = await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER'); + + // Check if response contains tools to execute + const hasTools = await executeToolsFromResponse(assistantResponse); + + // If no tools were found, set working flag to false and return control to user + if (!hasTools) { + agentCore.getAgentState().setIsWorkingOnTask(false); } } }; diff --git a/src/hooks/useStreamProcessor.ts b/src/hooks/useStreamProcessor.ts new file mode 100644 index 0000000..c04c74e --- /dev/null +++ b/src/hooks/useStreamProcessor.ts @@ -0,0 +1,119 @@ +import { useState, useCallback } from 'react'; +import { AgentCore } from '../agent/core/AgentCore'; +import { createStreamingMessage } from '../utils/chatMessageUtils'; +import type { ChatMessage } from '../types/projectTypes'; + +interface StreamProcessorOptions { + onMessageUpdate: (messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void; + onMessageAdd: (message: ChatMessage) => void; + onProcessingChange: (isProcessing: boolean) => void; +} + +interface StreamProcessorResult { + processStream: (input: string, logPrefix?: string) => Promise; + abortController: AbortController | null; + isProcessing: boolean; +} + +export const useStreamProcessor = (options: StreamProcessorOptions): StreamProcessorResult => { + const { onMessageUpdate, onMessageAdd, onProcessingChange } = options; + const [abortController, setAbortController] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + const processStream = useCallback(async (input: string, logPrefix: string = 'USER'): Promise => { + setIsProcessing(true); + onProcessingChange(true); + + // Create abort controller for this request + const controller = new AbortController(); + setAbortController(controller); + + // Add streaming assistant message + const streamingMessage = createStreamingMessage(); + onMessageAdd(streamingMessage); + + try { + const agentCore = AgentCore.instance(); + let assistantResponse = ''; + let tokenCount = 0; + let streamCompleted = false; + + // Log the input being sent to LLM + console.log(`------------ ${logPrefix} ------------`); + console.log(input); + console.log('------------------------------'); + + for await (const chunk of agentCore.processUserInput(input)) { + // Check if request was aborted + if (controller.signal.aborted) { + return ''; + } + + if (chunk.type === 'text') { + assistantResponse += chunk.content; + tokenCount++; + + // Update streaming message with token count and abort link + onMessageUpdate(streamingMessage.id, (msg) => ({ + ...msg, + content: `Processing... ${tokenCount} tokens received. click here to abort.`, + tokenCount + })); + } else if (chunk.type === 'done') { + streamCompleted = true; + // Replace with final response + onMessageUpdate(streamingMessage.id, (msg) => ({ + ...msg, + content: assistantResponse, + isStreaming: false, + tokenCount: undefined + })); + + // Log the complete assistant response + console.log('------------ ASSISTANT ------------'); + console.log(assistantResponse); + console.log('-----------------------------------'); + + break; + } + } + + // If stream didn't complete normally, finalize the message + if (!streamCompleted && !controller.signal.aborted) { + onMessageUpdate(streamingMessage.id, (msg) => ({ + ...msg, + content: assistantResponse || 'Stream was interrupted unexpectedly', + isStreaming: false, + tokenCount: undefined + })); + } + + return assistantResponse; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + // Request was aborted, don't show error + return ''; + } + + console.error('Error processing stream:', error); + // Update with error message + onMessageUpdate(streamingMessage.id, (msg) => ({ + ...msg, + content: 'Error: Failed to process message', + isStreaming: false, + tokenCount: undefined + })); + return ''; + } finally { + setAbortController(null); + setIsProcessing(false); + onProcessingChange(false); + } + }, [onMessageUpdate, onMessageAdd, onProcessingChange]); + + return { + processStream, + abortController, + isProcessing + }; +}; \ No newline at end of file diff --git a/src/types/projectTypes.ts b/src/types/projectTypes.ts index 98d4e27..3c59d53 100644 --- a/src/types/projectTypes.ts +++ b/src/types/projectTypes.ts @@ -5,6 +5,14 @@ export interface TimeSignature { denominator: number; } +export interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + isStreaming?: boolean; + tokenCount?: number; +} + /** * A reusable class-transformer decorator to apply a default value during deserialization. * @param defaultValue The default value to apply if the field is undefined. diff --git a/src/utils/chatMessageUtils.ts b/src/utils/chatMessageUtils.ts new file mode 100644 index 0000000..393f05a --- /dev/null +++ b/src/utils/chatMessageUtils.ts @@ -0,0 +1,42 @@ +import { processUserMessage } from '../util/messageFilter/UserMessageFilter'; +import type { ChatMessage } from '../types/projectTypes'; + +export const generateMessageId = (): string => { + return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; +}; + +export const createMessage = (role: 'user' | 'assistant', content: string): ChatMessage => { + return { + id: generateMessageId(), + role, + content, + }; +}; + +export const createStreamingMessage = (content: string = 'Processing... 0 tokens received. click here to abort.'): ChatMessage => { + return { + id: generateMessageId(), + role: 'assistant', + content, + isStreaming: true, + tokenCount: 0, + }; +}; + +export const createToolResultMessage = (toolName: string, success: boolean, result: string): ChatMessage => { + const friendlyDisplay = `${success ? '✅' : '❌'} __**${toolName}**__ \n\n └── ${result}`; + return createMessage('user', friendlyDisplay); +}; + +export const addWelcomeMessage = async (): Promise => { + try { + const result = await processUserMessage('/welcome'); + if (result.pseudoAssistantResponse) { + return createMessage('assistant', result.pseudoAssistantResponse); + } + return null; + } catch { + // ignore errors, just don't show welcome if it fails + return null; + } +}; \ No newline at end of file diff --git a/src/utils/toolExecutionUtils.ts b/src/utils/toolExecutionUtils.ts new file mode 100644 index 0000000..63a4dfa --- /dev/null +++ b/src/utils/toolExecutionUtils.ts @@ -0,0 +1,102 @@ +import { XMLToolExecutor } from '../agent/core/XMLToolExecutor'; +import { extractXMLFromString } from '../util/xmlUtil'; +import { createToolResultMessage } from './chatMessageUtils'; +import type { ChatMessage } from '../types/projectTypes'; + +interface ToolExecutionResult { + success: boolean; + result: string; +} + +interface ToolExecutionOptions { + onMessageAdd: (message: ChatMessage) => void; + onStatusUpdate: (status: string) => void; +} + +export const extractActionableTools = (response: string): string[] => { + const xmlBlocks = extractXMLFromString(response); + // Consider only actionable tools (exclude think/thinking) + return xmlBlocks.filter((block) => { + const match = block.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/); + const name = match ? match[1].toLowerCase() : ''; + return name !== 'think' && name !== 'thinking'; + }); +}; + +export const extractToolName = (xmlBlock: string): string => { + const toolNameMatch = xmlBlock.match(/<([a-zA-Z_][a-zA-Z0-9_-]*)/); + return toolNameMatch ? toolNameMatch[1] : 'unknown_tool'; +}; + +export const executeSingleTool = async ( + xmlBlock: string, + toolName: string, + options: ToolExecutionOptions +): Promise => { + const { onMessageAdd } = options; + + try { + const executor = XMLToolExecutor.instance(); + const results = await executor.executeXMLTools(xmlBlock); + const result = results[0]; // Single block should give single result + + if (result) { + // Add friendly display message + const toolMessage = createToolResultMessage(toolName, result.success, result.result); + onMessageAdd(toolMessage); + + return { + success: result.success, + result: result.result + }; + } + + return { + success: false, + result: 'No result returned from tool execution' + }; + } catch (error) { + // Handle individual tool error + const errorMessage = `Tool execution failed: ${error}`; + const toolMessage = createToolResultMessage(toolName, false, errorMessage); + onMessageAdd(toolMessage); + + return { + success: false, + result: errorMessage + }; + } +}; + +export const formatToolResultForLLM = (toolName: string, result: ToolExecutionResult): string => { + // Skip thinking tools + if (toolName === 'thinking' || toolName === 'think') { + return ''; + } + + return `tool: ${toolName}\nsuccess: ${result.success}\nresult:\n${result.result}\n------------\n`; +}; + +export const executeAllTools = async ( + actionableBlocks: string[], + options: ToolExecutionOptions +): Promise => { + const { onStatusUpdate } = options; + + onStatusUpdate(`Executing ${actionableBlocks.length} tool(s)...`); + + let accumulatedResults = ''; + + // Execute tools sequentially with real-time updates + for (let i = 0; i < actionableBlocks.length; i++) { + onStatusUpdate(`Executing tool ${i + 1} of ${actionableBlocks.length}...`); + + const toolName = extractToolName(actionableBlocks[i]); + const result = await executeSingleTool(actionableBlocks[i], toolName, options); + + // Accumulate formatted result for LLM + accumulatedResults += formatToolResultForLLM(toolName, result); + } + + return accumulatedResults; +}; \ No newline at end of file