510 lines
18 KiB
TypeScript
510 lines
18 KiB
TypeScript
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<ChatPanelProps> = ({ conversationId }) => {
|
|
const [conversation, setConversation] = useState<ChatConversation | null>(null);
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const [streamingContent, setStreamingContent] = useState('');
|
|
const [toolEvents, setToolEvents] = useState<Array<{ type: 'call' | 'result'; name: string; args?: unknown; timestamp: number }>>([]);
|
|
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
|
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<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const streamingRef = useRef('');
|
|
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
|
|
|
|
// 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<string, unknown> };
|
|
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<string, number>();
|
|
|
|
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 (
|
|
<div className="tool-markers">
|
|
{markers.map((marker, i) => {
|
|
const argsPreview = marker.args
|
|
? Object.entries(marker.args as Record<string, unknown>)
|
|
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
|
|
.join(', ')
|
|
: '';
|
|
|
|
return (
|
|
<div key={i} className={`tool-marker ${marker.completed ? 'completed' : 'pending'}`}>
|
|
<span className="tool-marker-icon">{marker.completed ? '\u2713' : '\u25CF'}</span>
|
|
<span className="tool-marker-name">{marker.name}</span>
|
|
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div key={msg.id} className={`chat-message ${msg.role}`}>
|
|
<div className="chat-message-avatar">
|
|
{msg.role === 'user' ? '\u{1F464}' : '\u{1F916}'}
|
|
</div>
|
|
<div className="chat-message-content">
|
|
<div className="chat-message-header">
|
|
<span className="chat-message-role">
|
|
{msg.role === 'user' ? 'You' : 'Assistant'}
|
|
</span>
|
|
</div>
|
|
{storedToolCalls.length > 0 && (
|
|
<div className="tool-markers">
|
|
{storedToolCalls.map((marker, i) => {
|
|
const argsPreview = marker.args
|
|
? Object.entries(marker.args as Record<string, unknown>)
|
|
.map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v.length > 30 ? v.slice(0, 30) + '...' : v}"` : JSON.stringify(v)}`)
|
|
.join(', ')
|
|
: '';
|
|
return (
|
|
<div key={i} className="tool-marker completed">
|
|
<span className="tool-marker-icon">{'\u2713'}</span>
|
|
<span className="tool-marker-name">{marker.name}</span>
|
|
{argsPreview && <span className="tool-marker-args">({argsPreview})</span>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<div className="chat-message-text">
|
|
{msg.role === 'assistant' ? (
|
|
<Markdown gfm>{msg.content}</Markdown>
|
|
) : (
|
|
msg.content
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// API key setup screen
|
|
if (needsApiKey) {
|
|
return (
|
|
<div className="chat-panel">
|
|
<div className="chat-panel-header">
|
|
<div className="chat-panel-title">AI Chat Setup</div>
|
|
</div>
|
|
<div className="chat-messages">
|
|
<div className="chat-welcome">
|
|
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
|
|
<h2>OpenCode Zen API Key Required</h2>
|
|
<p>Enter your OpenCode API key to enable AI chat.</p>
|
|
<div className="api-key-form">
|
|
<input
|
|
type="password"
|
|
className="api-key-input"
|
|
value={apiKeyInput}
|
|
onChange={(e) => setApiKeyInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()}
|
|
placeholder="Enter your API key..."
|
|
disabled={isValidating}
|
|
/>
|
|
<button
|
|
className="api-key-submit"
|
|
onClick={handleApiKeySubmit}
|
|
disabled={!apiKeyInput.trim() || isValidating}
|
|
>
|
|
{isValidating ? 'Validating...' : 'Save Key'}
|
|
</button>
|
|
{apiKeyError && <div className="api-key-error">{apiKeyError}</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="chat-panel">
|
|
<div className="chat-panel-header">
|
|
<div className="chat-panel-title">
|
|
{conversation?.title || 'New Chat'}
|
|
</div>
|
|
<div className="chat-panel-model">
|
|
<button
|
|
className="model-selector-button"
|
|
onClick={() => setShowModelSelector(!showModelSelector)}
|
|
>
|
|
{conversation?.model || 'claude-sonnet-4'}
|
|
<span className="model-dropdown-icon">{'\u25BE'}</span>
|
|
</button>
|
|
{showModelSelector && (
|
|
<div className="model-dropdown">
|
|
{availableModels.map(model => (
|
|
<button
|
|
key={model.id}
|
|
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
|
onClick={() => handleModelChange(model.id)}
|
|
>
|
|
{model.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="chat-messages">
|
|
{messages.length === 0 && !isStreaming && (
|
|
<div className="chat-welcome">
|
|
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
|
|
<h2>Welcome to the AI Assistant</h2>
|
|
<p>I can help you manage your posts and media. Try asking me to:</p>
|
|
<ul>
|
|
<li>Search for posts about a specific topic</li>
|
|
<li>Get details about a specific post</li>
|
|
<li>List all tags or categories in your blog</li>
|
|
<li>Update metadata for posts or media</li>
|
|
<li>List all images in your media library</li>
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map(renderMessage)}
|
|
|
|
{isStreaming && (streamingContent || toolEvents.length > 0) && (
|
|
<div className="chat-message assistant streaming">
|
|
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
|
<div className="chat-message-content">
|
|
<div className="chat-message-header">
|
|
<span className="chat-message-role">Assistant</span>
|
|
<span className="streaming-indicator">{'\u25CF'}</span>
|
|
</div>
|
|
{renderToolMarkers(toolEvents)}
|
|
{streamingContent && (
|
|
<div className="chat-message-text">
|
|
<Markdown gfm>{streamingContent}</Markdown>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isStreaming && !streamingContent && toolEvents.length === 0 && (
|
|
<div className="chat-message assistant thinking">
|
|
<div className="chat-message-avatar">{'\u{1F916}'}</div>
|
|
<div className="chat-message-content">
|
|
<div className="chat-thinking-indicator">
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
<div className="chat-input-container">
|
|
{isStreaming && (
|
|
<button className="chat-abort-button" onClick={handleAbort}>
|
|
{'\u25FC'} Stop
|
|
</button>
|
|
)}
|
|
<div className="chat-input-wrapper">
|
|
<textarea
|
|
ref={inputRef}
|
|
className="chat-input"
|
|
value={inputValue}
|
|
onChange={(e) => {
|
|
setInputValue(e.target.value);
|
|
// Auto-grow the textarea
|
|
e.target.style.height = 'auto';
|
|
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Type a message..."
|
|
rows={1}
|
|
disabled={isStreaming}
|
|
/>
|
|
<button
|
|
className="chat-send-button"
|
|
onClick={handleSend}
|
|
disabled={!inputValue.trim() || isStreaming}
|
|
>
|
|
{'\u2191'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|