import React, { useState, useEffect, useRef, useCallback } from 'react'; import Markdown from 'marked-react'; import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron'; import './ChatPanel.css'; interface ChatPanelProps { conversationId: string; } export const ChatPanel: React.FC = ({ conversationId }) => { const [conversation, setConversation] = useState(null); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [streamingContent, setStreamingContent] = useState(''); const [toolEvents, setToolEvents] = 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 messagesEndRef = useRef(null); const inputRef = useRef(null); const streamingRef = useRef(''); const toolEventsRef = useRef>([]); // 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); if (modelsResult?.models) setAvailableModels(modelsResult.models); } catch (error) { console.error('Failed to load chat data:', error); } }, [conversationId]); useEffect(() => { checkReady(); loadData(); // Subscribe to stream events const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => { if (data.conversationId === conversationId) { streamingRef.current += data.delta; setStreamingContent(streamingRef.current); 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 }; toolEventsRef.current.push({ name: toolCall.name, args: toolCall.arguments }); setToolEvents(prev => [...prev, { type: 'call', name: toolCall.name, args: toolCall.arguments, timestamp: Date.now() }]); 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 }; setToolEvents(prev => [...prev, { type: 'result', name: result.name, timestamp: Date.now() }]); scrollToBottom(); } }); const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => { if (data.conversationId === conversationId) { setConversation(prev => prev ? { ...prev, title: data.title } : null); } }); return () => { unsubDelta?.(); unsubToolCall?.(); unsubToolResult?.(); unsubTitle?.(); }; }, [conversationId, loadData, scrollToBottom, checkReady]); // 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('Invalid API key. Please check and try again.'); } } catch { setApiKeyError('Failed to validate API key.'); } 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'; } setIsStreaming(true); streamingRef.current = ''; setStreamingContent(''); setToolEvents([]); toolEventsRef.current = []; // Add user message optimistically const userMessage: ChatMessage = { id: `temp-${Date.now()}`, conversationId, role: 'user', content: message, createdAt: new Date().toISOString() }; setMessages(prev => [...prev, userMessage]); try { // Send message and wait for complete response const result = await window.electronAPI?.chat.sendMessage(conversationId, message); // Use the streamed content we accumulated via onStreamDelta // Fall back to the backend result message if streaming didn't capture the content const assistantContent = streamingRef.current || (result?.success ? result.message : ''); if (assistantContent) { const assistantMessage: ChatMessage = { id: `assistant-${Date.now()}`, conversationId, role: 'assistant', content: assistantContent, toolCalls: toolEventsRef.current.length > 0 ? JSON.stringify(toolEventsRef.current) : undefined, createdAt: new Date().toISOString() }; setMessages(prev => [...prev, assistantMessage]); } else if (result && !result.success) { // Backend returned an error (API failure, model unavailable, etc.) const errorMessage: ChatMessage = { id: `error-${Date.now()}`, conversationId, role: 'assistant', content: `Error: ${result.error || 'Failed to get a response. Please try again.'}`, createdAt: new Date().toISOString() }; setMessages(prev => [...prev, errorMessage]); } else { // No content from streaming AND no error, but also no success message // This can happen with some models that don't return content properly const noContentMessage: ChatMessage = { id: `empty-${Date.now()}`, conversationId, role: 'assistant', content: 'The model returned an empty response. Try a different model or rephrase your question.', createdAt: new Date().toISOString() }; setMessages(prev => [...prev, noContentMessage]); } } catch (error) { console.error('Failed to send message:', error); const errorMessage: ChatMessage = { id: `error-${Date.now()}`, conversationId, role: 'assistant', content: 'Sorry, an error occurred while processing your message.', createdAt: new Date().toISOString() }; setMessages(prev => [...prev, errorMessage]); } finally { setIsStreaming(false); setStreamingContent(''); streamingRef.current = ''; } }; 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 { // Keep any streamed content as a visible message const partialContent = streamingRef.current; setIsStreaming(false); setStreamingContent(''); streamingRef.current = ''; if (partialContent) { const partialMessage: ChatMessage = { id: `partial-${Date.now()}`, conversationId, role: 'assistant', content: partialContent + '\n\n*(cancelled)*', createdAt: new Date().toISOString() }; setMessages(prev => [...prev, partialMessage]); } } }; const handleModelChange = async (modelId: string) => { try { await window.electronAPI?.chat.setConversationModel(conversationId, modelId); setConversation(prev => prev ? { ...prev, model: modelId } : null); setShowModelSelector(false); } catch (error) { console.error('Failed to change model:', error); } }; const renderToolMarkers = (events: Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>) => { if (events.length === 0) return null; // Group into pairs: call + result for each tool invocation const markers: Array<{ name: string; args?: unknown; completed: boolean }> = []; const pendingCalls = new Map(); for (const event of events) { if (event.type === 'call') { markers.push({ name: event.name, args: event.args, completed: false }); const count = pendingCalls.get(event.name) || 0; pendingCalls.set(event.name, count + 1); } else if (event.type === 'result') { // Find the last uncompleted marker for this tool for (let i = markers.length - 1; i >= 0; i--) { if (markers[i].name === event.name && !markers[i].completed) { markers[i].completed = true; break; } } } } return (
{markers.map((marker, i) => { const argsPreview = marker.args ? Object.entries(marker.args as Record) .map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`) .join(', ') : ''; return (
{marker.completed ? '\u2713' : '\u25CF'} {marker.name} {argsPreview && ({argsPreview})}
); })}
); }; const renderMessage = (msg: ChatMessage) => { if (msg.role === 'system' || msg.role === 'tool') return null; // Parse tool calls from stored message data const storedToolCalls: Array<{ name: string; args?: unknown; completed: boolean }> = []; if (msg.role === 'assistant' && msg.toolCalls) { try { const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args?: unknown }>; calls.forEach(c => storedToolCalls.push({ name: c.name, args: c.args, completed: true })); } catch { /* ignore parse errors */ } } return (
{msg.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
{msg.role === 'user' ? 'You' : 'Assistant'}
{storedToolCalls.length > 0 && (
{storedToolCalls.map((marker, i) => { const argsPreview = marker.args ? Object.entries(marker.args as Record) .map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`) .join(', ') : ''; return (
{'\u2713'} {marker.name} {argsPreview && ({argsPreview})}
); })}
)}
{msg.role === 'assistant' ? ( {msg.content} ) : ( msg.content )}
); }; // API key setup screen if (needsApiKey) { return (
AI Chat Setup
{'\u{1F511}'}

OpenCode Zen API Key Required

Enter your OpenCode API key to enable AI chat.

setApiKeyInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()} placeholder="Enter your API key..." disabled={isValidating} /> {apiKeyError &&
{apiKeyError}
}
); } return (
{conversation?.title || 'New Chat'}
{showModelSelector && (
{availableModels.map(model => ( ))}
)}
{messages.length === 0 && !isStreaming && (
{'\u{1F916}'}

Welcome to the AI Assistant

I can help you manage your posts and media. Try asking me to:

  • Search for posts about a specific topic
  • Get details about a specific post
  • List all tags or categories in your blog
  • Update metadata for posts or media
  • List all images in your media library
)} {messages.map(renderMessage)} {isStreaming && (streamingContent || toolEvents.length > 0) && (
{'\u{1F916}'}
Assistant {'\u25CF'}
{renderToolMarkers(toolEvents)} {streamingContent && (
{streamingContent}
)}
)} {isStreaming && !streamingContent && toolEvents.length === 0 && (
{'\u{1F916}'}
)}
{isStreaming && ( )}