444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
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<ChatPanelProps> = ({ conversationId }) => {
|
|
const { t: tr } = useI18n();
|
|
const surfaceMode = getChatSurfaceMode('tab');
|
|
const [conversation, setConversation] = useState<ChatConversation | null>(null);
|
|
const [inputValue, setInputValue] = useState('');
|
|
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 [actionError, setActionError] = useState<string | null>(null);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(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<string, unknown> };
|
|
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<string, unknown>) => {
|
|
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 (
|
|
<div className="chat-panel chat-surface">
|
|
<div className="chat-panel-header">
|
|
<div className="chat-panel-title">{tr('chat.setupTitle')}</div>
|
|
</div>
|
|
<div className="chat-messages chat-surface-scroll">
|
|
<div className="chat-welcome">
|
|
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
|
|
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
|
|
<p>{tr('chat.apiKeyRequiredDescription')}</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={tr('chat.apiKeyPlaceholder')}
|
|
disabled={isValidating}
|
|
/>
|
|
<button
|
|
className="api-key-submit"
|
|
onClick={handleApiKeySubmit}
|
|
disabled={!apiKeyInput.trim() || isValidating}
|
|
>
|
|
{isValidating ? tr('chat.apiKeyValidating') : tr('chat.apiKeySave')}
|
|
</button>
|
|
{apiKeyError && <div className="api-key-error">{apiKeyError}</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="chat-panel chat-surface">
|
|
<div className="chat-panel-header">
|
|
<div className="chat-panel-title">
|
|
{conversation?.title || tr('chat.newChat')}
|
|
</div>
|
|
{surfaceMode.showModelSelector && (
|
|
<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 chat-surface-scroll">
|
|
{surfaceMode.showWelcomeTips && messages.filter(m => m.role !== 'system' && m.role !== 'tool').length === 0 && !isStreaming && (
|
|
<div className="chat-welcome">
|
|
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
|
|
<h2>{tr('chat.welcomeTitle')}</h2>
|
|
<p>{tr('chat.welcomeDescription')}</p>
|
|
<ul>
|
|
<li>{tr('chat.welcomeTipSearch')}</li>
|
|
<li>{tr('chat.welcomeTipChart')}</li>
|
|
<li>{tr('chat.welcomeTipTable')}</li>
|
|
<li>{tr('chat.welcomeTipMetadata')}</li>
|
|
<li>{tr('chat.welcomeTipTabs')}</li>
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<ChatTranscript
|
|
messages={messages}
|
|
isStreaming={isStreaming}
|
|
streamingContent={streamingContent}
|
|
toolEvents={toolEvents}
|
|
assistantRoleLabel={tr('chat.role.assistant')}
|
|
userRoleLabel={tr('chat.role.you')}
|
|
showToolMarkers={surfaceMode.showToolMarkers}
|
|
endRef={messagesEndRef}
|
|
surfacesByTurn={surfacesByTurn}
|
|
latestSurfaceId={latestSurfaceId}
|
|
dismissedSurfaceIds={dismissedSurfaceIds}
|
|
onSurfaceDismiss={dismissSurface}
|
|
onSurfaceAction={(a) => handleAssistantAction(a.action, a.payload)}
|
|
onSurfaceDataChange={updateLocalData}
|
|
currentTurnIndex={currentTurnIndex}
|
|
/>
|
|
|
|
{actionError && <p className="chat-surface-error">{actionError}</p>}
|
|
</div>
|
|
|
|
<div className="chat-input-container">
|
|
{isStreaming && (
|
|
<button className="chat-abort-button" onClick={handleAbort}>
|
|
{'\u25FC'} {tr('chat.stop')}
|
|
</button>
|
|
)}
|
|
<div className="chat-input-wrapper">
|
|
<textarea
|
|
ref={inputRef}
|
|
className="chat-input chat-surface-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={tr('chat.inputPlaceholder')}
|
|
rows={1}
|
|
disabled={isStreaming}
|
|
/>
|
|
<button
|
|
className="chat-send-button"
|
|
onClick={handleSend}
|
|
disabled={!inputValue.trim() || isStreaming}
|
|
>
|
|
{'\u2191'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|