wip: agui integration

This commit is contained in:
2026-02-25 19:51:58 +01:00
parent 5efbcfe03a
commit fcdf869a7c
59 changed files with 3467 additions and 267 deletions

View File

@@ -1,7 +1,15 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import Markdown from 'marked-react';
import type { ChatMessage, ChatConversation, ChatModel } from '../../types/electron';
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 { extractAssistantResponseContent, type AssistantPanelElement } from '../../navigation/assistantPanelSpec';
import { useAppStore } from '../../store';
import { ChatTranscript } from '../ChatSurface';
import { AssistantPanelControls } from '../AssistantPanelControls';
import { useI18n } from '../../i18n';
import '../../styles/chatSurface.css';
import './ChatPanel.css';
interface ChatPanelProps {
@@ -10,22 +18,47 @@ interface ChatPanelProps {
export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const { t: tr } = useI18n();
const surfaceMode = getChatSurfaceMode('tab');
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 [panelElements, setPanelElements] = useState<AssistantPanelElement[]>([]);
const [actionError, setActionError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const streamingRef = useRef('');
const toolEventsRef = useRef<Array<{ name: string; args?: unknown }>>([]);
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();
// Scroll to bottom when messages change
const scrollToBottom = useCallback(() => {
@@ -70,8 +103,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
// Subscribe to stream events
const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => {
if (data.conversationId === conversationId) {
streamingRef.current += data.delta;
setStreamingContent(streamingRef.current);
appendStreamDelta(data.delta);
scrollToBottom();
}
});
@@ -80,8 +112,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
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() }]);
recordToolCall(toolCall.name, toolCall.arguments);
scrollToBottom();
}
});
@@ -90,7 +121,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
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() }]);
recordToolResult(result.name);
scrollToBottom();
}
});
@@ -107,7 +138,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
unsubToolResult?.();
unsubTitle?.();
};
}, [conversationId, loadData, scrollToBottom, checkReady]);
}, [conversationId, loadData, scrollToBottom, checkReady, appendStreamDelta, recordToolCall, recordToolResult]);
// Scroll on new messages or streaming content
useEffect(() => {
@@ -146,76 +177,89 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
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]);
beginUserTurn(conversationId, message);
try {
// Send message and wait for complete response
const result = await window.electronAPI?.chat.sendMessage(conversationId, message);
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 = streamingRef.current || (result?.success ? result.message : '');
const assistantContent = getStreamingContent() || (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) {
const parsedResponse = extractAssistantResponseContent(assistantContent);
finalizeAssistantTurn(conversationId, parsedResponse.displayText);
setPanelElements(parsedResponse.panelSpec?.elements ?? []);
} else if (!result.success) {
// Backend returned an error (API failure, model unavailable, etc.)
const errorMessage: ChatMessage = {
id: `error-${Date.now()}`,
conversationId,
role: 'assistant',
content: tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }),
createdAt: new Date().toISOString()
};
setMessages(prev => [...prev, errorMessage]);
appendAssistantMessage(conversationId, tr('chat.errorPrefix', { error: result.error || tr('chat.errorNoResponse') }));
stopStreaming();
setPanelElements([]);
} 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: tr('chat.errorEmptyResponse'),
createdAt: new Date().toISOString()
};
setMessages(prev => [...prev, noContentMessage]);
appendAssistantMessage(conversationId, tr('chat.errorEmptyResponse'));
stopStreaming();
setPanelElements([]);
}
} catch (error) {
console.error('Failed to send message:', error);
const errorMessage: ChatMessage = {
id: `error-${Date.now()}`,
conversationId,
role: 'assistant',
content: tr('chat.errorGeneric'),
createdAt: new Date().toISOString()
};
setMessages(prev => [...prev, errorMessage]);
appendAssistantMessage(conversationId, tr('chat.errorGeneric'));
stopStreaming();
setPanelElements([]);
} finally {
setIsStreaming(false);
setStreamingContent('');
streamingRef.current = '';
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);
}
};
@@ -232,140 +276,18 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ 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*(${tr('chat.cancelledSuffix')})*`,
createdAt: new Date().toISOString()
};
setMessages(prev => [...prev, partialMessage]);
}
abortStreaming(conversationId, tr('chat.cancelledSuffix'));
}
};
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' ? tr('chat.role.you') : tr('chat.role.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 chat-surface">
<div className="chat-panel-header">
<div className="chat-panel-title">{tr('chat.setupTitle')}</div>
</div>
<div className="chat-messages">
<div className="chat-messages chat-surface-scroll">
<div className="chat-welcome">
<div className="chat-welcome-icon">{'\u{1F511}'}</div>
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
@@ -396,37 +318,39 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
}
return (
<div className="chat-panel">
<div className="chat-panel chat-surface">
<div className="chat-panel-header">
<div className="chat-panel-title">
{conversation?.title || tr('chat.newChat')}
</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>
{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">
{messages.length === 0 && !isStreaming && (
<div className="chat-messages chat-surface-scroll">
{surfaceMode.showWelcomeTips && messages.length === 0 && !isStreaming && (
<div className="chat-welcome">
<div className="chat-welcome-icon">{'\u{1F916}'}</div>
<h2>{tr('chat.welcomeTitle')}</h2>
@@ -441,38 +365,22 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
</div>
)}
{messages.map(renderMessage)}
<ChatTranscript
messages={messages}
isStreaming={isStreaming}
streamingContent={streamingContent}
toolEvents={toolEvents}
assistantRoleLabel={tr('chat.role.assistant')}
userRoleLabel={tr('chat.role.you')}
showToolMarkers={surfaceMode.showToolMarkers}
endRef={messagesEndRef}
/>
{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">{tr('chat.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>
{panelElements.length > 0 && (
<AssistantPanelControls elements={panelElements} onAction={handleAssistantAction} />
)}
{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} />
{actionError && <p className="chat-surface-error">{actionError}</p>}
</div>
<div className="chat-input-container">
@@ -484,7 +392,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
<div className="chat-input-wrapper">
<textarea
ref={inputRef}
className="chat-input"
className="chat-input chat-surface-input"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);