466 lines
17 KiB
TypeScript
466 lines
17 KiB
TypeScript
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<ChatBoxProps> = ({ isVisible }) => {
|
|
const [inputValue, setInputValue] = useState('');
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [lastUserMessage, setLastUserMessage] = useState<string>('');
|
|
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
|
const [activeProvider, setActiveProvider] = useState<string>('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<HTMLDivElement>(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]);
|
|
|
|
return (
|
|
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
|
|
<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>
|
|
)}
|
|
<div className="chatbox-export-wrapper">
|
|
<button
|
|
type="button"
|
|
title="Export"
|
|
onClick={() => setShowExportDropdown(!showExportDropdown)}
|
|
className="chatbox-action-btn chatbox-export-btn"
|
|
>
|
|
<FaDownload />
|
|
</button>
|
|
<div className="chatbox-export-dropdown-anchor">
|
|
<KGDropdown
|
|
options={exportOptions}
|
|
value={exportOptions[0]}
|
|
onChange={handleExportOptionSelect}
|
|
label="Export"
|
|
hideButton={true}
|
|
isOpen={showExportDropdown}
|
|
onToggle={setShowExportDropdown}
|
|
className="chatbox-export-dropdown"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
title="New Chat"
|
|
onClick={handleClearCommand}
|
|
className="chatbox-action-btn"
|
|
>
|
|
<FaPlus />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{activeProvider === LOCAL_LLM_PROVIDER_KEY && (
|
|
<div className="chatbox-local-runtime-section">
|
|
<div className="chatbox-local-runtime-card">
|
|
<h4 className="chatbox-local-mode-title">{LOCAL_LLM_DISPLAY_NAME} Local Runtime</h4>
|
|
{!localModelState.runtimeSupport.supported && (
|
|
<div className="chatbox-local-runtime-warning">
|
|
{localModelState.runtimeSupport.reason}
|
|
</div>
|
|
)}
|
|
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
|
|
<div className="chatbox-local-runtime-help">
|
|
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.
|
|
</div>
|
|
)}
|
|
{(localModelState.isChecking || localModelState.isDownloading || localModelState.progressText) && (
|
|
<div className="chatbox-progress-block">
|
|
<div
|
|
className="chatbox-progress-track"
|
|
role="progressbar"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={Math.max(0, Math.min(100, localModelState.progressPercent))}
|
|
>
|
|
<div className="chatbox-progress-fill" style={{ width: `${Math.max(0, Math.min(100, localModelState.progressPercent))}%` }} />
|
|
</div>
|
|
<div className="chatbox-gen-hint">
|
|
{localModelState.isChecking ? 'Checking local model cache...' : localModelState.progressText}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{localModelState.error && (
|
|
<div className="chatbox-local-runtime-error">
|
|
{localModelState.error}
|
|
</div>
|
|
)}
|
|
</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}
|
|
performanceInfo={message.performanceInfo}
|
|
onAbort={message.isStreaming ? handleAbort : undefined}
|
|
/>
|
|
)
|
|
))}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{!isProcessing && (
|
|
<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);
|