feat: refactor sgclaw around zeroclaw compat runtime

This commit is contained in:
zyl
2026-03-26 16:23:31 +08:00
parent bca5b75801
commit ff0771a83f
1059 changed files with 409460 additions and 23 deletions

View File

@@ -0,0 +1,409 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Send, Bot, User, AlertCircle, Copy, Check } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { WsMessage } from '@/types/api';
import { WebSocketClient } from '@/lib/ws';
import { generateUUID } from '@/lib/uuid';
import { useDraft } from '@/hooks/useDraft';
import { t } from '@/lib/i18n';
interface ChatMessage {
id: string;
role: 'user' | 'agent';
content: string;
thinking?: string;
markdown?: boolean;
timestamp: Date;
}
const DRAFT_KEY = 'agent-chat';
export default function AgentChat() {
const { draft, saveDraft, clearDraft } = useDraft(DRAFT_KEY);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState(draft);
const [typing, setTyping] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const wsRef = useRef<WebSocketClient | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const pendingContentRef = useRef('');
const pendingThinkingRef = useRef('');
// Snapshot of thinking captured at chunk_reset, so it survives the reset.
const capturedThinkingRef = useRef('');
const [streamingContent, setStreamingContent] = useState('');
const [streamingThinking, setStreamingThinking] = useState('');
// Persist draft to in-memory store so it survives route changes
useEffect(() => {
saveDraft(input);
}, [input, saveDraft]);
useEffect(() => {
const ws = new WebSocketClient();
ws.onOpen = () => {
setConnected(true);
setError(null);
};
ws.onClose = (ev: CloseEvent) => {
setConnected(false);
if (ev.code !== 1000 && ev.code !== 1001) {
setError(`Connection closed unexpectedly (code: ${ev.code}). Please check your configuration.`);
}
};
ws.onError = () => {
setError(t('agent.connection_error'));
};
ws.onMessage = (msg: WsMessage) => {
switch (msg.type) {
case 'thinking':
setTyping(true);
pendingThinkingRef.current += msg.content ?? '';
setStreamingThinking(pendingThinkingRef.current);
break;
case 'chunk':
setTyping(true);
pendingContentRef.current += msg.content ?? '';
setStreamingContent(pendingContentRef.current);
break;
case 'chunk_reset':
// Server signals that the authoritative done message follows.
// Snapshot thinking before clearing display state.
capturedThinkingRef.current = pendingThinkingRef.current;
pendingContentRef.current = '';
pendingThinkingRef.current = '';
setStreamingContent('');
setStreamingThinking('');
break;
case 'message':
case 'done': {
const content = msg.full_response ?? msg.content ?? pendingContentRef.current;
const thinking = capturedThinkingRef.current || pendingThinkingRef.current || undefined;
if (content) {
setMessages((prev) => [
...prev,
{
id: generateUUID(),
role: 'agent',
content,
thinking,
markdown: true,
timestamp: new Date(),
},
]);
}
pendingContentRef.current = '';
pendingThinkingRef.current = '';
capturedThinkingRef.current = '';
setStreamingContent('');
setStreamingThinking('');
setTyping(false);
break;
}
case 'tool_call':
setMessages((prev) => [
...prev,
{
id: generateUUID(),
role: 'agent',
content: `${t('agent.tool_call_prefix')} ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
timestamp: new Date(),
},
]);
break;
case 'tool_result':
setMessages((prev) => [
...prev,
{
id: generateUUID(),
role: 'agent',
content: `${t('agent.tool_result_prefix')} ${msg.output ?? ''}`,
timestamp: new Date(),
},
]);
break;
case 'error':
setMessages((prev) => [
...prev,
{
id: generateUUID(),
role: 'agent',
content: `${t('agent.error_prefix')} ${msg.message ?? t('agent.unknown_error')}`,
timestamp: new Date(),
},
]);
if (msg.code === 'AGENT_INIT_FAILED' || msg.code === 'AUTH_ERROR' || msg.code === 'PROVIDER_ERROR') {
setError(`Configuration error: ${msg.message}. Please check your provider settings (API key, model, etc.).`);
} else if (msg.code === 'INVALID_JSON' || msg.code === 'UNKNOWN_MESSAGE_TYPE' || msg.code === 'EMPTY_CONTENT') {
setError(`Message error: ${msg.message}`);
}
setTyping(false);
pendingContentRef.current = '';
pendingThinkingRef.current = '';
setStreamingContent('');
setStreamingThinking('');
break;
}
};
ws.connect();
wsRef.current = ws;
return () => {
ws.disconnect();
};
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, typing, streamingContent]);
const handleSend = () => {
const trimmed = input.trim();
if (!trimmed || !wsRef.current?.connected) return;
setMessages((prev) => [
...prev,
{
id: generateUUID(),
role: 'user',
content: trimmed,
timestamp: new Date(),
},
]);
try {
wsRef.current.sendMessage(trimmed);
setTyping(true);
pendingContentRef.current = '';
pendingThinkingRef.current = '';
} catch {
setError(t('agent.send_error'));
}
setInput('');
clearDraft();
if (inputRef.current) {
inputRef.current.style.height = 'auto';
inputRef.current.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
};
const handleCopy = useCallback((msgId: string, content: string) => {
const onSuccess = () => {
setCopiedId(msgId);
setTimeout(() => setCopiedId((prev) => (prev === msgId ? null : prev)), 2000);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(content).then(onSuccess).catch(() => {
// Fallback for insecure contexts (HTTP)
fallbackCopy(content) && onSuccess();
});
} else {
fallbackCopy(content) && onSuccess();
}
}, []);
/**
* Fallback copy using a temporary textarea for HTTP contexts
* where navigator.clipboard is unavailable.
*/
function fallbackCopy(text: string): boolean {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
return true;
} catch {
return false;
} finally {
document.body.removeChild(textarea);
}
}
return (
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
{/* Connection status bar */}
{error && (
<div className="px-4 py-2 border-b flex items-center gap-2 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171', }}>
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{/* Messages area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center animate-fade-in" style={{ color: 'var(--pc-text-muted)' }}>
<div className="h-16 w-16 rounded-3xl flex items-center justify-center mb-4 animate-float" style={{ background: 'var(--pc-accent-glow)' }}>
<Bot className="h-8 w-8" style={{ color: 'var(--pc-accent)' }} />
</div>
<p className="text-lg font-semibold mb-1" style={{ color: 'var(--pc-text-primary)' }}>ZeroClaw Agent</p>
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>{t('agent.start_conversation')}</p>
</div>
)}
{messages.map((msg, idx) => (
<div
key={msg.id}
className={`group flex items-start gap-3 ${
msg.role === 'user' ? 'flex-row-reverse animate-slide-in-right' : 'animate-slide-in-left'
}`}
style={{ animationDelay: `${Math.min(idx * 30, 200)}ms` }}
>
<div
className="flex-shrink-0 w-9 h-9 rounded-2xl flex items-center justify-center border"
style={{
background: msg.role === 'user' ? 'var(--pc-accent)' : 'var(--pc-bg-elevated)',
borderColor: msg.role === 'user' ? 'var(--pc-accent)' : 'var(--pc-border)',
}}
>
{msg.role === 'user' ? (
<User className="h-4 w-4 text-white" />
) : (
<Bot className="h-4 w-4" style={{ color: 'var(--pc-accent)' }} />
)}
</div>
<div className="relative max-w-[75%]">
<div
className="rounded-2xl px-4 py-3 border"
style={
msg.role === 'user'
? { background: 'var(--pc-accent-glow)', borderColor: 'var(--pc-accent-dim)', color: 'var(--pc-text-primary)', }
: { background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)', color: 'var(--pc-text-primary)', }
}
>
{msg.thinking && (
<details className="mb-2">
<summary className="text-xs cursor-pointer select-none" style={{ color: 'var(--pc-text-muted)' }}>Thinking</summary>
<pre className="text-xs mt-1 whitespace-pre-wrap break-words leading-relaxed overflow-auto max-h-60 p-2 rounded-lg" style={{ color: 'var(--pc-text-muted)', background: 'var(--pc-bg-surface)' }}>{msg.thinking}</pre>
</details>
)}
{msg.markdown ? (
<div className="text-sm break-words leading-relaxed chat-markdown"><ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown></div>
) : (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{msg.content}</p>
)}
<p
className="text-[10px] mt-1.5" style={{ color: msg.role === 'user' ? 'var(--pc-accent-light)' : 'var(--pc-text-faint)' }}>
{msg.timestamp.toLocaleTimeString()}
</p>
</div>
<button
onClick={() => handleCopy(msg.id, msg.content)}
aria-label={t('agent.copy_message')}
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-all p-1.5 rounded-xl"
style={{ background: 'var(--pc-bg-elevated)', border: '1px solid var(--pc-border)', color: 'var(--pc-text-muted)', }}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--pc-text-primary)'; e.currentTarget.style.borderColor = 'var(--pc-accent-dim)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--pc-text-muted)'; e.currentTarget.style.borderColor = 'var(--pc-border)'; }}
>
{copiedId === msg.id ? (
<Check className="h-3 w-3" style={{ color: '#34d399' }} />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</div>
))}
{typing && (
<div className="flex items-start gap-3 animate-fade-in">
<div className="flex-shrink-0 w-9 h-9 rounded-2xl flex items-center justify-center border" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)' }}>
<Bot className="h-4 w-4" style={{ color: 'var(--pc-accent)' }} />
</div>
{streamingContent || streamingThinking ? (
<div className="rounded-2xl px-4 py-3 border max-w-[75%]" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)', color: 'var(--pc-text-primary)' }}>
{streamingThinking && (
<details className="mb-2" open={!streamingContent}>
<summary className="text-xs cursor-pointer select-none" style={{ color: 'var(--pc-text-muted)' }}>Thinking{!streamingContent && '...'}</summary>
<pre className="text-xs mt-1 whitespace-pre-wrap break-words leading-relaxed overflow-auto max-h-60 p-2 rounded-lg" style={{ color: 'var(--pc-text-muted)', background: 'var(--pc-bg-surface)' }}>{streamingThinking}</pre>
</details>
)}
{streamingContent && <p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{streamingContent}</p>}
</div>
) : (
<div className="rounded-2xl px-4 py-3 border flex items-center gap-1.5" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)' }}>
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
</div>
)}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="border-t p-4" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-surface)' }}>
<div className="flex items-center gap-3 max-w-4xl mx-auto">
<textarea
ref={inputRef}
rows={1}
value={input}
onChange={handleTextareaChange}
onKeyDown={handleKeyDown}
placeholder={connected ? t('agent.type_message') : t('agent.connecting')}
disabled={!connected}
className="input-electric flex-1 px-4 text-sm resize-none disabled:opacity-40"
style={{ minHeight: '44px', maxHeight: '200px', paddingTop: '10px', paddingBottom: '10px' }}
/>
<button
type='button'
onClick={handleSend}
disabled={!connected || !input.trim()}
className="btn-electric flex-shrink-0 rounded-2xl flex items-center justify-center"
style={{ color: 'white', width: '40px', height: '40px' }}
>
<Send className="h-5 w-5" />
</button>
</div>
<div className="flex items-center justify-center mt-2 gap-2">
<span
className="status-dot"
style={connected
? { background: 'var(--color-status-success)', boxShadow: '0 0 6px var(--color-status-success)' }
: { background: 'var(--color-status-error)', boxShadow: '0 0 6px var(--color-status-error)' }
}
/>
<span className="text-[10px]" style={{ color: 'var(--pc-text-faint)' }}>
{connected ? t('agent.connected_status') : t('agent.disconnected_status')}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,355 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Monitor, Trash2, History, RefreshCw } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { basePath } from '@/lib/basePath';
import { getToken } from '@/lib/auth';
interface CanvasFrame {
frame_id: string;
content_type: string;
content: string;
timestamp: string;
}
interface WsCanvasMessage {
type: string;
canvas_id: string;
frame?: CanvasFrame;
}
export default function Canvas() {
const [canvasId, setCanvasId] = useState('default');
const [canvasIdInput, setCanvasIdInput] = useState('default');
const [currentFrame, setCurrentFrame] = useState<CanvasFrame | null>(null);
const [history, setHistory] = useState<CanvasFrame[]>([]);
const [connected, setConnected] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [canvasList, setCanvasList] = useState<string[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Build WebSocket URL for canvas
const getWsUrl = useCallback((id: string) => {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const base = basePath || '';
return `${proto}//${location.host}${base}/ws/canvas/${encodeURIComponent(id)}`;
}, []);
// Connect to canvas WebSocket
const connectWs = useCallback((id: string) => {
if (wsRef.current) {
wsRef.current.close();
}
const token = getToken();
const protocols = token ? ['zeroclaw.v1', `bearer.${token}`] : ['zeroclaw.v1'];
const ws = new WebSocket(getWsUrl(id), protocols);
ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onerror = () => setConnected(false);
ws.onmessage = (event) => {
try {
const msg: WsCanvasMessage = JSON.parse(event.data);
if (msg.type === 'frame' && msg.frame) {
if (msg.frame.content_type === 'clear') {
setCurrentFrame(null);
setHistory([]);
} else {
setCurrentFrame(msg.frame);
setHistory((prev) => [...prev.slice(-49), msg.frame!]);
}
}
} catch {
// ignore parse errors
}
};
wsRef.current = ws;
}, [getWsUrl]);
// Connect on mount and when canvasId changes
useEffect(() => {
connectWs(canvasId);
return () => {
wsRef.current?.close();
};
}, [canvasId, connectWs]);
// Fetch canvas list periodically
useEffect(() => {
const fetchList = async () => {
try {
const data = await apiFetch<{ canvases: string[] }>('/api/canvas');
setCanvasList(data.canvases || []);
} catch {
// ignore
}
};
fetchList();
const interval = setInterval(fetchList, 5000);
return () => clearInterval(interval);
}, []);
// Render content into the iframe
useEffect(() => {
if (!iframeRef.current || !currentFrame) return;
if (currentFrame.content_type === 'eval') return; // eval frames are special
const iframe = iframeRef.current;
const doc = iframe.contentDocument;
if (!doc) return;
let html = currentFrame.content;
if (currentFrame.content_type === 'svg') {
html = `<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#1a1a2e;}</style></head><body>${currentFrame.content}</body></html>`;
} else if (currentFrame.content_type === 'markdown') {
// Simple markdown-to-HTML: render as preformatted text with basic styling
const escaped = currentFrame.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
html = `<!DOCTYPE html><html><head><style>body{margin:1rem;font-family:system-ui,sans-serif;color:#e0e0e0;background:#1a1a2e;line-height:1.6;}pre{white-space:pre-wrap;word-wrap:break-word;}</style></head><body><pre>${escaped}</pre></body></html>`;
} else if (currentFrame.content_type === 'text') {
const escaped = currentFrame.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
html = `<!DOCTYPE html><html><head><style>body{margin:1rem;font-family:monospace;color:#e0e0e0;background:#1a1a2e;white-space:pre-wrap;}</style></head><body>${escaped}</body></html>`;
}
doc.open();
doc.write(html);
doc.close();
}, [currentFrame]);
const handleSwitchCanvas = () => {
if (canvasIdInput.trim()) {
setCanvasId(canvasIdInput.trim());
setCurrentFrame(null);
setHistory([]);
}
};
const handleClear = async () => {
try {
await apiFetch(`/api/canvas/${encodeURIComponent(canvasId)}`, {
method: 'DELETE',
});
setCurrentFrame(null);
setHistory([]);
} catch {
// ignore
}
};
const handleSelectHistoryFrame = (frame: CanvasFrame) => {
setCurrentFrame(frame);
};
return (
<div className="p-6 space-y-4 h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Monitor className="h-6 w-6" style={{ color: 'var(--pc-accent)' }} />
<h1 className="text-xl font-semibold" style={{ color: 'var(--pc-text-primary)' }}>
Live Canvas
</h1>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{
background: connected ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.15)',
color: connected ? '#22c55e' : '#ef4444',
}}
>
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHistory(!showHistory)}
className="p-2 rounded-lg transition-colors hover:opacity-80"
style={{ background: 'var(--pc-bg-elevated)', color: 'var(--pc-text-muted)' }}
title="Toggle history"
>
<History className="h-4 w-4" />
</button>
<button
onClick={handleClear}
className="p-2 rounded-lg transition-colors hover:opacity-80"
style={{ background: 'var(--pc-bg-elevated)', color: 'var(--pc-text-muted)' }}
title="Clear canvas"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={() => connectWs(canvasId)}
className="p-2 rounded-lg transition-colors hover:opacity-80"
style={{ background: 'var(--pc-bg-elevated)', color: 'var(--pc-text-muted)' }}
title="Reconnect"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
{/* Canvas selector */}
<div className="flex items-center gap-2">
<input
type="text"
value={canvasIdInput}
onChange={(e) => setCanvasIdInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSwitchCanvas()}
placeholder="Canvas ID"
className="px-3 py-1.5 rounded-lg text-sm border"
style={{
background: 'var(--pc-bg-elevated)',
borderColor: 'var(--pc-border)',
color: 'var(--pc-text-primary)',
}}
/>
<button
onClick={handleSwitchCanvas}
className="px-3 py-1.5 rounded-lg text-sm font-medium"
style={{ background: 'var(--pc-accent)', color: '#fff' }}
>
Switch
</button>
{canvasList.length > 0 && (
<div className="flex items-center gap-1 ml-2">
<span className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>Active:</span>
{canvasList.map((id) => (
<button
key={id}
onClick={() => {
setCanvasIdInput(id);
setCanvasId(id);
setCurrentFrame(null);
setHistory([]);
}}
className="px-2 py-0.5 rounded text-xs font-mono transition-colors"
style={{
background: id === canvasId ? 'var(--pc-accent-dim)' : 'var(--pc-bg-elevated)',
color: id === canvasId ? 'var(--pc-accent)' : 'var(--pc-text-muted)',
borderColor: 'var(--pc-border)',
}}
>
{id}
</button>
))}
</div>
)}
</div>
{/* Main content area */}
<div className="flex-1 flex gap-4 min-h-0">
{/* Canvas viewer */}
<div
className="flex-1 rounded-lg border overflow-hidden"
style={{ borderColor: 'var(--pc-border)', background: '#1a1a2e' }}
>
{currentFrame ? (
<iframe
ref={iframeRef}
sandbox="allow-scripts"
className="w-full h-full border-0"
title={`Canvas: ${canvasId}`}
style={{ background: '#1a1a2e' }}
/>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Monitor
className="h-12 w-12 mx-auto mb-3 opacity-30"
style={{ color: 'var(--pc-text-muted)' }}
/>
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>
Waiting for content on canvas <span className="font-mono">"{canvasId}"</span>
</p>
<p className="text-xs mt-1" style={{ color: 'var(--pc-text-muted)', opacity: 0.6 }}>
The agent can push content here using the canvas tool
</p>
</div>
</div>
)}
</div>
{/* History panel */}
{showHistory && (
<div
className="w-64 rounded-lg border overflow-y-auto"
style={{
borderColor: 'var(--pc-border)',
background: 'var(--pc-bg-elevated)',
}}
>
<div
className="px-3 py-2 border-b text-xs font-medium sticky top-0"
style={{
borderColor: 'var(--pc-border)',
background: 'var(--pc-bg-elevated)',
color: 'var(--pc-text-muted)',
}}
>
Frame History ({history.length})
</div>
{history.length === 0 ? (
<p className="p-3 text-xs" style={{ color: 'var(--pc-text-muted)' }}>
No frames yet
</p>
) : (
<div className="space-y-1 p-2">
{[...history].reverse().map((frame) => (
<button
key={frame.frame_id}
onClick={() => handleSelectHistoryFrame(frame)}
className="w-full text-left px-2 py-1.5 rounded text-xs transition-colors"
style={{
background:
currentFrame?.frame_id === frame.frame_id
? 'var(--pc-accent-dim)'
: 'transparent',
color: 'var(--pc-text-primary)',
}}
>
<div className="flex items-center justify-between">
<span className="font-mono truncate" style={{ color: 'var(--pc-accent)' }}>
{frame.content_type}
</span>
<span style={{ color: 'var(--pc-text-muted)' }}>
{new Date(frame.timestamp).toLocaleTimeString()}
</span>
</div>
<div
className="truncate mt-0.5"
style={{ color: 'var(--pc-text-muted)', fontSize: '0.65rem' }}
>
{frame.content.substring(0, 60)}
{frame.content.length > 60 ? '...' : ''}
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
{/* Frame info bar */}
{currentFrame && (
<div
className="flex items-center justify-between px-3 py-1.5 rounded-lg text-xs"
style={{ background: 'var(--pc-bg-elevated)', color: 'var(--pc-text-muted)' }}
>
<span>
Type: <span className="font-mono">{currentFrame.content_type}</span> | Frame:{' '}
<span className="font-mono">{currentFrame.frame_id.substring(0, 8)}</span>
</span>
<span>{new Date(currentFrame.timestamp).toLocaleString()}</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,233 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Settings,
Save,
CheckCircle,
AlertTriangle,
ShieldAlert,
} from 'lucide-react';
import { getConfig, putConfig } from '@/lib/api';
import { t } from '@/lib/i18n';
// ---------------------------------------------------------------------------
// Lightweight zero-dependency TOML syntax highlighter.
// Produces an HTML string. The <pre> overlay sits behind the <textarea> so
// the textarea remains the editable surface; the pre just provides colour.
// ---------------------------------------------------------------------------
function highlightToml(raw: string): string {
const lines = raw.split('\n');
const result: string[] = [];
for (const line of lines) {
const escaped = line
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Section header [section] or [[array]]
if (/^\s*\[/.test(escaped)) {
result.push(`<span style="color:#67e8f9;font-weight:600">${escaped}</span>`);
continue;
}
// Comment line
if (/^\s*#/.test(escaped)) {
result.push(`<span style="color:#52525b;font-style:italic">${escaped}</span>`);
continue;
}
// Key = value line
const kvMatch = escaped.match(/^(\s*)([A-Za-z0-9_\-.]+)(\s*=\s*)(.*)$/);
if (kvMatch) {
const [, indent, key, eq, rawValue] = kvMatch;
const value = colorValue(rawValue ?? '');
result.push(
`${indent}<span style="color:#a78bfa">${key}</span>`
+ `<span style="color:#71717a">${eq}</span>${value}`
);
continue;
}
result.push(escaped);
}
return result.join('\n') + '\n';
}
function colorValue(v: string): string {
const trimmed = v.trim();
const commentIdx = findUnquotedHash(trimmed);
if (commentIdx !== -1) {
const valueCore = trimmed.slice(0, commentIdx).trimEnd();
const comment = `<span style="color:#52525b;font-style:italic">${trimmed.slice(commentIdx)}</span>`;
const leading = v.slice(0, v.indexOf(trimmed));
return leading + colorScalar(valueCore) + ' ' + comment;
}
return colorScalar(v);
}
function findUnquotedHash(s: string): number {
let inSingle = false;
let inDouble = false;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === "'" && !inDouble) inSingle = !inSingle;
else if (c === '"' && !inSingle) inDouble = !inDouble;
else if (c === '#' && !inSingle && !inDouble) return i;
}
return -1;
}
function colorScalar(v: string): string {
const t = v.trim();
if (t === 'true' || t === 'false')
return `<span style="color:#34d399">${v}</span>`;
if (/^-?\d[\d_]*(\.[\d_]*)?([eE][+-]?\d+)?$/.test(t))
return `<span style="color:#fbbf24">${v}</span>`;
if (t.startsWith('"') || t.startsWith("'"))
return `<span style="color:#86efac">${v}</span>`;
if (t.startsWith('[') || t.startsWith('{'))
return `<span style="color:#e2e8f0">${v}</span>`;
if (/^\d{4}-\d{2}-\d{2}/.test(t))
return `<span style="color:#fb923c">${v}</span>`;
return v;
}
export default function Config() {
const [config, setConfig] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const preRef = useRef<HTMLPreElement>(null);
const syncScroll = useCallback(() => {
if (preRef.current && textareaRef.current) {
preRef.current.scrollTop = textareaRef.current.scrollTop;
preRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
useEffect(() => {
getConfig()
.then((data) => { setConfig(typeof data === 'string' ? data : JSON.stringify(data, null, 2)); })
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccess(null);
try {
await putConfig(config);
setSuccess(t('config.save_success'));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('config.save_error'));
} finally {
setSaving(false);
}
};
// Auto-dismiss success after 4 seconds
useEffect(() => {
if (!success) return;
const timer = setTimeout(() => setSuccess(null), 4000);
return () => clearTimeout(timer);
}, [success]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div>
);
}
return (
<div className="flex flex-col h-full p-6 gap-6 animate-fade-in overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>{t('config.configuration_title')}</h2>
</div>
<button onClick={handleSave} disabled={saving} className="btn-electric flex items-center gap-2 text-sm px-4 py-2">
<Save className="h-4 w-4" />{saving ? t('config.saving') : t('config.save')}
</button>
</div>
{/* Sensitive fields note */}
<div className="flex items-start gap-3 rounded-2xl p-4 border" style={{ borderColor: 'rgba(255, 170, 0, 0.2)', background: 'rgba(255, 170, 0, 0.05)' }}>
<ShieldAlert className="h-5 w-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-status-warning)' }} />
<div>
<p className="text-sm font-medium" style={{ color: 'var(--color-status-warning)' }}>
{t('config.sensitive_title')}
</p>
<p className="text-sm mt-0.5" style={{ color: 'rgba(255, 170, 0, 0.7)' }}>
{t('config.sensitive_hint')}
</p>
</div>
</div>
{/* Success message */}
{success && (
<div className="flex items-center gap-2 rounded-xl p-3 border animate-fade-in" style={{ borderColor: 'rgba(0, 230, 138, 0.2)', background: 'rgba(0, 230, 138, 0.06)' }}>
<CheckCircle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-success)' }} />
<span className="text-sm" style={{ color: 'var(--color-status-success)' }}>{success}</span>
</div>
)}
{/* Error message */}
{error && (
<div className="flex items-center gap-2 rounded-xl p-3 border animate-fade-in" style={{ borderColor: 'rgba(239, 68, 68, 0.2)', background: 'rgba(239, 68, 68, 0.06)' }}>
<AlertTriangle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-error)' }} />
<span className="text-sm" style={{ color: 'var(--color-status-error)' }}>{error}</span>
</div>
)}
{/* Config Editor */}
<div className="card overflow-hidden rounded-2xl flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-accent-glow)' }}>
<span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-muted)' }}>
{t('config.toml_label')}
</span>
<span className="text-[10px]" style={{ color: 'var(--pc-text-faint)' }}>
{config.split('\n').length} {t('config.lines')}
</span>
</div>
<div className="relative flex-1 min-h-0 overflow-hidden">
<pre
ref={preRef}
aria-hidden="true"
className="absolute inset-0 text-sm p-4 font-mono overflow-auto whitespace-pre pointer-events-none m-0"
style={{ background: 'var(--pc-bg-base)', tabSize: 4 }}
dangerouslySetInnerHTML={{ __html: highlightToml(config) }}
/>
<textarea
ref={textareaRef}
value={config}
onChange={(e) => setConfig(e.target.value)}
onScroll={syncScroll}
onKeyDown={(e) => {
if (e.key === 'Tab') {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart;
const end = el.selectionEnd;
setConfig(config.slice(0, start) + ' ' + config.slice(end));
requestAnimationFrame(() => { el.selectionStart = el.selectionEnd = start + 2; });
}
}}
spellCheck={false}
className="absolute inset-0 w-full h-full text-sm p-4 resize-none focus:outline-none font-mono caret-white"
style={{ background: 'transparent', color: 'transparent', tabSize: 4 }}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react';
import {
DollarSign,
TrendingUp,
Hash,
Layers,
} from 'lucide-react';
import type { CostSummary } from '@/types/api';
import { getCost } from '@/lib/api';
import { t } from '@/lib/i18n';
function formatUSD(value: number): string {
return `$${value.toFixed(4)}`;
}
export default function Cost() {
const [cost, setCost] = useState<CostSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getCost().then(setCost).catch((err) => setError(err.message)).finally(() => setLoading(false));
}, []);
if (error) {
return (
<div className="p-6 animate-fade-in">
<div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{t('cost.load_error')}: {error}
</div>
</div>
);
}
if (loading || !cost) {
return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div>
);
}
const models = Object.values(cost.by_model);
return (
<div className="p-6 space-y-6 animate-fade-in">
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
{[
{ icon: DollarSign, accent: 'var(--pc-accent)', bg: 'rgba(var(--pc-accent-rgb), 0.08)', label: t('cost.session_cost'), value: formatUSD(cost.session_cost_usd) },
{ icon: TrendingUp, accent: 'var(--color-status-success)', bg: 'rgba(0, 230, 138, 0.08)', label: t('cost.daily_cost'), value: formatUSD(cost.daily_cost_usd) },
{ icon: Layers, accent: '#a78bfa', bg: 'rgba(167, 139, 250, 0.08)', label: t('cost.monthly_cost'), value: formatUSD(cost.monthly_cost_usd) },
{ icon: Hash, accent: 'var(--color-status-warning)', bg: 'rgba(255, 170, 0, 0.08)', label: t('cost.total_requests'), value: cost.request_count.toLocaleString() },
].map(({ icon: Icon, accent, bg, label, value }) => (
<div key={label} className="card p-5 animate-slide-in-up">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-2xl" style={{ background: bg, color: accent }}>
<Icon className="h-5 w-5" />
</div>
<span className="text-xs uppercase tracking-wider font-medium" style={{ color: 'var(--pc-text-muted)' }}>{label}</span>
</div>
<p className="text-2xl font-bold font-mono" style={{ color: 'var(--pc-text-primary)' }}>{value}</p>
</div>
))}
</div>
{/* Token Statistics */}
<div className="card p-5 animate-slide-in-up" style={{ animationDelay: '200ms' }}>
<h3 className="text-sm font-semibold mb-4 uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('cost.token_statistics')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ label: t('cost.total_tokens'), value: cost.total_tokens.toLocaleString() },
{ label: t('cost.avg_tokens_per_request'), value: cost.request_count > 0 ? Math.round(cost.total_tokens / cost.request_count).toLocaleString() : '0' },
{ label: t('cost.cost_per_1k_tokens'), value: cost.total_tokens > 0 ? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000) : '$0.0000' },
].map(({ label, value }) => (
<div key={label} className="rounded-2xl p-4 border" style={{ background: 'var(--pc-accent-glow)', borderColor: 'var(--pc-border)' }}>
<p className="text-xs uppercase tracking-wider" style={{ color: 'var(--pc-text-muted)' }}>{label}</p>
<p className="text-xl font-bold mt-1 font-mono" style={{ color: 'var(--pc-text-primary)' }}>{value}</p>
</div>
))}
</div>
</div>
{/* Model Breakdown Table */}
<div className="card overflow-hidden animate-slide-in-up rounded-2xl" style={{ animationDelay: '300ms' }}>
<div className="px-5 py-4 border-b" style={{ borderColor: 'var(--pc-border)' }}>
<h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('cost.model_breakdown')}
</h3>
</div>
{models.length === 0 ? (
<div className="p-8 text-center" style={{ color: 'var(--pc-text-faint)' }}>
{t('cost.no_model_data')}
</div>
) : (
<div className="overflow-x-auto">
<table className="table-electric">
<thead>
<tr>
<th>{t('cost.model')}</th>
<th className="text-right">{t('cost.cost')}</th>
<th className="text-right">{t('cost.tokens')}</th>
<th className="text-right">{t('cost.requests')}</th>
<th>{t('cost.share')}</th>
</tr>
</thead>
<tbody>
{models.sort((a, b) => b.cost_usd - a.cost_usd).map((m) => {
const share = cost.monthly_cost_usd > 0 ? (m.cost_usd / cost.monthly_cost_usd) * 100 : 0;
return (
<tr key={m.model}>
<td className="font-medium text-sm" style={{ color: 'var(--pc-text-primary)' }}>
{m.model}
</td>
<td className="text-right font-mono text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
{formatUSD(m.cost_usd)}
</td>
<td className="text-right text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
{m.total_tokens.toLocaleString()}
</td>
<td className="text-right text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
{m.request_count.toLocaleString()}
</td>
<td>
<div className="flex items-center gap-2">
<div className="w-20 h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--pc-hover)' }}>
<div
className="h-full rounded-full progress-bar-animated transition-all duration-700"
style={{ width: `${Math.max(share, 2)}%`, background: 'var(--pc-accent)' }}
/>
</div>
<span className="text-xs font-mono w-10 text-right" style={{ color: 'var(--pc-text-muted)' }}>
{share.toFixed(1)}%
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,531 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Clock,
Plus,
Trash2,
X,
CheckCircle,
XCircle,
AlertCircle,
ChevronDown,
ChevronRight,
RefreshCw,
Pencil,
} from 'lucide-react';
import type { CronJob, CronRun } from '@/types/api';
import {
getCronJobs,
addCronJob,
deleteCronJob,
getCronRuns,
getCronSettings,
patchCronSettings,
patchCronJob,
} from '@/lib/api';
import type { CronSettings } from '@/lib/api';
import { t } from '@/lib/i18n';
function formatDate(iso: string | null): string {
if (!iso) return '-';
const d = new Date(iso);
return d.toLocaleString();
}
function formatDuration(ms: number | null): string {
if (ms === null || ms === undefined) return '-';
if (ms < 1000) return `${ms}ms`;
const secs = ms / 1000;
if (secs < 60) return `${secs.toFixed(1)}s`;
return `${(secs / 60).toFixed(1)}m`;
}
function RunHistoryPanel({ jobId }: { jobId: string }) {
const [runs, setRuns] = useState<CronRun[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchRuns = useCallback(() => {
setLoading(true);
setError(null);
getCronRuns(jobId, 20)
.then(setRuns)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [jobId]);
useEffect(() => { fetchRuns(); }, [fetchRuns]);
if (loading) {
return (
<div className="flex items-center gap-2 px-4 py-3 text-xs" style={{ color: 'var(--pc-text-muted)' }}>
<div className="h-4 w-4 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
Loading run history...
</div>
);
}
if (error) {
return (
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-xs" style={{ color: 'var(--color-status-error)' }}>
{t('cron.load_run_history_error')}: {error}
</span>
<button
onClick={fetchRuns}
className="btn-icon">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
if (runs.length === 0) {
return (
<div className="px-4 py-3 flex items-center justify-between">
<span className="text-xs" style={{ color: 'var(--pc-text-faint)' }}>{t('cron.no_runs')}</span>
<button
onClick={fetchRuns}
className="btn-icon"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
);
}
return (
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium" style={{ color: 'var(--pc-text-secondary)' }}>
{t('cron.recent_runs')} ({runs.length})
</span>
<button
onClick={fetchRuns}
className="btn-icon"
title="Refresh runs"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto">
{runs.map((run) => (
<div
key={run.id}
className="rounded-xl px-3 py-2 text-xs border" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)' }}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
{run.status === 'ok' ? (
<CheckCircle className="h-3.5 w-3.5" style={{ color: 'var(--color-status-success)' }} />
) : (
<XCircle className="h-3.5 w-3.5" style={{ color: 'var(--color-status-error)' }} />
)}
<span style={{ color: 'var(--pc-text-secondary)' }}>{run.status}</span>
</div>
<span style={{ color: 'var(--pc-text-muted)' }}>
{formatDuration(run.duration_ms)}
</span>
</div>
<div className="flex items-center gap-3" style={{ color: 'var(--pc-text-muted)' }}>
<span>{formatDate(run.started_at)}</span>
</div>
{run.output && (
<pre className="mt-1.5 rounded-lg p-2 text-xs overflow-x-auto max-h-24 whitespace-pre-wrap break-words font-mono" style={{ background: 'var(--pc-bg-base)', color: 'var(--pc-text-secondary)' }}>
{run.output}
</pre>
)}
</div>
))}
</div>
</div>
);
}
export default function Cron() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [expandedJob, setExpandedJob] = useState<string | null>(null);
const [settings, setSettings] = useState<CronSettings | null>(null);
const [togglingCatchUp, setTogglingCatchUp] = useState(false);
// Unified modal: null = closed, 'add' = adding, CronJob = editing
const [modalJob, setModalJob] = useState<CronJob | 'add' | null>(null);
// Shared form state for both add and edit
const [formName, setFormName] = useState('');
const [formSchedule, setFormSchedule] = useState('');
const [formCommand, setFormCommand] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const isEditing = modalJob !== null && modalJob !== 'add';
const openAddModal = () => {
setFormName('');
setFormSchedule('');
setFormCommand('');
setFormError(null);
setModalJob('add');
};
const openEditModal = (job: CronJob) => {
setFormName(job.name ?? '');
setFormSchedule(job.expression);
setFormCommand(job.prompt ?? job.command);
setFormError(null);
setModalJob(job);
};
const closeModal = () => {
setModalJob(null);
setFormError(null);
};
const fetchJobs = () => {
setLoading(true);
getCronJobs().then(setJobs).catch((err) => setError(err.message)).finally(() => setLoading(false));
};
const fetchSettings = () => {
getCronSettings().then(setSettings).catch(() => {});
};
const toggleCatchUp = async () => {
if (!settings) return;
setTogglingCatchUp(true);
try {
const updated = await patchCronSettings({
catch_up_on_startup: !settings.catch_up_on_startup,
});
setSettings(updated);
} catch {
// silently fail — user can retry
} finally {
setTogglingCatchUp(false);
}
};
useEffect(() => {
fetchJobs();
fetchSettings();
}, []);
const handleSubmit = async () => {
if (!formSchedule.trim() || !formCommand.trim()) {
setFormError(t('cron.validation_error'));
return;
}
setSubmitting(true);
setFormError(null);
try {
if (isEditing) {
const updated = await patchCronJob((modalJob as CronJob).id, {
name: formName.trim() || undefined,
schedule: formSchedule.trim(),
command: formCommand.trim(),
});
setJobs((prev) => prev.map((j) => (j.id === updated.id ? updated : j)));
} else {
const job = await addCronJob({
name: formName.trim() || undefined,
schedule: formSchedule.trim(),
command: formCommand.trim(),
});
setJobs((prev) => [...prev, job]);
}
closeModal();
} catch (err: unknown) {
setFormError(
err instanceof Error
? err.message
: t(isEditing ? 'cron.edit_error' : 'cron.add_error'),
);
} finally {
setSubmitting(false);
}
};
const handleDelete = async (id: string) => {
try {
await deleteCronJob(id);
setJobs((prev) => prev.filter((j) => j.id !== id));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('cron.delete_error'));
} finally {
setConfirmDelete(null);
}
};
const statusIcon = (status: string | null) => {
if (!status) return null;
switch (status.toLowerCase()) {
case 'ok':
case 'success':
return <CheckCircle className="h-4 w-4" style={{ color: 'var(--color-status-success)' }} />;
case 'error':
case 'failed':
return <XCircle className="h-4 w-4" style={{ color: 'var(--color-status-error)' }} />;
default:
return <AlertCircle className="h-4 w-4" style={{ color: 'var(--color-status-warning)' }} />;
}
};
if (error) {
return (
<div className="p-6 animate-fade-in">
<div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{t('cron.load_error')}: {error}
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div>
);
}
return (
<div className="flex flex-col h-full p-6 gap-6 animate-fade-in overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('cron.scheduled_tasks')} ({jobs.length})
</h2>
</div>
<button
onClick={openAddModal}
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
>
<Plus className="h-4 w-4" />{t('cron.add_job')}
</button>
</div>
{/* Catch-up toggle */}
{settings && (
<div className="glass-card px-4 py-3 flex items-center justify-between">
<div>
<span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
Catch up missed jobs on startup
</span>
<p className="text-xs mt-0.5" style={{ color: 'var(--pc-text-muted)' }}>
Run all overdue jobs when ZeroClaw starts after downtime
</p>
</div>
<button
onClick={toggleCatchUp}
disabled={togglingCatchUp}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-300 focus:outline-none ${
settings.catch_up_on_startup
? 'bg-[#0080ff]'
: 'bg-[#1a1a3e]'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform duration-300 ${
settings.catch_up_on_startup
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
)}
{/* Unified Add / Edit Modal */}
{modalJob !== null && (
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">
<div className="surface-panel p-6 w-full max-w-md mx-4 animate-fade-in-scale">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold" style={{ color: 'var(--pc-text-primary)' }}>
{isEditing ? t('cron.edit_modal_title') : t('cron.add_modal_title')}
</h3>
<button
onClick={closeModal}
className="btn-icon"
>
<X className="h-5 w-5" />
</button>
</div>
{formError && (
<div className="mb-4 rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{formError}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('cron.name_optional')}
</label>
<input type="text" value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="e.g. Daily cleanup" className="input-electric w-full px-3 py-2.5 text-sm" />
</div>
<div>
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('cron.schedule_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
</label>
<input type="text" value={formSchedule} onChange={(e) => setFormSchedule(e.target.value)} placeholder="e.g. 0 0 * * * (cron expression)" className="input-electric w-full px-3 py-2.5 text-sm" />
</div>
<div>
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('cron.command_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
</label>
<textarea
value={formCommand}
onChange={(e) => setFormCommand(e.target.value)}
placeholder="e.g. cleanup --older-than 7d"
rows={4}
className="input-electric w-full px-3 py-2.5 text-sm resize-y"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={closeModal}
className="btn-secondary px-4 py-2 text-sm font-medium"
>
{t('cron.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="btn-electric px-4 py-2 text-sm font-medium"
>
{submitting
? t(isEditing ? 'cron.saving' : 'cron.adding')
: t(isEditing ? 'cron.save' : 'cron.add_job')}
</button>
</div>
</div>
</div>
)}
{/* Jobs Table */}
{jobs.length === 0 ? (
<div className="card p-8 text-center">
<Clock className="h-10 w-10 mx-auto mb-3" style={{ color: 'var(--pc-text-faint)' }} />
<p style={{ color: 'var(--pc-text-muted)' }}>{t('cron.empty')}</p>
</div>
) : (
<div className="card overflow-auto rounded-2xl flex-1 min-h-0">
<table className="table-electric">
<thead>
<tr>
<th>{t('cron.id')}</th>
<th>{t('cron.name')}</th>
<th>{t('cron.command')}</th>
<th>{t('cron.next_run')}</th>
<th>{t('cron.last_status')}</th>
<th>{t('cron.enabled')}</th>
<th className="text-right">{t('cron.actions')}</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<React.Fragment key={job.id}>
<tr>
<td className="font-mono text-xs">
<button
onClick={() =>
setExpandedJob((prev) =>
prev === job.id ? null : job.id,
)
}
className="flex items-center gap-1 btn-icon"
title="Toggle run history"
>
{expandedJob === job.id ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{job.id.slice(0, 8)}
</button>
</td>
<td className="font-medium text-sm" style={{ color: 'var(--pc-text-primary)' }}>
{job.name ?? '-'}
</td>
<td className="font-mono text-xs max-w-[200px] truncate" style={{ color: 'var(--pc-text-secondary)' }}>
{job.prompt ?? job.command}
</td>
<td className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>
{formatDate(job.next_run)}
</td>
<td>
<div className="flex items-center gap-1.5">
{statusIcon(job.last_status)}
<span className="text-xs capitalize" style={{ color: 'var(--pc-text-secondary)' }}>
{job.last_status ?? '-'}
</span>
</div>
</td>
<td>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border"
style={job.enabled ? { color: 'var(--color-status-success)', borderColor: 'rgba(0, 230, 138, 0.2)', background: 'rgba(0, 230, 138, 0.06)' } : { color: 'var(--pc-text-faint)', borderColor: 'var(--pc-border)', background: 'transparent' }}>
{job.enabled ? t('cron.enabled_status') : t('cron.disabled_status')}
</span>
</td>
<td className="text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditModal(job)}
className="btn-icon"
title={t('cron.edit')}
>
<Pencil className="h-4 w-4" />
</button>
{confirmDelete === job.id ? (
<div className="flex items-center justify-end gap-2 animate-fade-in">
<span className="text-xs" style={{ color: 'var(--color-status-error)' }}>
{t('cron.confirm_delete')}
</span>
<button
onClick={() => handleDelete(job.id)}
className="text-xs font-medium"
style={{ color: 'var(--color-status-error)' }}
>
{t('cron.yes')}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs font-medium"
style={{ color: 'var(--pc-text-muted)' }}
>
{t('cron.no')}
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(job.id)}
className="btn-icon"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</td>
</tr>
{expandedJob === job.id && (
<tr>
<td colSpan={7} style={{ background: 'var(--pc-bg-elevated)' }}>
<RunHistoryPanel jobId={job.id} />
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,921 @@
import { useState, useEffect, useCallback } from 'react';
import {
Cpu,
Clock,
Globe,
Database,
Activity,
DollarSign,
Radio,
LayoutDashboard,
Users,
MessageSquare,
ChevronRight,
Hash,
Wifi,
} from 'lucide-react';
import type { StatusResponse, CostSummary, Session, ChannelDetail } from '@/types/api';
import { getStatus, getCost, getSessions, getChannels } from '@/lib/api';
import { useSSE } from '@/hooks/useSSE';
import { t } from '@/lib/i18n';
type TabId = 'overview' | 'sessions' | 'channels';
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function formatUSD(value: number): string {
return `$${value.toFixed(4)}`;
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
function formatRelative(iso: string): string {
try {
const diff = Date.now() - new Date(iso).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
} catch {
return iso;
}
}
function healthColor(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'var(--color-status-success)';
case 'warn':
case 'warning':
case 'degraded':
return 'var(--color-status-warning)';
default:
return 'var(--color-status-error)';
}
}
function healthBorder(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'rgba(0, 230, 138, 0.2)';
case 'warn':
case 'warning':
case 'degraded':
return 'rgba(255, 170, 0, 0.2)';
default:
return 'rgba(255, 68, 102, 0.2)';
}
}
function healthBg(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'rgba(0, 230, 138, 0.05)';
case 'warn':
case 'warning':
case 'degraded':
return 'rgba(255, 170, 0, 0.05)';
default:
return 'rgba(255, 68, 102, 0.05)';
}
}
function sessionStatusColor(status: string): string {
switch (status) {
case 'active':
return 'var(--color-status-success)';
case 'idle':
return 'var(--color-status-warning)';
default:
return 'var(--pc-text-faint)';
}
}
const STATUS_CARDS = [
{
icon: Cpu,
accent: "var(--pc-accent)",
labelKey: "dashboard.provider_model",
getValue: (s: StatusResponse) => s.provider ?? "Unknown",
getSub: (s: StatusResponse) => s.model ?? "",
},
{
icon: Clock,
accent: "#34d399",
labelKey: "dashboard.uptime",
getValue: (s: StatusResponse) => formatUptime(s.uptime_seconds),
getSub: () => t("dashboard.since_last_restart"),
},
{
icon: Globe,
accent: "#a78bfa",
labelKey: "dashboard.gateway_port",
getValue: (s: StatusResponse) => `:${s.gateway_port}`,
getSub: () => "",
},
{
icon: Database,
accent: "#fbbf24",
labelKey: "dashboard.memory_backend",
getValue: (s: StatusResponse) => s.memory_backend,
getSub: (s: StatusResponse) =>
`${t("dashboard.paired")}: ${s.paired ? t("dashboard.paired_yes") : t("dashboard.paired_no")}`,
},
];
const TABS: { id: TabId; labelKey: string; icon: typeof LayoutDashboard }[] = [
{ id: 'overview', labelKey: 'dashboard.tab_overview', icon: LayoutDashboard },
{ id: 'sessions', labelKey: 'dashboard.tab_sessions', icon: Users },
{ id: 'channels', labelKey: 'dashboard.tab_channels', icon: Wifi },
];
// ---------------------------------------------------------------------------
// Overview Tab (existing dashboard content)
// ---------------------------------------------------------------------------
function OverviewTab({
status,
cost,
showAllChannels,
setShowAllChannels,
}: {
status: StatusResponse;
cost: CostSummary;
showAllChannels: boolean;
setShowAllChannels: (fn: (v: boolean) => boolean) => void;
}) {
const maxCost = Math.max(
cost.session_cost_usd,
cost.daily_cost_usd,
cost.monthly_cost_usd,
0.001
);
return (
<>
{/* Status Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
{STATUS_CARDS.map(({ icon: Icon, accent, labelKey, getValue, getSub }) => (
<div key={labelKey} className="card p-5 animate-slide-in-up">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-2xl" style={{ background: `rgba(var(--pc-accent-rgb), 0.08)`, color: accent, }}>
<Icon className="h-5 w-5" />
</div>
<span className="text-xs uppercase tracking-wider font-medium" style={{ color: "var(--pc-text-muted)" }}>{t(labelKey)}</span>
</div>
<p className="text-lg font-semibold truncate capitalize" style={{ color: "var(--pc-text-primary)" }}>{getValue(status)}</p>
<p className="text-sm truncate" style={{ color: "var(--pc-text-muted)" }}>{getSub(status)}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 stagger-children">
{/* Cost Widget */}
<div className="card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5">
<DollarSign className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: "var(--pc-text-primary)" }}>{t("dashboard.cost_overview")}</h2>
</div>
<div className="space-y-4">
{[
{
label: t("dashboard.session_label"),
value: cost.session_cost_usd,
color: "var(--pc-accent)",
},
{
label: t("dashboard.daily_label"),
value: cost.daily_cost_usd,
color: "#34d399",
},
{
label: t("dashboard.monthly_label"),
value: cost.monthly_cost_usd,
color: "#a78bfa",
},
].map(({ label, value, color }) => (
<div key={label}>
<div className="flex justify-between text-sm mb-1.5">
<span style={{ color: "var(--pc-text-muted)" }}>{label}</span>
<span
className="font-medium font-mono"
style={{ color: "var(--pc-text-primary)" }}
>
{formatUSD(value)}
</span>
</div>
<div
className="w-full h-1.5 rounded-full overflow-hidden"
style={{ background: "var(--pc-hover)" }}
>
<div
className="h-full rounded-full progress-bar-animated transition-all duration-700 ease-out"
style={{
width: `${Math.max((value / maxCost) * 100, 2)}%`,
background: color,
}}
/>
</div>
</div>
))}
</div>
<div
className="mt-5 pt-4 border-t flex justify-between text-sm"
style={{ borderColor: "var(--pc-border)" }}
>
<span style={{ color: "var(--pc-text-muted)" }}>
{t("dashboard.total_tokens_label")}
</span>
<span className="font-mono" style={{ color: "var(--pc-text-primary)" }}>
{cost.total_tokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span style={{ color: "var(--pc-text-muted)" }}>
{t("dashboard.requests_label")}
</span>
<span className="font-mono" style={{ color: "var(--pc-text-primary)" }}>
{cost.request_count.toLocaleString()}
</span>
</div>
</div>
{/* Active Channels */}
<div className="card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5">
<Radio className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
<h2
className="text-sm font-semibold uppercase tracking-wider"
style={{ color: "var(--pc-text-primary)" }}
>
{t("dashboard.channels")}
</h2>
<button
onClick={() => setShowAllChannels((v) => !v)}
className="ml-auto flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium border transition-all"
style={
showAllChannels
? {
background: "rgba(var(--pc-accent-rgb), 0.1)",
borderColor: "rgba(var(--pc-accent-rgb), 0.3)",
color: "var(--pc-accent-light)",
}
: {
background: "rgba(0, 230, 138, 0.08)",
borderColor: "rgba(0, 230, 138, 0.25)",
color: "#34d399",
}
}
aria-label={
showAllChannels
? t("dashboard.filter_active")
: t("dashboard.filter_all")
}
>
{showAllChannels
? t("dashboard.filter_all")
: t("dashboard.filter_active")}
</button>
</div>
<div className="space-y-2 overflow-y-auto max-h-48 pr-1">
{Object.entries(status.channels).length === 0 ? (
<p className="text-sm" style={{ color: "var(--pc-text-faint)" }}>
{t("dashboard.no_channels")}
</p>
) : (() => {
const entries = Object.entries(status.channels).filter(
([, active]) => showAllChannels || active
);
if (entries.length === 0) {
return (
<p className="text-sm" style={{ color: "var(--pc-text-faint)" }}>
{t("dashboard.no_active_channels")}
</p>
);
}
return entries.map(([name, active]) => (
<div
key={name}
className="flex items-center justify-between py-2.5 px-3 rounded-xl transition-all"
style={{ background: "var(--pc-bg-elevated)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--pc-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--pc-bg-elevated)";
}}
>
<span
className="text-sm font-medium capitalize"
style={{ color: "var(--pc-text-primary)" }}
>
{name}
</span>
<div className="flex items-center gap-2">
<span
className="status-dot"
style={
active
? {
background: "var(--color-status-success)",
boxShadow: "0 0 6px var(--color-status-success)",
}
: { background: "var(--pc-text-faint)" }
}
/>
<span className="text-xs" style={{ color: "var(--pc-text-muted)" }}>
{active ? t("dashboard.active") : t("dashboard.inactive")}
</span>
</div>
</div>
));
})()}
</div>
</div>
<div className="card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5">
<Activity className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
<h2
className="text-sm font-semibold uppercase tracking-wider"
style={{ color: "var(--pc-text-primary)" }}
>
{t("dashboard.component_health")}
</h2>
</div>
<div className="grid grid-cols-2 gap-3">
{Object.entries(status.health.components).length === 0 ? (
<p
className="text-sm col-span-2"
style={{ color: "var(--pc-text-faint)" }}
>
{t("dashboard.no_components")}
</p>
) : (
Object.entries(status.health.components).map(([name, comp]) => (
<div
key={name}
className="rounded-2xl p-3 transition-all"
style={{
border: `1px solid ${healthBorder(comp.status)}`,
background: healthBg(comp.status),
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "scale(1.02)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)";
}}
>
<div className="flex items-center gap-2 mb-1">
<span
className="status-dot"
style={{
background: healthColor(comp.status),
boxShadow: `0 0 6px ${healthColor(comp.status)}`,
}}
/>
<span
className="text-sm font-medium truncate capitalize"
style={{ color: "var(--pc-text-primary)" }}
>
{name}
</span>
</div>
<p className="text-xs capitalize" style={{ color: "var(--pc-text-muted)" }}>
{comp.status}
</p>
{comp.restart_count > 0 && (
<p
className="text-xs mt-1"
style={{ color: "var(--color-status-warning)" }}
>
{t("dashboard.restarts")}: {comp.restart_count}
</p>
)}
</div>
))
)}
</div>
</div>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Sessions Tab
// ---------------------------------------------------------------------------
function SessionsTab() {
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
const { events } = useSSE({
filterTypes: ['session_update', 'session_created', 'session_closed'],
autoConnect: true,
});
const loadSessions = useCallback(() => {
getSessions()
.then((data) => {
setSessions(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, []);
useEffect(() => {
loadSessions();
}, [loadSessions]);
// React to SSE events for real-time updates
useEffect(() => {
if (events.length === 0) return;
loadSessions();
}, [events.length, loadSessions]);
if (loading) {
return (
<div className="flex items-center justify-center h-48">
<div className="flex items-center gap-3">
<div
className="h-6 w-6 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--pc-border)", borderTopColor: "var(--pc-accent)" }}
/>
<span className="text-sm" style={{ color: "var(--pc-text-muted)" }}>
{t("dashboard.loading_sessions")}
</span>
</div>
</div>
);
}
if (error) {
return (
<div
className="rounded-2xl border p-4"
style={{ background: "rgba(239, 68, 68, 0.08)", borderColor: "rgba(239, 68, 68, 0.2)", color: "#f87171" }}
>
{t("dashboard.load_sessions_error")}: {error}
</div>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Session List */}
<div className="lg:col-span-2 card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5">
<Users className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
<h2
className="text-sm font-semibold uppercase tracking-wider"
style={{ color: "var(--pc-text-primary)" }}
>
{t("dashboard.sessions_title")}
</h2>
<span
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
style={{ background: "rgba(var(--pc-accent-rgb), 0.1)", color: "var(--pc-accent)" }}
>
{sessions.length}
</span>
</div>
{sessions.length === 0 ? (
<p className="text-sm py-8 text-center" style={{ color: "var(--pc-text-faint)" }}>
{t("dashboard.no_sessions")}
</p>
) : (
<div className="space-y-2 overflow-y-auto max-h-96">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => setSelectedSession(session)}
className="w-full text-left flex items-center justify-between py-3 px-4 rounded-xl transition-all"
style={{
background: selectedSession?.id === session.id
? "rgba(var(--pc-accent-rgb), 0.08)"
: "var(--pc-bg-elevated)",
border: selectedSession?.id === session.id
? "1px solid rgba(var(--pc-accent-rgb), 0.2)"
: "1px solid transparent",
}}
onMouseEnter={(e) => {
if (selectedSession?.id !== session.id) {
e.currentTarget.style.background = "var(--pc-hover)";
}
}}
onMouseLeave={(e) => {
if (selectedSession?.id !== session.id) {
e.currentTarget.style.background = "var(--pc-bg-elevated)";
}
}}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className="text-sm font-medium font-mono truncate"
style={{ color: "var(--pc-text-primary)" }}
>
{session.id.slice(0, 8)}...
</span>
<span
className="text-[10px] uppercase font-medium px-2 py-0.5 rounded-full"
style={{
background: `${sessionStatusColor(session.status)}15`,
color: sessionStatusColor(session.status),
}}
>
{session.status}
</span>
</div>
<div className="flex items-center gap-3 text-xs" style={{ color: "var(--pc-text-muted)" }}>
<span className="flex items-center gap-1">
<Hash className="h-3 w-3" />
{session.channel}
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{session.message_count}
</span>
<span>{formatRelative(session.last_activity)}</span>
</div>
</div>
<ChevronRight
className="h-4 w-4 flex-shrink-0"
style={{ color: "var(--pc-text-faint)" }}
/>
</button>
))}
</div>
)}
</div>
{/* Session Details Panel */}
<div className="card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5">
<Activity className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
<h2
className="text-sm font-semibold uppercase tracking-wider"
style={{ color: "var(--pc-text-primary)" }}
>
{t("dashboard.session_details")}
</h2>
</div>
{selectedSession ? (
<div className="space-y-4">
{[
{ label: t("dashboard.session_id"), value: selectedSession.id },
{ label: t("dashboard.session_channel"), value: selectedSession.channel },
{ label: t("dashboard.session_started"), value: formatTime(selectedSession.started_at) },
{ label: t("dashboard.session_last_activity"), value: formatRelative(selectedSession.last_activity) },
{ label: t("dashboard.session_status"), value: selectedSession.status },
{ label: t("dashboard.session_messages"), value: String(selectedSession.message_count) },
].map(({ label, value }) => (
<div key={label}>
<p className="text-xs uppercase tracking-wider mb-1" style={{ color: "var(--pc-text-faint)" }}>
{label}
</p>
<p
className="text-sm font-medium capitalize truncate"
style={{ color: "var(--pc-text-primary)" }}
>
{value}
</p>
</div>
))}
<div
className="pt-3 mt-3 border-t"
style={{ borderColor: "var(--pc-border)" }}
>
<div
className="flex items-center gap-2"
>
<span
className="status-dot"
style={{
background: sessionStatusColor(selectedSession.status),
boxShadow: `0 0 6px ${sessionStatusColor(selectedSession.status)}`,
}}
/>
<span className="text-xs capitalize" style={{ color: "var(--pc-text-muted)" }}>
{selectedSession.status}
</span>
</div>
</div>
</div>
) : (
<p className="text-sm py-8 text-center" style={{ color: "var(--pc-text-faint)" }}>
{t("dashboard.session_history")}
</p>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Channels Tab
// ---------------------------------------------------------------------------
function ChannelsTab() {
const [channels, setChannels] = useState<ChannelDetail[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { events } = useSSE({
filterTypes: ['channel_update', 'channel_status'],
autoConnect: true,
});
const loadChannels = useCallback(() => {
getChannels()
.then((data) => {
setChannels(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, []);
useEffect(() => {
loadChannels();
}, [loadChannels]);
// React to SSE events for real-time updates
useEffect(() => {
if (events.length === 0) return;
loadChannels();
}, [events.length, loadChannels]);
if (loading) {
return (
<div className="flex items-center justify-center h-48">
<div className="flex items-center gap-3">
<div
className="h-6 w-6 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--pc-border)", borderTopColor: "var(--pc-accent)" }}
/>
<span className="text-sm" style={{ color: "var(--pc-text-muted)" }}>
{t("dashboard.loading_channels")}
</span>
</div>
</div>
);
}
if (error) {
return (
<div
className="rounded-2xl border p-4"
style={{ background: "rgba(239, 68, 68, 0.08)", borderColor: "rgba(239, 68, 68, 0.2)", color: "#f87171" }}
>
{t("dashboard.load_channels_error")}: {error}
</div>
);
}
if (channels.length === 0) {
return (
<div className="card p-5 animate-slide-in-up">
<p className="text-sm py-8 text-center" style={{ color: "var(--pc-text-faint)" }}>
{t("dashboard.no_channels_detail")}
</p>
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
{channels.map((channel) => (
<div
key={channel.name}
className="card p-5 animate-slide-in-up transition-all"
style={{
border: `1px solid ${healthBorder(channel.health)}`,
background: healthBg(channel.health),
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-2px)";
e.currentTarget.style.boxShadow = `0 4px 12px ${healthBorder(channel.health)}`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "none";
}}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div
className="p-2 rounded-2xl"
style={{ background: `rgba(var(--pc-accent-rgb), 0.08)`, color: "var(--pc-accent)" }}
>
<Radio className="h-5 w-5" />
</div>
<div>
<h3
className="text-sm font-semibold capitalize"
style={{ color: "var(--pc-text-primary)" }}
>
{channel.name}
</h3>
<span className="text-xs" style={{ color: "var(--pc-text-muted)" }}>
{channel.type}
</span>
</div>
</div>
<span
className="status-dot"
style={{
background: healthColor(channel.health),
boxShadow: `0 0 6px ${healthColor(channel.health)}`,
}}
/>
</div>
{/* Status Badge */}
<div className="flex items-center gap-2 mb-3">
<span
className="text-[10px] uppercase font-medium px-2 py-0.5 rounded-full"
style={{
background: channel.status === 'active'
? 'rgba(0, 230, 138, 0.1)'
: channel.status === 'error'
? 'rgba(255, 68, 102, 0.1)'
: 'rgba(var(--pc-accent-rgb), 0.08)',
color: channel.status === 'active'
? '#34d399'
: channel.status === 'error'
? '#f87171'
: 'var(--pc-text-muted)',
}}
>
{channel.status}
</span>
<span
className="text-[10px] uppercase font-medium px-2 py-0.5 rounded-full"
style={{
background: channel.enabled
? 'rgba(0, 230, 138, 0.1)'
: 'rgba(255, 68, 102, 0.1)',
color: channel.enabled ? '#34d399' : '#f87171',
}}
>
{channel.enabled ? t("dashboard.channel_enabled") : t("dashboard.channel_disabled")}
</span>
</div>
{/* Stats */}
<div
className="pt-3 border-t space-y-2"
style={{ borderColor: "var(--pc-border)" }}
>
<div className="flex justify-between text-xs">
<span style={{ color: "var(--pc-text-muted)" }}>{t("dashboard.channel_messages")}</span>
<span className="font-mono" style={{ color: "var(--pc-text-primary)" }}>
{channel.message_count.toLocaleString()}
</span>
</div>
<div className="flex justify-between text-xs">
<span style={{ color: "var(--pc-text-muted)" }}>{t("dashboard.channel_last_message")}</span>
<span className="font-mono" style={{ color: "var(--pc-text-primary)" }}>
{channel.last_message_at ? formatRelative(channel.last_message_at) : t("dashboard.never")}
</span>
</div>
<div className="flex justify-between text-xs">
<span style={{ color: "var(--pc-text-muted)" }}>{t("dashboard.health")}</span>
<span className="capitalize" style={{ color: healthColor(channel.health) }}>
{channel.health}
</span>
</div>
</div>
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Main Dashboard Component
// ---------------------------------------------------------------------------
export default function Dashboard() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [cost, setCost] = useState<CostSummary | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAllChannels, setShowAllChannels] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>('overview');
useEffect(() => {
Promise.all([getStatus(), getCost()])
.then(([s, c]) => {
setStatus(s);
setCost(c);
})
.catch((err) => setError(err.message));
}, []);
if (error) {
return (
<div className="p-6 animate-fade-in">
<div className="rounded-2xl border p-4" style={{ background: "rgba(239, 68, 68, 0.08)", borderColor: "rgba(239, 68, 68, 0.2)", color: "#f87171", }}>
{t("dashboard.load_error")}: {error}
</div>
</div>
);
}
if (!status || !cost) {
return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: "var(--pc-border)", borderTopColor: "var(--pc-accent)", }}/>
</div>
);
}
return (
<div className="p-6 space-y-6 animate-fade-in">
{/* Tab Navigation */}
<div
className="flex items-center gap-1 p-1 rounded-2xl"
style={{ background: "var(--pc-bg-elevated)" }}
>
{TABS.map(({ id, labelKey, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all"
style={
activeTab === id
? {
background: "var(--pc-bg-primary)",
color: "var(--pc-accent)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)",
}
: {
background: "transparent",
color: "var(--pc-text-muted)",
}
}
onMouseEnter={(e) => {
if (activeTab !== id) {
e.currentTarget.style.color = "var(--pc-text-primary)";
}
}}
onMouseLeave={(e) => {
if (activeTab !== id) {
e.currentTarget.style.color = "var(--pc-text-muted)";
}
}}
>
<Icon className="h-4 w-4" />
{t(labelKey)}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<OverviewTab
status={status}
cost={cost}
showAllChannels={showAllChannels}
setShowAllChannels={setShowAllChannels}
/>
)}
{activeTab === 'sessions' && <SessionsTab />}
{activeTab === 'channels' && <ChannelsTab />}
</div>
);
}

View File

@@ -0,0 +1,216 @@
import { useState } from 'react';
import {
Stethoscope,
Play,
CheckCircle,
AlertTriangle,
XCircle,
Loader2,
} from 'lucide-react';
import type { DiagResult } from '@/types/api';
import { runDoctor } from '@/lib/api';
import { t } from '@/lib/i18n';
function severityIcon(severity: DiagResult['severity']) {
switch (severity) {
case 'ok':
return <CheckCircle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-success)' }} />;
case 'warn':
return <AlertTriangle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-warning)' }} />;
case 'error':
return <XCircle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-error)' }} />;
}
}
function severityBg(severity: DiagResult['severity']): string {
switch (severity) {
case 'ok':
return 'rgba(0, 230, 138, 0.04)';
case 'warn':
return 'rgba(255, 170, 0, 0.04)';
case 'error':
return 'rgba(239, 68, 68, 0.04)';
}
}
function severityBorder(severity: DiagResult['severity']): string {
switch (severity) {
case 'ok':
return 'border-[rgba(0,230,138,0.3)]';
case 'warn':
return 'border-[rgba(255,170,0,0.3)]';
case 'error':
return 'border-[rgba(239,68,68,0.3)]';
}
}
export default function Doctor() {
const [results, setResults] = useState<DiagResult[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleRun = async () => {
setLoading(true);
setError(null);
setResults(null);
try {
const data = await runDoctor();
setResults(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to run diagnostics');
} finally {
setLoading(false);
}
};
const okCount = results?.filter((r) => r.severity === 'ok').length ?? 0;
const warnCount = results?.filter((r) => r.severity === 'warn').length ?? 0;
const errorCount = results?.filter((r) => r.severity === 'error').length ?? 0;
const grouped =
results?.reduce<Record<string, DiagResult[]>>((acc, item) => {
const key = item.category;
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {}) ?? {};
return (
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Stethoscope className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>{t('doctor.diagnostics_title')}</h2>
</div>
<button
onClick={handleRun}
disabled={loading}
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('doctor.running_btn')}
</>
) : (
<>
<Play className="h-4 w-4" />
{t('doctor.run_diagnostics')}
</>
)}
</button>
</div>
{/* Error */}
{error && (
<div className="rounded-xl p-4 border animate-fade-in" style={{ background: 'rgba(239,68,68,0.06)', borderColor: 'rgba(239,68,68,0.2)', color: '#f87171' }}>
{error}
</div>
)}
{/* Loading spinner */}
{loading && (
<div className="flex flex-col items-center justify-center py-16 animate-fade-in">
<div className="h-12 w-12 border-2 rounded-full animate-spin mb-4" style={{ borderColor: 'rgba(255,255,255,0.1)', borderTopColor: 'var(--pc-accent)' }}/>
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>{t('doctor.running_desc')}</p>
<p className="text-[13px] mt-1" style={{ color: 'var(--pc-text-faint)' }}>
{t('doctor.running_hint')}
</p>
</div>
)}
{/* Results */}
{results && !loading && (
<>
{/* Summary Bar */}
<div className="flex items-center gap-4 p-4 rounded-xl border animate-fade-in" style={{ background: 'var(--pc-bg-surface)', borderColor: 'var(--pc-border)' }}>
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5" style={{ color: 'var(--color-status-success)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
{okCount}{' '}<span className="text-sm font-normal" style={{ color: 'var(--pc-text-muted)' }}>ok</span>
</span>
</div>
<div className="w-px h-5" style={{ background: 'var(--pc-border)' }} />
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5" style={{ color: 'var(--color-status-warning)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
{warnCount}{' '}
<span className="text-sm font-normal" style={{ color: 'var(--pc-text-muted)' }}>
warning{warnCount !== 1 ? 's' : ''}
</span>
</span>
</div>
<div className="w-px h-5" style={{ background: 'var(--pc-border)' }} />
<div className="flex items-center gap-2">
<XCircle className="h-5 w-5" style={{ color: 'var(--color-status-error)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
{errorCount}{' '}
<span className="text-sm font-normal" style={{ color: 'var(--pc-text-muted)' }}>
error{errorCount !== 1 ? 's' : ''}
</span>
</span>
</div>
{/* Overall indicator */}
<div className="ml-auto">
{errorCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border" style={{ background: 'rgba(239,68,68,0.06)', borderColor: 'rgba(239,68,68,0.3)', color: '#f87171' }}>
{t('doctor.issues_found')}
</span>
) : warnCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border" style={{ background: 'rgba(255,170,0,0.06)', borderColor: 'rgba(255,170,0,0.3)', color: '#fbbf24' }}>
{t('doctor.warnings_summary')}
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border" style={{ background: 'rgba(0,230,138,0.06)', borderColor: 'rgba(0,230,138,0.3)', color: '#34d399' }}>
{t('doctor.all_clear')}
</span>
)}
</div>
</div>
{/* Grouped Results */}
{Object.entries(grouped)
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, items]) => (
<div key={category}>
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3 capitalize" style={{ color: 'var(--pc-text-muted)' }}>
{category}
</h3>
<div className="space-y-2">
{items.map((result, idx) => (
<div
key={`${category}-${idx}`}
className={`flex items-start gap-3 rounded-xl border p-3 ${severityBorder(result.severity,)} ${severityBg(result.severity)}`}
>
{severityIcon(result.severity)}
<div className="min-w-0">
<p className="text-sm" style={{ color: 'var(--pc-text-primary)' }}>{result.message}</p>
<p className="text-xs capitalize mt-0.5" style={{ color: 'var(--pc-text-faint)' }}>
{result.severity}
</p>
</div>
</div>
))}
</div>
</div>
))}
</>
)}
{/* Empty state */}
{!results && !loading && !error && (
<div className="flex flex-col items-center justify-center py-16 text-[var(--pc-text-muted)]">
<div className="h-16 w-16 rounded-2xl flex items-center justify-center mb-4 animate-float" style={{ background: 'linear-gradient(135deg, var(--pc-accent-glow), transparent)' }}>
<Stethoscope className="h-8 w-8" style={{ color: 'var(--pc-accent)' }} />
</div>
<p className="text-lg font-semibold mb-1" style={{ color: 'var(--pc-text-primary)' }}>{t('doctor.system_diagnostics')}</p>
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>
{t('doctor.empty_hint')}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useState, useEffect } from 'react';
import { Puzzle, Check, Zap, Clock } from 'lucide-react';
import type { Integration } from '@/types/api';
import { getIntegrations } from '@/lib/api';
import { t } from '@/lib/i18n';
function statusBadge(status: Integration['status']) {
switch (status) {
case 'Active':
return {
icon: Check,
label: t('integrations.status_active'),
color: 'var(--color-status-success)',
border: 'rgba(0, 230, 138, 0.2)',
bg: 'rgba(0, 230, 138, 0.06)'
};
case 'Available':
return {
icon: Zap,
label: t('integrations.status_available'),
color: 'var(--pc-accent)',
border: 'var(--pc-accent-dim)',
bg: 'var(--pc-accent-glow)'
};
case 'ComingSoon':
return {
icon: Clock,
label: t('integrations.status_coming_soon'),
color: 'var(--pc-text-muted)',
border: 'var(--pc-border)',
bg: 'transparent'
};
}
}
export default function Integrations() {
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeCategory, setActiveCategory] = useState<string>('all');
useEffect(() => {
getIntegrations().then(setIntegrations).catch((err) => setError(err.message)).finally(() => setLoading(false));
}, []);
const categories = ['all',
...Array.from(new Set(integrations.map((i) => i.category))).sort()
];
const filtered =
activeCategory === 'all'
? integrations
: integrations.filter((i) => i.category === activeCategory);
// Group by category for display
const grouped = filtered.reduce<Record<string, Integration[]>>((acc, item) => {
const key = item.category;
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {});
if (error) {
return (
<div className="p-6 animate-fade-in">
<div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{t('integrations.load_error')}: {error}
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div>
);
}
return (
<div className="p-6 space-y-6 animate-fade-in">
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('integrations.title')} ({integrations.length})
</h2>
</div>
{/* Category Filter Tabs */}
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className="px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all capitalize"
style={activeCategory === cat
? { background: 'var(--pc-accent)', color: 'white' }
: { color: 'var(--pc-text-muted)', border: '1px solid var(--pc-border)', background: 'transparent' }
}
>
{cat}
</button>
))}
</div>
{/* Grouped Integration Cards */}
{Object.keys(grouped).length === 0 ? (
<div className="card p-8 text-center">
<Puzzle className="h-10 w-10 mx-auto mb-3" style={{ color: 'var(--pc-text-faint)' }} />
<p style={{ color: 'var(--pc-text-muted)' }}>{t('integrations.empty')}</p>
</div>
) : (
Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([category, items]) => (
<div key={category}>
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-3 capitalize" style={{ color: 'var(--pc-text-faint)' }}>
{category}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
{items.map((integration) => {
const badge = statusBadge(integration.status);
const BadgeIcon = badge.icon;
return (
<div
key={integration.name}
className="card p-5 animate-slide-in-up"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h4 className="text-sm font-semibold truncate" style={{ color: 'var(--pc-text-primary)' }}>
{integration.name}
</h4>
<p className="text-sm mt-1 line-clamp-2" style={{ color: 'var(--pc-text-muted)' }}>
{integration.description}
</p>
</div>
<span
className="flex-shrink-0 inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-semibold border"
style={badge}
>
<BadgeIcon className="h-3 w-3" />
{badge.label}
</span>
</div>
</div>
);
})}
</div>
</div>
))
)}
</div>
);
}

View File

@@ -0,0 +1,282 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Activity,
Pause,
Play,
ArrowDown,
Filter,
} from 'lucide-react';
import type { SSEEvent } from '@/types/api';
import { SSEClient } from '@/lib/sse';
import { t } from '@/lib/i18n';
function formatTimestamp(ts?: string): string {
if (!ts) return new Date().toLocaleTimeString();
return new Date(ts).toLocaleTimeString();
}
function eventTypeStyle(type: string): { color: string; bg: string; border: string } {
switch (type.toLowerCase()) {
case 'error':
return { color: 'var(--color-status-error)', bg: 'rgba(239, 68, 68, 0.06)', border: 'rgba(239, 68, 68, 0.2)' };
case 'warn':
case 'warning':
return { color: 'var(--color-status-warning)', bg: 'rgba(255, 170, 0, 0.06)', border: 'rgba(255, 170, 0, 0.2)' };
case 'tool_call':
case 'tool_result':
case 'tool_call_start':
return { color: '#a78bfa', bg: 'rgba(167, 139, 250, 0.06)', border: 'rgba(167, 139, 250, 0.2)' };
case 'llm_request':
return { color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.06)', border: 'rgba(56, 189, 248, 0.2)' };
case 'agent_start':
case 'agent_end':
return { color: '#34d399', bg: 'rgba(52, 211, 153, 0.06)', border: 'rgba(52, 211, 153, 0.2)' };
case 'message':
case 'chat':
return { color: 'var(--pc-accent)', bg: 'var(--pc-accent-glow)', border: 'var(--pc-accent-dim)' };
case 'health':
case 'status':
return { color: 'var(--color-status-success)', bg: 'rgba(0, 230, 138, 0.06)', border: 'rgba(0, 230, 138, 0.2)' };
default:
return { color: 'var(--pc-text-muted)', bg: 'var(--pc-hover)', border: 'var(--pc-border)' };
}
}
interface LogEntry { id: string; event: SSEEvent; }
export default function Logs() {
const [entries, setEntries] = useState<LogEntry[]>([]);
const [paused, setPaused] = useState(false);
const [connected, setConnected] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
const [infoDismissed, setInfoDismissed] = useState(false);
const [typeFilters, setTypeFilters] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const sseRef = useRef<SSEClient | null>(null);
const pausedRef = useRef(false);
const entryIdRef = useRef(0);
// Keep pausedRef in sync
useEffect(() => { pausedRef.current = paused; }, [paused]);
useEffect(() => {
const client = new SSEClient();
client.onConnect = () => {
setConnected(true);
};
client.onError = () => {
setConnected(false);
};
client.onEvent = (event: SSEEvent) => {
if (pausedRef.current) return;
entryIdRef.current += 1;
const entry: LogEntry = {
id: `log-${entryIdRef.current}`,
event,
};
setEntries((prev) => {
const next = [...prev, entry];
return next.length > 500 ? next.slice(-500) : next;
});
};
client.connect();
sseRef.current = client;
return () => {
client.disconnect();
};
}, []);
// Auto-scroll to bottom
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [entries, autoScroll]);
// Detect user scroll to toggle auto-scroll
const handleScroll = useCallback(() => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setAutoScroll(isAtBottom);
}, []);
const jumpToBottom = () => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
setAutoScroll(true);
};
const allTypes = Array.from(new Set(entries.map((e) => e.event.type))).sort();
const toggleTypeFilter = (type: string) => {
setTypeFilters((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
const filteredEntries = typeFilters.size === 0 ? entries : entries.filter((e) => typeFilters.has(e.event.type));
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center justify-between px-6 py-3 border-b animate-fade-in" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-surface)' }}>
<div className="flex items-center gap-3">
<Activity className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>{t('logs.live_logs')}</h2>
<span className="text-[10px] font-mono ml-2" style={{ color: 'var(--pc-text-faint)' }}>
{filteredEntries.length} {t('logs.events')}
</span>
</div>
<div className="flex items-center gap-2">
{/* Pause/Resume */}
<button
onClick={() => setPaused(!paused)}
className="btn-electric flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold"
style={{ background: paused ? 'var(--color-status-success)' : 'var(--color-status-warning)', color: 'white' }}
>
{paused ? (
<>
<Play className="h-3.5 w-3.5" /> {t('logs.resume')}
</>
) : (
<>
<Pause className="h-3.5 w-3.5" /> {t('logs.pause')}
</>
)}
</button>
{/* Jump to Bottom */}
{!autoScroll && (
<button onClick={jumpToBottom} className="btn-electric flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold">
<ArrowDown className="h-3.5 w-3.5" />{t('logs.jump_to_bottom')}
</button>
)}
</div>
</div>
{/* Event type filters */}
{allTypes.length > 0 && (
<div className="flex items-center gap-2 px-6 py-2 border-b overflow-x-auto" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-base)' }}>
<Filter className="h-3.5 w-3.5 flex-shrink-0" style={{ color: 'var(--pc-text-faint)' }} />
<span className="text-[10px] uppercase tracking-wider flex-shrink-0" style={{ color: 'var(--pc-text-faint)' }}>{t('logs.filter_label')}:</span>
{allTypes.map((type) => (
<label key={type} className="flex items-center gap-1.5 cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={typeFilters.has(type)}
onChange={() => toggleTypeFilter(type)}
className="rounded"
style={{ accentColor: 'var(--pc-accent)' }}
/>
<span className="text-[10px] capitalize" style={{ color: 'var(--pc-text-muted)' }}>{type}</span>
</label>
))}
{typeFilters.size > 0 && (
<button
onClick={() => setTypeFilters(new Set())}
className="text-[10px] flex-shrink-0 ml-1 transition-colors"
style={{ color: 'var(--pc-accent)' }}>
{t('logs.clear')}
</button>
)}
</div>
)}
{/* Informational banner — what appears here and what does not */}
{!infoDismissed && (
<div className="flex items-start gap-3 px-6 py-3 border-b flex-shrink-0" style={{ borderColor: 'rgba(56, 189, 248, 0.2)', background: 'rgba(56, 189, 248, 0.05)' }}>
<div className="flex-1 text-xs" style={{ color: 'var(--pc-text-secondary)' }}>
<span className="font-semibold" style={{ color: '#38bdf8' }}>What appears here: </span>
agent activity over SSE LLM requests, tool calls, agent start/end, and errors.
{' '}<span className="font-semibold" style={{ color: 'var(--pc-text-muted)' }}>What does not: </span>
daemon stdout and <code>RUST_LOG</code> tracing output go to the terminal or log file, not this stream.
{' '}To see tracing logs, run the daemon with <code>RUST_LOG=info zeroclaw</code> and check your terminal.
</div>
<button
onClick={() => setInfoDismissed(true)}
className="flex-shrink-0 text-[10px] btn-icon"
aria-label="Dismiss"
style={{ color: 'var(--pc-text-faint)' }}
>
</button>
</div>
)}
{/* Log entries */}
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-4 space-y-2 min-h-0"
>
{filteredEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full animate-fade-in" style={{ color: 'var(--pc-text-muted)' }}>
<Activity className="h-10 w-10 mb-3" style={{ color: 'var(--pc-text-faint)' }} />
<p className="text-sm">
{paused
? t('logs.paused_hint')
: t('logs.waiting_hint')}
</p>
</div>
) : (
filteredEntries.map((entry) => {
const { event } = entry;
const style = eventTypeStyle(event.type);
const detail =
event.message ??
event.content ??
event.data ??
JSON.stringify(
Object.fromEntries(
Object.entries(event).filter(
([k]) => k !== 'type' && k !== 'timestamp',
),
),
);
return (
<div
key={entry.id}
className="card rounded-xl p-3"
>
<div className="flex items-start gap-3">
<span className="text-[10px] font-mono whitespace-nowrap mt-0.5" style={{ color: 'var(--pc-text-faint)' }}>
{formatTimestamp(event.timestamp)}
</span>
<span
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border capitalize flex-shrink-0"
style={style}
>
{event.type}
</span>
<p className="text-sm break-all min-w-0" style={{ color: 'var(--pc-text-secondary)' }}>
{typeof detail === 'string' ? detail : JSON.stringify(detail)}
</p>
</div>
</div>
);
})
)}
</div>
{/* Footer: connection status */}
<div className="flex items-center justify-center gap-2 px-6 py-2 border-t flex-shrink-0" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-surface)' }}>
<span className="status-dot" style={
connected ? { background: 'var(--color-status-success)', boxShadow: '0 0 6px var(--color-status-success)' } : { background: 'var(--color-status-error)', boxShadow: '0 0 6px var(--color-status-error)' }
} />
<span className="text-[10px]" style={{ color: 'var(--pc-text-faint)' }}>
{connected ? t('logs.connected') : t('logs.disconnected')}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,275 @@
import { useState, useEffect } from 'react';
import {
Brain,
Search,
Plus,
Trash2,
X,
Filter,
} from 'lucide-react';
import type { MemoryEntry } from '@/types/api';
import { getMemory, storeMemory, deleteMemory } from '@/lib/api';
import { t } from '@/lib/i18n';
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max) + '...';
}
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString();
}
export default function Memory() {
const [entries, setEntries] = useState<MemoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [showForm, setShowForm] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
// Form state
const [formKey, setFormKey] = useState('');
const [formContent, setFormContent] = useState('');
const [formCategory, setFormCategory] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const fetchEntries = (q?: string, cat?: string) => {
setLoading(true);
getMemory(q || undefined, cat || undefined)
.then(setEntries)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
};
useEffect(() => {
fetchEntries();
}, []);
const handleSearch = () => {
fetchEntries(search, categoryFilter);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSearch();
};
const categories = Array.from(new Set(entries.map((e) => e.category))).sort();
const handleAdd = async () => {
if (!formKey.trim() || !formContent.trim()) { setFormError(t('memory.validation_error')); return; }
setSubmitting(true);
setFormError(null);
try {
await storeMemory(
formKey.trim(),
formContent.trim(),
formCategory.trim() || undefined,
);
fetchEntries(search, categoryFilter);
setShowForm(false);
setFormKey(''); setFormContent(''); setFormCategory('');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : t('memory.store_error'));
} finally { setSubmitting(false); }
};
const handleDelete = async (key: string) => {
try {
await deleteMemory(key);
setEntries((prev) => prev.filter((e) => e.key !== key));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t('memory.delete_error'));
} finally {
setConfirmDelete(null);
}
};
if (error && entries.length === 0) {
return (
<div className="p-6 animate-fade-in">
<div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{t('memory.load_error')}: {error}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full p-6 gap-6 animate-fade-in overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Brain className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('memory.memory_title')} ({entries.length})
</h2>
</div>
<button onClick={() => setShowForm(true)} className="btn-electric flex items-center gap-2 text-sm px-4 py-2">
<Plus className="h-4 w-4" />{t('memory.add_memory')}
</button>
</div>
{/* Search and Filter */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: 'var(--pc-text-faint)' }} />
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={handleKeyDown} placeholder={t('memory.search_placeholder')} className="input-electric w-full pl-10 pr-4 py-2.5 text-sm" />
</div>
<div className="relative">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: 'var(--pc-text-faint)' }} />
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} className="input-electric pl-10 pr-8 py-2.5 text-sm appearance-none cursor-pointer">
<option value="">{t('memory.all_categories')}</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>
<button onClick={handleSearch} className="btn-electric px-4 py-2.5 text-sm">{t('memory.search_button')}</button>
</div>
{/* Error banner (non-fatal) */}
{error && (
<div className="rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{error}
</div>
)}
{/* Add Memory Form Modal */}
{showForm && (
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">
<div className="surface-panel p-6 w-full max-w-md mx-4 animate-fade-in-scale">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold" style={{ color: 'var(--pc-text-primary)' }}>{t('memory.add_modal_title')}</h3>
<button
onClick={() => {
setShowForm(false);
setFormError(null);
}}
className="btn-icon">
<X className="h-5 w-5" />
</button>
</div>
{formError && (
<div className="mb-4 rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{formError}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('memory.key_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
</label>
<input type="text" value={formKey} onChange={(e) => setFormKey(e.target.value)} placeholder="e.g. user_preferences" className="input-electric w-full px-3 py-2.5 text-sm" />
</div>
<div>
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('memory.content_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
</label>
<textarea value={formContent} onChange={(e) => setFormContent(e.target.value)} placeholder="Memory content..." rows={4} className="input-electric w-full px-3 py-2.5 text-sm resize-none" />
</div>
<div>
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
{t('memory.category_optional')}
</label>
<input type="text" value={formCategory} onChange={(e) => setFormCategory(e.target.value)} placeholder="e.g. preferences, context, facts" className="input-electric w-full px-3 py-2.5 text-sm" />
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => {
setShowForm(false);
setFormError(null);
}}
className="btn-secondary px-4 py-2 text-sm font-medium"
>
{t('memory.cancel')}
</button>
<button
onClick={handleAdd} disabled={submitting} className="btn-electric px-4 py-2 text-sm font-medium">{submitting ? t('memory.saving') : t('common.save')}</button>
</div>
</div>
</div>
)}
{/* Memory Table */}
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div>
) : entries.length === 0 ? (
<div className="card p-8 text-center">
<Brain className="h-10 w-10 mx-auto mb-3" style={{ color: 'var(--pc-text-faint)' }} />
<p style={{ color: 'var(--pc-text-muted)' }}>{t('memory.empty')}</p>
</div>
) : (
<div className="card overflow-x-auto rounded-2xl">
<table className="table-electric">
<thead>
<tr>
<th>{t('memory.key')}</th>
<th>{t('memory.content')}</th>
<th>{t('memory.category')}</th>
<th>{t('memory.timestamp')}</th>
<th className="text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id}>
<td className="font-mono text-xs" style={{ color: 'var(--pc-text-primary)' }}>
{entry.key}
</td>
<td className="max-w-[300px] text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
<span title={entry.content}>
{truncate(entry.content, 80)}
</span>
</td>
<td>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold capitalize border" style={{ borderColor: 'var(--pc-border)', color: 'var(--pc-text-secondary)', background: 'var(--pc-accent-glow)' }}>
{entry.category}
</span>
</td>
<td className="text-xs whitespace-nowrap" style={{ color: 'var(--pc-text-muted)' }}>
{formatDate(entry.timestamp)}
</td>
<td className="text-right">
{confirmDelete === entry.key ? (
<div className="flex items-center justify-end gap-2 animate-fade-in">
<span className="text-xs" style={{ color: 'var(--color-status-error)' }}>
{t('memory.delete_confirm')}
</span>
<button
onClick={() => handleDelete(entry.key)}
className="text-xs font-medium" style={{ color: 'var(--color-status-error)' }}
>
{t('memory.yes')}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs font-medium" style={{ color: 'var(--pc-text-muted)' }}>
{t('memory.no')}
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(entry.key)}
className="btn-icon"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { useState, useEffect, useCallback } from 'react';
import { Smartphone, Trash2 } from 'lucide-react';
import { getAdminPairCode } from '../lib/api';
import { t } from '@/lib/i18n';
interface Device {
id: string;
name: string | null;
device_type: string | null;
paired_at: string;
last_seen: string;
ip_address: string | null;
}
export default function Pairing() {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [pairingCode, setPairingCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const token = localStorage.getItem('zeroclaw_token') || '';
const fetchDevices = useCallback(async () => {
try {
const res = await fetch('/api/devices', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setDevices(data.devices || []);
}
} catch (err) {
setError('Failed to load devices');
} finally {
setLoading(false);
}
}, [token]);
// Fetch the current pairing code on mount (if one is active)
useEffect(() => {
getAdminPairCode()
.then((data) => {
if (data.pairing_code) {
setPairingCode(data.pairing_code);
}
})
.catch(() => {
// Admin endpoint not reachable — code will show after clicking "Pair New Device"
});
}, []);
useEffect(() => { fetchDevices(); }, [fetchDevices]);
const handleInitiatePairing = async () => {
try {
const res = await fetch('/api/pairing/initiate', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setPairingCode(data.pairing_code);
} else {
setError('Failed to generate pairing code');
}
} catch (err) {
setError('Failed to generate pairing code');
}
};
const handleRevokeDevice = async (deviceId: string) => {
try {
const res = await fetch(`/api/devices/${deviceId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
setDevices(devices.filter(d => d.id !== deviceId));
}
} catch (err) {
setError('Failed to revoke device');
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div>
);
}
return (
<div className="p-6 space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
{t('pairing.title')}
</h2>
<button
onClick={handleInitiatePairing}
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
>
<Smartphone className="h-4 w-4" />
{t('pairing.pair_new_device')}
</button>
</div>
{error && (
<div className="rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{error}
<button onClick={() => setError(null)} className="ml-2 font-bold">×</button>
</div>
)}
{pairingCode && (
<div className="card p-6 text-center rounded-2xl">
<p className="text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--pc-text-muted)' }}>{t('pairing.pairing_code')}</p>
<div className="text-4xl font-mono font-bold tracking-[0.4em] py-4" style={{ color: 'var(--pc-text-primary)' }}>
{pairingCode}
</div>
<p className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>{t('pairing.code_hint')}</p>
</div>
)}
<div className="card rounded-2xl overflow-hidden">
<div className="px-5 py-4 border-b" style={{ borderColor: 'var(--pc-border)' }}>
<h3 className="text-sm font-semibold" style={{ color: 'var(--pc-text-primary)' }}>
{t('pairing.paired_devices')} ({devices.length})
</h3>
</div>
{devices.length === 0 ? (
<div className="p-8 text-center" style={{ color: 'var(--pc-text-muted)' }}>
{t('pairing.no_devices')}
</div>
) : (
<table className="table-electric">
<thead>
<tr>
<th>{t('pairing.name')}</th>
<th>{t('pairing.type')}</th>
<th>{t('pairing.paired')}</th>
<th>{t('pairing.last_seen')}</th>
<th>IP</th>
<th className="text-right">{t('pairing.actions')}</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr key={device.id}>
<td style={{ color: 'var(--pc-text-primary)' }}>{device.name || 'Unnamed'}</td>
<td style={{ color: 'var(--pc-text-secondary)' }}>{device.device_type || 'Unknown'}</td>
<td className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>
{new Date(device.paired_at).toLocaleDateString()}
</td>
<td className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>
{new Date(device.last_seen).toLocaleString()}
</td>
<td className="font-mono text-xs" style={{ color: 'var(--pc-text-secondary)' }}>
{device.ip_address || '-'}
</td>
<td className="text-right">
<button
onClick={() => handleRevokeDevice(device.id)}
className="btn-icon"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,202 @@
import { useState, useEffect } from 'react';
import {
Wrench,
Search,
ChevronDown,
ChevronRight,
Terminal,
Package,
} from 'lucide-react';
import type { ToolSpec, CliTool } from '@/types/api';
import { getTools, getCliTools } from '@/lib/api';
import { t } from '@/lib/i18n';
export default function Tools() {
const [tools, setTools] = useState<ToolSpec[]>([]);
const [cliTools, setCliTools] = useState<CliTool[]>([]);
const [search, setSearch] = useState('');
const [expandedTool, setExpandedTool] = useState<string | null>(null);
const [agentSectionOpen, setAgentSectionOpen] = useState(true);
const [cliSectionOpen, setCliSectionOpen] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
Promise.all([getTools(), getCliTools()])
.then(([t, c]) => { setTools(t); setCliTools(c); })
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const filtered = tools.filter((t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.description.toLowerCase().includes(search.toLowerCase()),
);
const filteredCli = cliTools.filter((t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.category.toLowerCase().includes(search.toLowerCase()),
);
if (error) {
return (
<div className="p-6 animate-fade-in">
<div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
{t('tools.load_error')}: {error}
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
</div>
);
}
return (
<div className="p-6 space-y-6 animate-fade-in">
{/* Search */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: 'var(--pc-text-faint)' }} />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('tools.search')}
className="input-electric w-full pl-10 pr-4 py-2.5 text-sm"
/>
</div>
{/* Agent Tools Grid */}
<div>
<button
onClick={() => setAgentSectionOpen((v) => !v)}
className="flex items-center gap-2 mb-4 w-full text-left group"
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: 0 }}
aria-expanded={agentSectionOpen}
aria-controls="agent-tools-section"
>
<Wrench className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
<span className="text-sm font-semibold uppercase tracking-wider flex-1" role="heading" aria-level={2} style={{ color: 'var(--pc-text-primary)' }}>
{t('tools.agent_tools')} ({filtered.length})
</span>
<ChevronDown
className="h-4 w-4 opacity-40 group-hover:opacity-100"
style={{ color: 'var(--pc-text-muted)', transform: agentSectionOpen ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 0.2s ease, opacity 0.2s ease' }}
/>
</button>
<div id="agent-tools-section">
{agentSectionOpen && (filtered.length === 0 ? (
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>{t('tools.empty')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
{filtered.map((tool) => {
const isExpanded = expandedTool === tool.name;
return (
<div
key={tool.name}
className="card overflow-hidden animate-slide-in-up"
>
<button
onClick={() => setExpandedTool(isExpanded ? null : tool.name)}
className="w-full text-left p-4 transition-all"
style={{ background: 'transparent' }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Package className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--pc-accent)' }} />
<h3 className="text-sm font-semibold truncate" style={{ color: 'var(--pc-text-primary)' }}>{tool.name}</h3>
</div>
{isExpanded
? <ChevronDown className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--pc-accent)' }} />
: <ChevronRight className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--pc-text-faint)' }} />
}
</div>
<p className="text-sm mt-2 line-clamp-2" style={{ color: 'var(--pc-text-muted)' }}>
{tool.description}
</p>
</button>
{isExpanded && tool.parameters && (
<div className="border-t p-4 animate-fade-in" style={{ borderColor: 'var(--pc-border)' }}>
<p className="text-[10px] font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--pc-text-muted)' }}>
{t('tools.parameter_schema')}
</p>
<pre className="text-xs rounded-xl p-3 overflow-x-auto max-h-64 overflow-y-auto font-mono" style={{ background: 'var(--pc-bg-base)', color: 'var(--pc-text-secondary)' }}>
{JSON.stringify(tool.parameters, null, 2)}
</pre>
</div>
)}
</div>
);
})}
</div>
))}
</div>
</div>
{/* CLI Tools Section */}
{filteredCli.length > 0 && (
<div className="animate-slide-in-up" style={{ animationDelay: '200ms' }}>
<button
onClick={() => setCliSectionOpen((v) => !v)}
className="flex items-center gap-2 mb-4 w-full text-left group"
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: 0 }}
aria-expanded={cliSectionOpen}
aria-controls="cli-tools-section"
>
<Terminal className="h-5 w-5" style={{ color: 'var(--color-status-success)' }} />
<span className="text-sm font-semibold uppercase tracking-wider flex-1" role="heading" aria-level={2} style={{ color: 'var(--pc-text-primary)' }}>
{t('tools.cli_tools')} ({filteredCli.length})
</span>
<ChevronDown
className="h-4 w-4 opacity-40 group-hover:opacity-100"
style={{ color: 'var(--pc-text-muted)', transform: cliSectionOpen ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 0.2s ease, opacity 0.2s ease' }}
/>
</button>
<div id="cli-tools-section">
{cliSectionOpen && <div className="card overflow-hidden rounded-2xl">
<table className="table-electric">
<thead>
<tr>
<th>{t('tools.name')}</th>
<th>{t('tools.path')}</th>
<th>{t('tools.version')}</th>
<th>{t('tools.category')}</th>
</tr>
</thead>
<tbody>
{filteredCli.map((tool) => (
<tr key={tool.name}>
<td className="font-medium text-sm" style={{ color: 'var(--pc-text-primary)' }}>
{tool.name}
</td>
<td className="font-mono text-xs truncate max-w-[200px]" style={{ color: 'var(--pc-text-muted)' }}>
{tool.path}
</td>
<td style={{ color: 'var(--pc-text-muted)' }}>
{tool.version ?? '-'}
</td>
<td>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold capitalize border" style={{ borderColor: 'var(--pc-border)', color: 'var(--pc-text-secondary)', background: 'var(--pc-accent-glow)' }}>
{tool.category}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
</div>
)}
</div>
);
}