import React, { useState, useRef, useEffect, memo, useCallback } from 'react'; import './ChatBox.css'; import { FaPlus, FaBan, FaDownload } from 'react-icons/fa'; import { UserMessage, AssistantMessage } from './chat'; import { AgentCore } from '../agent/core/AgentCore'; import { OpenAICompatibleLLMProvider, type LLMProvider } from '../agent/llm/LLMProvider'; import { LocalBrowserLLMProvider } from '../agent/llm/LocalBrowserLLMProvider'; import { ConfigManager } from '../core/config/ConfigManager'; import { useProjectStore } from '../stores/projectStore'; 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 { formatLocalDateTime } from '../util/timeUtil'; import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil'; import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMModelManager'; import { LOCAL_LLM_DISPLAY_NAME, LOCAL_LLM_PROVIDER_KEY } from '../util/localLLMConfig'; import KGDropdown from './common/KGDropdown'; import type { ChatMessage } from '../types/projectTypes'; // Module-level guard to avoid duplicate welcome in React StrictMode dev remounts let hasShownWelcomeOnceInRuntime = false; /** * Create the LLM provider from current configuration */ const createLLMProviderFromConfig = (): LLMProvider => { const configManager = ConfigManager.instance(); const providerType = configManager.get('general.llm_provider') as string; let apiKey: string; let model: string; let baseURL: string | undefined; switch (providerType) { case LOCAL_LLM_PROVIDER_KEY: return new LocalBrowserLLMProvider(); case 'openai': apiKey = configManager.get('general.openai.api_key') as string; model = configManager.get('general.openai.model') as string; baseURL = undefined; // Uses OpenAI default break; case 'claude_openrouter': apiKey = configManager.get('general.claude_openrouter.api_key') as string; model = configManager.get('general.claude_openrouter.model') as string; baseURL = configManager.get('general.claude_openrouter.base_url') as string || undefined; break; case 'openai_compatible': default: apiKey = configManager.get('general.openai_compatible.api_key') as string; model = configManager.get('general.openai_compatible.model') as string; baseURL = configManager.get('general.openai_compatible.base_url') as string || undefined; break; } return new OpenAICompatibleLLMProvider(apiKey, model, baseURL); }; interface ChatBoxProps { isVisible: boolean; } const ChatBox: React.FC = ({ isVisible }) => { const [inputValue, setInputValue] = useState(''); const textareaRef = useRef(null); const [messages, setMessages] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [lastUserMessage, setLastUserMessage] = useState(''); const [localModelState, setLocalModelState] = useState(LocalLLMModelManager.getState()); const [activeProvider, setActiveProvider] = useState('openai'); // Track if this is the first message (for system prompt logging) const [isFirstMessage, setIsFirstMessage] = useState(true); // Export dropdown state and options const [showExportDropdown, setShowExportDropdown] = useState(false); const exportOptions = [ 'Export conversation as JSON', 'Export conversation as Markdown' ]; const handleExportOptionSelect = (option: string) => { if (option === 'Export conversation as JSON') { try { const agentMessages = AgentCore.instance().getAgentState().getMessages(); const exportMessages = agentMessages.map((m) => ({ id: m.id, role: m.role, content: m.content, ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}), ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}), timestamp: formatLocalDateTime(new Date(m.timestamp)) })); const json = JSON.stringify(exportMessages, null, 2); const filename = `kgstudio-conversation-${buildTimestampSuffix()}.json`; downloadBlob(json, 'application/json', filename); } catch (err) { console.error('Failed to export conversation as JSON:', err); } } else if (option === 'Export conversation as Markdown') { (async () => { try { const agentMessages = AgentCore.instance().getAgentState().getMessages(); const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`; const res = await fetch(templateUrl); const template = await res.text(); const sections = agentMessages.map((m) => { let roleLabel: string; if (m.role === 'assistant') roleLabel = 'Assistant'; else if (m.role === 'tool') roleLabel = 'Tool Result'; else roleLabel = 'User'; const ts = formatLocalDateTime(new Date(m.timestamp)); let content = m.content ?? ''; // For assistant messages with tool_calls but no text, show tool call info if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0) { const toolCallsText = m.tool_calls.map(tc => `**Tool call: ${tc.function.name}**\n\`\`\`json\n${tc.function.arguments}\n\`\`\`` ).join('\n\n'); content = content ? `${content}\n\n${toolCallsText}` : toolCallsText; } return template .replace('{role}', roleLabel) .replace('{timestamp}', ts) .replace('{content}', content); }); const markdown = sections.join('\n'); const filename = `kgstudio-conversation-${buildTimestampSuffix()}.md`; downloadBlob(markdown, 'text/markdown', filename); } catch (err) { console.error('Failed to export conversation as Markdown:', err); } })(); } setShowExportDropdown(false); }; // 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 handleMessageRemove = useCallback((messageId: string) => { setMessages(prev => prev.filter(msg => msg.id !== messageId)); }, []); const handleProcessingChange = useCallback((processing: boolean) => { setIsProcessing(processing); }, []); // Stream processor hook const streamProcessor = useStreamProcessor({ onMessageUpdate: handleMessageUpdate, onMessageAdd: handleMessageAdd, onMessageRemove: handleMessageRemove, onProcessingChange: handleProcessingChange }); const clearChatUI = useCallback(async () => { setMessages([]); setIsFirstMessage(true); const welcomeMessage = await addWelcomeMessage(); if (welcomeMessage) { setMessages([welcomeMessage]); } }, []); // Initialize AgentCore with configured provider and register clear UI callback useEffect(() => { const initializeProvider = async () => { const configManager = ConfigManager.instance(); if (!configManager.getIsInitialized()) { await configManager.initialize(); } const applyProviderFromConfig = () => { const providerType = (configManager.get('general.llm_provider') as string) || 'openai'; const provider = createLLMProviderFromConfig(); const agentCore = AgentCore.instance(); agentCore.setLLMProvider(provider); setActiveProvider(providerType); console.log('LLM provider configured'); }; applyProviderFromConfig(); const unsubscribe = configManager.addChangeListener((changedKeys) => { if ( changedKeys.includes('general.llm_provider') || changedKeys.includes('general.local_browser.context_length') || changedKeys.some(k => k.startsWith('general.openai.')) || changedKeys.some(k => k.startsWith('general.openai_compatible.')) ) { applyProviderFromConfig(); } }); return unsubscribe; }; registerClearChatUICallback(clearChatUI); const maybeUnsubscribePromise = initializeProvider(); const unsubscribeLocalModel = LocalLLMModelManager.subscribe(setLocalModelState); (async () => { if (hasShownWelcomeOnceInRuntime) return; hasShownWelcomeOnceInRuntime = true; const welcomeMessage = await addWelcomeMessage(); if (welcomeMessage) { setMessages([welcomeMessage]); } })(); return () => { unsubscribeLocalModel(); Promise.resolve(maybeUnsubscribePromise).then((cleanup) => { if (typeof cleanup === 'function') cleanup(); }).catch(() => {}); }; }, [clearChatUI]); const handleAbort = () => { const controller = streamProcessor.abortController; if (controller) { controller.abort(); const agentCore = AgentCore.instance(); const userMessageContent = agentCore.abortCurrentRequest(); setMessages(prev => prev.slice(0, -2)); setInputValue(userMessageContent || lastUserMessage); setIsProcessing(false); } }; const handleClearCommand = () => { const { setStatus } = useProjectStore.getState(); clearChatHistoryAndUI(setStatus); }; const handleSend = async () => { if (inputValue.trim() && !isProcessing) { const userMessage = inputValue.trim(); setLastUserMessage(userMessage); setInputValue(''); const filterResult = await processUserMessage(userMessage); if (filterResult.displayUserMessage) { const userMsgObject = createMessage('user', userMessage); handleMessageAdd(userMsgObject); } if (filterResult.pseudoAssistantResponse) { const pseudoMessage = createMessage('assistant', filterResult.pseudoAssistantResponse); handleMessageAdd(pseudoMessage); } if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) { return; } // Log system prompt only for first message if (isFirstMessage) { try { const provider = AgentCore.instance().getLLMProvider(); const systemPrompt = await SystemPrompts.getSystemPromptWithContext( provider?.getPreferredSystemPromptPath?.(), ); console.log('------------ SYSTEM ------------'); console.log(systemPrompt); console.log('--------------------------------'); } catch (error) { console.error('Failed to log system prompt:', error); } setIsFirstMessage(false); } // Process through stream processor — AgentCore handles the full agentic loop internally await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER'); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const handleInputFocus = () => { if (textareaRef.current) { textareaRef.current.setAttribute('data-chatbox-input', 'true'); } }; const handleInputBlur = () => { 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(null); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Auto-focus input when processing completes useEffect(() => { if (!isProcessing && textareaRef.current) { setTimeout(() => { textareaRef.current?.focus(); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, 0); } }, [isProcessing]); const localRuntimeMessage = localModelState.runtimeSupport.reason; const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported; return (

K.G.Studio Musician Assistant

{isProcessing && ( )}
{activeProvider === LOCAL_LLM_PROVIDER_KEY && (

{LOCAL_LLM_DISPLAY_NAME} Local Runtime

{hasLocalRuntimeHardFailure && (
{localRuntimeMessage}
)} {!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
The local language model has not been downloaded yet. It will be downloaded automatically the next time you send a chat request with this provider.
)} {(localModelState.isChecking || localModelState.isDownloading || localModelState.progressText) && (
{localModelState.isChecking ? 'Checking local model cache...' : localModelState.progressText}
)} {localModelState.error && (
{localModelState.error}
)}
)}
{messages.map((message) => ( message.role === 'user' ? ( ) : ( ) ))}
{!isProcessing && (