import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import type { ChatConversation, ChatModel } from '../../types/electron'; import { useChatMessageSender } from '../../navigation/useChatMessageSender'; import { useChatSurfaceState } from '../../navigation/useChatSurfaceState'; import { getChatSurfaceMode } from '../../navigation/chatSurfaceMode'; import { dispatchAssistantAction } from '../../navigation/assistantActionDispatcher'; import { useA2UISurface } from '../../a2ui/useA2UISurface'; import { useAppStore } from '../../store'; import { ChatTranscript } from '../ChatSurface'; import { useI18n } from '../../i18n'; import '../../styles/chatSurface.css'; import './ChatPanel.css'; interface ChatPanelProps { conversationId: string; } export const ChatPanel: React.FC = ({ conversationId }) => { const { t: tr } = useI18n(); const surfaceMode = getChatSurfaceMode('tab'); const [conversation, setConversation] = useState(null); const [inputValue, setInputValue] = useState(''); const [availableModels, setAvailableModels] = useState([]); const [showModelSelector, setShowModelSelector] = useState(false); const [needsApiKey, setNeedsApiKey] = useState(false); const [apiKeyInput, setApiKeyInput] = useState(''); const [apiKeyError, setApiKeyError] = useState(''); const [isValidating, setIsValidating] = useState(false); const [actionError, setActionError] = useState(null); const messagesEndRef = useRef(null); const inputRef = useRef(null); const { setSelectedPost, setSelectedMedia, openTab, setActiveView, toggleSidebar, togglePanel, toggleAssistantSidebar, } = useAppStore(); const { sendMessage: sendChatMessage } = useChatMessageSender({ chatService: window.electronAPI?.chat, }); const { messages, isStreaming, streamingContent, toolEvents, setMessages, beginUserTurn, appendStreamDelta, recordToolCall, recordToolResult, appendAssistantMessage, finalizeAssistantTurn, stopStreaming, abortStreaming, getStreamingContent, } = useChatSurfaceState(); // A2UI surface rendering const { surfacesByTurn, latestSurfaceId, dismissedSurfaceIds, dismissSurface, updateLocalData, replayFromMessages, } = useA2UISurface({ conversationId }); // Current turn index for associating streaming surfaces const currentTurnIndex = useMemo(() => { return messages.filter(m => m.role === 'user').length - 1; }, [messages]); // Scroll to bottom when messages change const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, []); // Check if service is ready const checkReady = useCallback(async () => { try { const status = await window.electronAPI?.chat.checkReady(); if (!status?.ready) { setNeedsApiKey(true); } else { setNeedsApiKey(false); } } catch { setNeedsApiKey(true); } }, []); // Load conversation and messages const loadData = useCallback(async () => { try { const [conv, msgs, modelsResult] = await Promise.all([ window.electronAPI?.chat.getConversation(conversationId), window.electronAPI?.chat.getHistory(conversationId), window.electronAPI?.chat.getAvailableModels() ]); if (conv) setConversation(conv); if (msgs) { setMessages(msgs); replayFromMessages(msgs); } if (modelsResult?.models) setAvailableModels(modelsResult.models); } catch (error) { console.error('Failed to load chat data:', error); } }, [conversationId, replayFromMessages]); useEffect(() => { checkReady(); loadData(); // Subscribe to stream events const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => { if (data.conversationId === conversationId) { appendStreamDelta(data.delta); scrollToBottom(); } }); const unsubToolCall = window.electronAPI?.chat.onToolCall((data) => { console.log('[ChatPanel] Tool call received:', data); if (data.conversationId === conversationId) { const toolCall = data.toolCall as { name: string; arguments: Record }; recordToolCall(toolCall.name, toolCall.arguments); scrollToBottom(); } }); const unsubToolResult = window.electronAPI?.chat.onToolResult((data) => { console.log('[ChatPanel] Tool result received:', data); if (data.conversationId === conversationId) { const result = data.result as { name: string; result: unknown }; recordToolResult(result.name); scrollToBottom(); } }); const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => { if (data.conversationId === conversationId) { setConversation(prev => prev ? { ...prev, title: data.title } : null); } }); const unsubTokenUsage = window.electronAPI?.chat.onTokenUsage((data) => { if (data.conversationId === conversationId) { useAppStore.getState().setChatTokenUsage(conversationId, { inputTokens: data.cumulativeInputTokens, outputTokens: data.cumulativeOutputTokens, cacheReadTokens: data.cumulativeCacheReadTokens, cacheWriteTokens: data.cumulativeCacheWriteTokens, totalTokens: data.cumulativeTotalTokens, }); } }); return () => { unsubDelta?.(); unsubToolCall?.(); unsubToolResult?.(); unsubTitle?.(); unsubTokenUsage?.(); }; }, [conversationId, loadData, scrollToBottom, checkReady, appendStreamDelta, recordToolCall, recordToolResult]); // Scroll on new messages or streaming content useEffect(() => { scrollToBottom(); }, [messages, streamingContent, scrollToBottom]); const handleApiKeySubmit = async () => { if (!apiKeyInput.trim()) return; setIsValidating(true); setApiKeyError(''); try { const result = await window.electronAPI?.chat.validateApiKey(apiKeyInput.trim()); if (result?.isValid) { await window.electronAPI?.chat.setApiKey(apiKeyInput.trim()); setNeedsApiKey(false); setApiKeyInput(''); loadData(); } else { setApiKeyError(tr('chat.apiKeyInvalid')); } } catch { setApiKeyError(tr('chat.apiKeyValidationFailed')); } finally { setIsValidating(false); } }; const handleSend = async () => { const message = inputValue.trim(); if (!message || isStreaming) return; setInputValue(''); // Reset textarea height if (inputRef.current) { inputRef.current.style.height = 'auto'; } beginUserTurn(conversationId, message); try { const result = await sendChatMessage({ conversationId, message, metadata: { surface: 'tab' }, }); // Use the streamed content we accumulated via onStreamDelta // Fall back to the backend result message if streaming didn't capture the content const assistantContent = getStreamingContent() || (result.success ? result.message : ''); if (assistantContent) { finalizeAssistantTurn(conversationId, assistantContent); } else if (!result.success) { appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') })); stopStreaming(); } else { appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse')); stopStreaming(); } } catch (error) { console.error('Failed to send message:', error); appendAssistantMessage(conversationId, tr('chat.errorGeneric')); stopStreaming(); } finally { if (isStreaming) { stopStreaming(); } } }; const persistActionEvent = async (message: string) => { try { await window.electronAPI?.chat.addSystemEvent(conversationId, message); } catch (error) { console.error('Failed to persist chat action event:', error); } }; const handleAssistantAction = (action: string, payload?: Record) => { const result = dispatchAssistantAction( { action, payload, }, { setSelectedPost, setSelectedMedia, openTab, setActiveView, toggleSidebar, togglePanel, toggleAssistantSidebar, }, ); if (!result.handled) { setActionError(result.error || tr('assistantSidebar.error.actionFailed')); void persistActionEvent(`Assistant action failed: ${action}${result.error ? ` (${result.error})` : ''}`); return; } setActionError(null); void persistActionEvent(`Assistant action executed: ${action}${payload ? ` ${JSON.stringify(payload)}` : ''}`); }; const handleModelChange = async (modelId: string) => { try { await window.electronAPI?.chat.setConversationModel(conversationId, modelId); setConversation((previous) => (previous ? { ...previous, model: modelId } : null)); setShowModelSelector(false); } catch (error) { console.error('Failed to change model:', error); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const handleAbort = async () => { try { await window.electronAPI?.chat.abortMessage(conversationId); } catch (error) { console.error('Failed to abort:', error); } finally { abortStreaming(conversationId, tr('chat.cancelledSuffix')); } }; // API key setup screen if (needsApiKey) { return (
{tr('chat.setupTitle')}
{'\u{1F511}'}

{tr('chat.apiKeyRequiredTitle')}

{tr('chat.apiKeyRequiredDescription')}

setApiKeyInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()} placeholder={tr('chat.apiKeyPlaceholder')} disabled={isValidating} /> {apiKeyError &&
{apiKeyError}
}
); } return (
{conversation?.title || tr('chat.newChat')}
{surfaceMode.showModelSelector && (
{showModelSelector && (
{availableModels.map(model => ( ))}
)}
)}
{surfaceMode.showWelcomeTips && messages.filter(m => m.role !== 'system' && m.role !== 'tool').length === 0 && !isStreaming && (
{'\u{1F916}'}

{tr('chat.welcomeTitle')}

{tr('chat.welcomeDescription')}

  • {tr('chat.welcomeTipSearch')}
  • {tr('chat.welcomeTipChart')}
  • {tr('chat.welcomeTipTable')}
  • {tr('chat.welcomeTipMetadata')}
  • {tr('chat.welcomeTipTabs')}
)} handleAssistantAction(a.action, a.payload)} onSurfaceDataChange={updateLocalData} currentTurnIndex={currentTurnIndex} /> {actionError &&

{actionError}

}
{isStreaming && ( )}