601 lines
21 KiB
TypeScript
601 lines
21 KiB
TypeScript
import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
|
|
import { FaPlus, FaBan } from 'react-icons/fa';
|
|
import { UserMessage, AssistantMessage } from './chat';
|
|
import { AgentCore } from '../agent/core/AgentCore';
|
|
import { OpenAIProvider } from '../agent/llm/OpenAIProvider';
|
|
import { ClaudeProvider } from '../agent/llm/ClaudeProvider';
|
|
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';
|
|
|
|
// 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
|
|
*/
|
|
const createLLMProvider = (): LLMProvider => {
|
|
const configManager = ConfigManager.instance();
|
|
const providerType = configManager.get('general.llm_provider') as string;
|
|
|
|
switch (providerType) {
|
|
case 'claude':
|
|
return new ClaudeProvider();
|
|
case 'gemini':
|
|
return new GeminiProvider();
|
|
case 'openai_compatible':
|
|
case 'openai':
|
|
default:
|
|
return new OpenAIProvider();
|
|
}
|
|
};
|
|
|
|
interface ChatBoxProps {
|
|
isVisible: boolean;
|
|
}
|
|
|
|
const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
|
const [inputValue, setInputValue] = useState('');
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
// Initialize with empty messages
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
|
const [lastUserMessage, setLastUserMessage] = useState<string>('');
|
|
|
|
// Tool execution state
|
|
const [isExecutingTools, setIsExecutingTools] = useState(false);
|
|
const [, setToolResults] = useState<string>(''); // placeholder for future display/use
|
|
const [, setCurrentToolIndex] = useState<number>(0); // placeholder for future display/use
|
|
|
|
// 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)}`;
|
|
};
|
|
|
|
const clearChatUI = useCallback(async () => {
|
|
// Clear UI state
|
|
setMessages([]);
|
|
|
|
// Reset first message flag so system prompt will be logged again
|
|
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
|
|
}
|
|
}, []);
|
|
|
|
// Initialize AgentCore with configured provider and register clear UI callback
|
|
useEffect(() => {
|
|
const initializeProvider = async () => {
|
|
const configManager = ConfigManager.instance();
|
|
|
|
// Ensure ConfigManager is initialized
|
|
if (!configManager.getIsInitialized()) {
|
|
await configManager.initialize();
|
|
}
|
|
|
|
const provider = createLLMProvider();
|
|
const agentCore = AgentCore.instance();
|
|
agentCore.setLLMProvider(provider);
|
|
|
|
console.log(`Switched to ${provider.name} provider`);
|
|
};
|
|
|
|
// Register the UI clear callback for external components to use
|
|
registerClearChatUICallback(clearChatUI);
|
|
|
|
initializeProvider();
|
|
|
|
// Auto-trigger welcome on first launch (guard against React StrictMode double-invoke only)
|
|
(async () => {
|
|
try {
|
|
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
|
|
}
|
|
})();
|
|
}, [clearChatUI]);
|
|
|
|
const handleAbort = () => {
|
|
if (abortController) {
|
|
abortController.abort();
|
|
setAbortController(null);
|
|
|
|
// Use AgentCore to clean up the data model and get the user message content
|
|
const agentCore = AgentCore.instance();
|
|
const userMessageContent = agentCore.abortCurrentRequest();
|
|
|
|
// Remove the last user message and assistant message from UI
|
|
setMessages(prev => prev.slice(0, -2));
|
|
|
|
// Restore the user's input (use the content from AgentCore if available)
|
|
setInputValue(userMessageContent || lastUserMessage);
|
|
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
const handleClearCommand = () => {
|
|
const { setStatus } = useProjectStore.getState();
|
|
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<boolean> => {
|
|
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';
|
|
});
|
|
|
|
if (actionableBlocks.length === 0) {
|
|
// No actionable tools to execute, stop the loop
|
|
return false;
|
|
}
|
|
|
|
// Start tool execution phase
|
|
setIsExecutingTools(true);
|
|
setToolResults('');
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store accumulated results
|
|
setToolResults(accumulatedResults);
|
|
setIsExecutingTools(false);
|
|
|
|
// Check if agent is still working on task before sending results to LLM
|
|
const agentCore = AgentCore.instance();
|
|
const isStillWorkingOnTask = agentCore.getAgentState().getIsWorkingOnTask();
|
|
|
|
if (isStillWorkingOnTask) {
|
|
// Send tool results back to LLM
|
|
setStatus('Processing tool results...');
|
|
await sendToolResultsToLLM(accumulatedResults);
|
|
} else {
|
|
// Agent is no longer working on task, ignore results and return control to user
|
|
setStatus('Tool execution completed');
|
|
}
|
|
|
|
return true; // Tools were found and executed
|
|
|
|
} catch (error) {
|
|
console.error('Error executing tools:', error);
|
|
setIsExecutingTools(false);
|
|
const { setStatus } = useProjectStore.getState();
|
|
setStatus(`Tool execution failed: ${error}`);
|
|
return false; // Tool execution failed
|
|
}
|
|
};
|
|
|
|
const sendToolResultsToLLM = async (toolResultsString: string): Promise<void> => {
|
|
// 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 {
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleSend = async () => {
|
|
if (inputValue.trim() && !isProcessing) {
|
|
const userMessage = inputValue.trim();
|
|
setLastUserMessage(userMessage);
|
|
setInputValue('');
|
|
|
|
// Run message through the filter system
|
|
const filterResult = await processUserMessage(userMessage);
|
|
|
|
// Conditionally show the user message bubble
|
|
if (filterResult.displayUserMessage) {
|
|
const userMsgId = generateMessageId();
|
|
setMessages(prev => [...prev, {
|
|
id: userMsgId,
|
|
role: 'user',
|
|
content: userMessage
|
|
}]);
|
|
}
|
|
|
|
// 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!,
|
|
}]);
|
|
}
|
|
|
|
// If we shouldn't send anything to LLM, stop here
|
|
if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) {
|
|
return;
|
|
}
|
|
|
|
setIsProcessing(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 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);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
// Allow Shift+Enter for new lines (default textarea behavior)
|
|
};
|
|
|
|
const handleInputFocus = () => {
|
|
// Set a data attribute on the textarea to help global keyboard handler identify it
|
|
if (textareaRef.current) {
|
|
textareaRef.current.setAttribute('data-chatbox-input', 'true');
|
|
}
|
|
};
|
|
|
|
const handleInputBlur = () => {
|
|
// Remove the data attribute when losing focus
|
|
if (textareaRef.current) {
|
|
textareaRef.current.removeAttribute('data-chatbox-input');
|
|
}
|
|
};
|
|
|
|
// Auto-resize textarea based on content
|
|
useEffect(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto';
|
|
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
|
}
|
|
}, [inputValue]);
|
|
|
|
// Auto-scroll to bottom when new messages arrive
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages]);
|
|
|
|
// Auto-focus input when it becomes visible (when processing and tool execution complete)
|
|
useEffect(() => {
|
|
if (!isProcessing && !isExecutingTools && textareaRef.current) {
|
|
// Use a small delay to ensure the DOM has updated
|
|
setTimeout(() => {
|
|
textareaRef.current?.focus();
|
|
// Also scroll to bottom when input becomes visible after tool execution
|
|
// This ensures proper scroll position after layout changes from showing input box
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, 0);
|
|
}
|
|
}, [isProcessing, isExecutingTools]);
|
|
|
|
return (
|
|
<div className="chatbox" style={{ display: isVisible ? 'flex' : 'none' }}>
|
|
<div className="chatbox-header">
|
|
<h3>K.G.Studio Musician Assistant</h3>
|
|
<div className="chatbox-actions">
|
|
{isProcessing && (
|
|
<button
|
|
type="button"
|
|
title="Abort"
|
|
onClick={handleAbort}
|
|
className="chatbox-action-btn"
|
|
>
|
|
<FaBan />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
title="New Chat"
|
|
onClick={handleClearCommand}
|
|
className="chatbox-action-btn"
|
|
>
|
|
<FaPlus />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="chatbox-messages">
|
|
{messages.map((message) => (
|
|
message.role === 'user' ? (
|
|
<UserMessage key={message.id} content={message.content} />
|
|
) : (
|
|
<AssistantMessage
|
|
key={message.id}
|
|
content={message.content}
|
|
isStreaming={message.isStreaming}
|
|
onAbort={message.isStreaming ? handleAbort : undefined}
|
|
/>
|
|
)
|
|
))}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{!isProcessing && !isExecutingTools && (
|
|
<div className="chatbox-input-area">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={handleInputFocus}
|
|
onBlur={handleInputBlur}
|
|
placeholder="Press Enter to send message, Shift + Enter for new line"
|
|
className="chatbox-input"
|
|
rows={1}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default memo(ChatBox); |