feat: refactor sgclaw around zeroclaw compat runtime
This commit is contained in:
409
third_party/zeroclaw/web/src/pages/AgentChat.tsx
vendored
Normal file
409
third_party/zeroclaw/web/src/pages/AgentChat.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
355
third_party/zeroclaw/web/src/pages/Canvas.tsx
vendored
Normal file
355
third_party/zeroclaw/web/src/pages/Canvas.tsx
vendored
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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>
|
||||
);
|
||||
}
|
||||
233
third_party/zeroclaw/web/src/pages/Config.tsx
vendored
Normal file
233
third_party/zeroclaw/web/src/pages/Config.tsx
vendored
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
149
third_party/zeroclaw/web/src/pages/Cost.tsx
vendored
Normal file
149
third_party/zeroclaw/web/src/pages/Cost.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
531
third_party/zeroclaw/web/src/pages/Cron.tsx
vendored
Normal file
531
third_party/zeroclaw/web/src/pages/Cron.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
921
third_party/zeroclaw/web/src/pages/Dashboard.tsx
vendored
Normal file
921
third_party/zeroclaw/web/src/pages/Dashboard.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
216
third_party/zeroclaw/web/src/pages/Doctor.tsx
vendored
Normal file
216
third_party/zeroclaw/web/src/pages/Doctor.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
153
third_party/zeroclaw/web/src/pages/Integrations.tsx
vendored
Normal file
153
third_party/zeroclaw/web/src/pages/Integrations.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
282
third_party/zeroclaw/web/src/pages/Logs.tsx
vendored
Normal file
282
third_party/zeroclaw/web/src/pages/Logs.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
275
third_party/zeroclaw/web/src/pages/Memory.tsx
vendored
Normal file
275
third_party/zeroclaw/web/src/pages/Memory.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
177
third_party/zeroclaw/web/src/pages/Pairing.tsx
vendored
Normal file
177
third_party/zeroclaw/web/src/pages/Pairing.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
202
third_party/zeroclaw/web/src/pages/Tools.tsx
vendored
Normal file
202
third_party/zeroclaw/web/src/pages/Tools.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user