feat: refactor sgclaw around zeroclaw compat runtime

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

255
third_party/zeroclaw/web/src/App.tsx vendored Normal file
View File

@@ -0,0 +1,255 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useState, useEffect, createContext, useContext, Component, type ReactNode, type ErrorInfo } from 'react';
import { ThemeProvider } from './contexts/ThemeContext';
import Layout from './components/layout/Layout';
import Dashboard from './pages/Dashboard';
import AgentChat from './pages/AgentChat';
import Tools from './pages/Tools';
import Cron from './pages/Cron';
import Integrations from './pages/Integrations';
import Memory from './pages/Memory';
import Config from './pages/Config';
import Cost from './pages/Cost';
import Logs from './pages/Logs';
import Doctor from './pages/Doctor';
import Pairing from './pages/Pairing';
import Canvas from './pages/Canvas';
import { AuthProvider, useAuth } from './hooks/useAuth';
import { DraftContext, useDraftStore } from './hooks/useDraft';
import { setLocale, type Locale } from './lib/i18n';
import { basePath } from './lib/basePath';
import { getAdminPairCode } from './lib/api';
// Locale context
interface LocaleContextType {
locale: string;
setAppLocale: (locale: string) => void;
}
export const LocaleContext = createContext<LocaleContextType>({
locale: 'en',
setAppLocale: () => {},
});
export const useLocaleContext = () => useContext(LocaleContext);
// ---------------------------------------------------------------------------
// Error boundary — catches render crashes and shows a recoverable message
// instead of a black screen
// ---------------------------------------------------------------------------
interface ErrorBoundaryState {
error: Error | null;
}
export class ErrorBoundary extends Component<
{ children: ReactNode },
ErrorBoundaryState
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[ZeroClaw] Render error:', error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div className="p-6">
<div className="card p-6 w-full max-w-lg" style={{ borderColor: 'rgba(239, 68, 68, 0.3)' }}>
<h2 className="text-lg font-semibold mb-2" style={{ color: 'var(--color-status-error)' }}>
Something went wrong
</h2>
<p className="text-sm mb-4" style={{ color: 'var(--pc-text-muted)' }}>
A render error occurred. Check the browser console for details.
</p>
<pre className="text-xs rounded-lg p-3 overflow-x-auto whitespace-pre-wrap break-all font-mono" style={{ background: 'var(--pc-bg-base)', color: 'var(--color-status-error)' }}>
{this.state.error.message}
</pre>
<button
onClick={() => this.setState({ error: null })}
className="btn-electric mt-6 px-4 py-2 text-sm font-medium"
>
Try again
</button>
</div>
</div>
);
}
return this.props.children;
}
}
// Pairing dialog component
function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> }) {
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [displayCode, setDisplayCode] = useState<string | null>(null);
const [codeLoading, setCodeLoading] = useState(true);
// Fetch the current pairing code from the admin endpoint (localhost only)
useEffect(() => {
let cancelled = false;
getAdminPairCode()
.then((data) => {
if (!cancelled && data.pairing_code) {
setDisplayCode(data.pairing_code);
}
})
.catch(() => {
// Admin endpoint not reachable (non-localhost) — user must check terminal
})
.finally(() => {
if (!cancelled) setCodeLoading(false);
});
return () => { cancelled = true; };
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await onPair(code);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pairing failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--pc-bg-base)' }}>
{/* Ambient glow */}
<div className="relative surface-panel p-8 w-full max-w-md animate-fade-in-scale">
<div className="text-center mb-8">
<img
src={`${basePath}/_app/zeroclaw-trans.png`}
alt="ZeroClaw"
className="h-20 w-20 rounded-2xl object-cover mx-auto mb-4 animate-float"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<h1 className="text-2xl font-bold mb-2 text-gradient-blue">ZeroClaw</h1>
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>
{displayCode ? 'Your pairing code' : 'Enter the pairing code from your terminal'}
</p>
</div>
{/* Show the pairing code if available (localhost) */}
{!codeLoading && displayCode && (
<div className="mb-6 p-4 rounded-2xl text-center border" style={{ background: 'var(--pc-accent-glow)', borderColor: 'var(--pc-accent-dim)' }}>
<div className="text-4xl font-mono font-bold tracking-[0.4em] py-2" style={{ color: 'var(--pc-text-primary)' }}>
{displayCode}
</div>
<p className="text-xs mt-2" style={{ color: 'var(--pc-text-muted)' }}>Enter this code below or on another device</p>
</div>
)}
<form onSubmit={handleSubmit}>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6-digit code"
className="input-electric w-full px-4 py-4 text-center text-2xl tracking-[0.3em] font-medium mb-4"
maxLength={6}
autoFocus
/>
{error && (
<p aria-live="polite" className="text-sm mb-4 text-center animate-fade-in" style={{ color: 'var(--color-status-error)' }}>{error}</p>
)}
<button
type="submit"
disabled={loading || code.length < 6}
className="btn-electric w-full py-3.5 text-sm font-semibold tracking-wide"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Pairing...
</span>
) : 'Pair'}
</button>
</form>
</div>
</div>
);
}
function AppContent() {
const { isAuthenticated, requiresPairing, loading, pair, logout } = useAuth();
const [locale, setLocaleState] = useState('en');
const draftStore = useDraftStore();
const setAppLocale = (newLocale: string) => {
setLocaleState(newLocale);
setLocale(newLocale as Locale);
};
// Listen for 401 events to force logout
useEffect(() => {
const handler = () => {
logout();
};
window.addEventListener('zeroclaw-unauthorized', handler);
return () => window.removeEventListener('zeroclaw-unauthorized', handler);
}, [logout]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--pc-bg-base)' }}>
<div className="flex flex-col items-center gap-4 animate-fade-in">
<div className="h-10 w-10 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>Connecting...</p>
</div>
</div>
);
}
if (!isAuthenticated && requiresPairing) {
return <PairingDialog onPair={pair} />;
}
return (
<DraftContext.Provider value={draftStore}>
<LocaleContext.Provider value={{ locale, setAppLocale }}>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/agent" element={<AgentChat />} />
<Route path="/tools" element={<Tools />} />
<Route path="/cron" element={<Cron />} />
<Route path="/integrations" element={<Integrations />} />
<Route path="/memory" element={<Memory />} />
<Route path="/config" element={<Config />} />
<Route path="/cost" element={<Cost />} />
<Route path="/logs" element={<Logs />} />
<Route path="/doctor" element={<Doctor />} />
<Route path="/pairing" element={<Pairing />} />
<Route path="/canvas" element={<Canvas />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</LocaleContext.Provider>
</DraftContext.Provider>
);
}
export default function App() {
return (
<AuthProvider>
<ThemeProvider>
<AppContent />
</ThemeProvider>
</AuthProvider>
);
}

View File

@@ -0,0 +1,447 @@
import { useEffect, useMemo, useState } from 'react';
import { X, Settings, Sun, Moon, Monitor, Laptop, Check, Type, CaseSensitive, Palette } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme';
import { t } from '@/lib/i18n';
import type { AccentColor, UiFont, MonoFont, ThemeMode } from '@/contexts/ThemeContextDef';
import { uiFontStacks, monoFontStacks } from '@/contexts/ThemeContextDef';
import { colorThemes } from '@/contexts/colorThemes';
const themeOptions: { value: ThemeMode; icon: typeof Sun; labelKey: string }[] = [
{ value: 'system', icon: Laptop, labelKey: 'theme.system' },
{ value: 'dark', icon: Moon, labelKey: 'theme.dark' },
{ value: 'light', icon: Sun, labelKey: 'theme.light' },
{ value: 'oled', icon: Monitor, labelKey: 'theme.oled' },
];
const accentOptions: { value: AccentColor; color: string }[] = [
{ value: 'cyan', color: '#22d3ee' },
{ value: 'violet', color: '#8b5cf6' },
{ value: 'emerald', color: '#10b981' },
{ value: 'amber', color: '#f59e0b' },
{ value: 'rose', color: '#f43f5e' },
{ value: 'blue', color: '#3b82f6' },
];
const uiFontOptions: { value: UiFont; label: string; sample: string }[] = [
{ value: 'system', label: 'System', sample: 'Segoe/UI' },
{ value: 'inter', label: 'Inter', sample: 'Inter' },
{ value: 'segoe', label: 'Segoe UI', sample: 'Segoe' },
{ value: 'sf', label: 'SF Pro', sample: 'SF' },
];
const monoFontOptions: { value: MonoFont; label: string; sample: string }[] = [
{ value: 'jetbrains', label: 'JetBrains Mono', sample: 'JetBrains' },
{ value: 'fira', label: 'Fira Code', sample: 'Fira' },
{ value: 'cascadia', label: 'Cascadia Code', sample: 'Cascadia' },
{ value: 'system-mono', label: 'System mono', sample: 'System' },
];
const uiSizes = [14, 15, 16, 17, 18];
const monoSizes = [13, 14, 15, 16, 17];
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div
className="text-[10px] uppercase tracking-wider mb-2 mt-5 first:mt-0"
style={{ color: 'var(--pc-text-faint)', fontWeight: 600 }}
>
{children}
</div>
);
}
/** Mini terminal preview card for a color theme. */
function ThemePreviewCard({
theme,
active,
onClick,
}: {
theme: typeof colorThemes[number];
active: boolean;
onClick: () => void;
}) {
const [bg, c1, c2, c3, text] = theme.preview;
return (
<button
onClick={onClick}
className="flex flex-col gap-1.5 p-2 rounded-xl border transition-all text-left group"
style={{
borderColor: active ? 'var(--pc-accent)' : 'var(--pc-border)',
background: active ? 'var(--pc-accent-glow)' : 'transparent',
boxShadow: active ? '0 0 12px var(--pc-accent-glow)' : 'none',
minWidth: '110px',
}}
aria-pressed={active}
>
{/* Mini terminal */}
<div
className="w-full rounded-lg overflow-hidden"
style={{ background: bg, border: `1px solid ${theme.scheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}` }}
>
{/* Title bar dots */}
<div className="flex gap-1 px-2 py-1.5">
<span className="w-[6px] h-[6px] rounded-full" style={{ background: '#ff5f57' }} />
<span className="w-[6px] h-[6px] rounded-full" style={{ background: '#febc2e' }} />
<span className="w-[6px] h-[6px] rounded-full" style={{ background: '#28c840' }} />
</div>
{/* Fake code lines */}
<div className="px-2 pb-2 flex flex-col gap-[3px]">
<div className="flex gap-1 items-center">
<span className="h-[3px] rounded-full" style={{ background: c1, width: '30%' }} />
<span className="h-[3px] rounded-full" style={{ background: text, width: '20%', opacity: 0.4 }} />
</div>
<div className="flex gap-1 items-center">
<span className="h-[3px] rounded-full" style={{ background: text, width: '15%', opacity: 0.3 }} />
<span className="h-[3px] rounded-full" style={{ background: c2, width: '25%' }} />
<span className="h-[3px] rounded-full" style={{ background: c3, width: '18%' }} />
</div>
<div className="flex gap-1 items-center">
<span className="h-[3px] rounded-full" style={{ background: c3, width: '22%' }} />
<span className="h-[3px] rounded-full" style={{ background: text, width: '28%', opacity: 0.3 }} />
</div>
</div>
</div>
{/* Label */}
<div className="flex items-center gap-1 px-0.5">
{active && <Check size={10} style={{ color: 'var(--pc-accent)' }} />}
<span
className="text-[10px] font-medium truncate"
style={{ color: active ? 'var(--pc-accent-light)' : 'var(--pc-text-muted)' }}
>
{theme.name}
</span>
</div>
</button>
);
}
interface Props {
open: boolean;
onClose: () => void;
}
export function SettingsModal({ open, onClose }: Props) {
const {
theme, accent, colorTheme, uiFont, monoFont, uiFontSize, monoFontSize,
setTheme, setAccent, setColorTheme, setUiFont, setMonoFont, setUiFontSize, setMonoFontSize,
} = useTheme();
type TabId = 'appearance' | 'themes' | 'typography';
const [tab, setTab] = useState<TabId>('appearance');
const tabs: { id: TabId; label: string; icon: typeof Palette }[] = useMemo(() => [
{ id: 'appearance', label: t('settings.tab.appearance'), icon: Settings },
{ id: 'themes', label: 'Themes', icon: Palette },
{ id: 'typography', label: t('settings.tab.typography'), icon: Type },
], []);
// Group themes by scheme for the themes tab
const darkThemes = useMemo(() => colorThemes.filter(ct => ct.scheme === 'dark'), []);
const lightThemes = useMemo(() => colorThemes.filter(ct => ct.scheme === 'light'), []);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('settings.title')}
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
<div className="absolute inset-0" style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }} />
<div
className="relative w-full max-w-2xl mx-4 rounded-3xl border shadow-2xl animate-fade-in"
style={{ background: 'var(--pc-bg-base)', borderColor: 'var(--pc-border)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="flex items-center justify-between px-6 py-4 border-b"
style={{ borderColor: 'var(--pc-border)' }}
>
<div className="flex items-center gap-2.5">
<Settings size={18} style={{ color: 'var(--pc-accent-light)' }} />
<h2 className="text-sm font-semibold" style={{ color: 'var(--pc-text-primary)' }}>{t('settings.title')}</h2>
</div>
<button
onClick={onClose}
className="h-8 w-8 rounded-xl flex items-center justify-center transition-colors"
style={{ color: 'var(--pc-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--pc-text-primary)'; e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--pc-text-muted)'; e.currentTarget.style.background = 'transparent'; }}
aria-label="Close"
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 max-h-[65vh] overflow-y-auto">
{/* Tabs */}
<div className="flex gap-2 mb-4">
{tabs.map(tTab => (
<button
key={tTab.id}
onClick={() => setTab(tTab.id)}
className="flex-1 rounded-xl border px-3 py-2 text-xs font-medium transition-colors flex items-center justify-center gap-1.5"
style={tab === tTab.id
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (tab !== tTab.id) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (tab !== tTab.id) e.currentTarget.style.background = 'transparent'; }}
>
<tTab.icon size={13} />
{tTab.label}
</button>
))}
</div>
{/* Appearance Tab */}
{tab === 'appearance' && (
<>
<SectionTitle>{t('settings.appearance')}</SectionTitle>
{/* Theme Mode */}
<div className="mb-3">
<div className="text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>{t('theme.mode')}</div>
<div className="flex gap-1.5">
{themeOptions.map(opt => {
const Icon = opt.icon;
const active = theme === opt.value;
return (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
aria-pressed={active}
className="flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs transition-all"
style={active
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}
>
<Icon size={16} />
<span>{t(opt.labelKey)}</span>
</button>
);
})}
</div>
</div>
{/* Accent Color */}
<div className="mb-4">
<div className="text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>{t('theme.accent')}</div>
<div className="flex gap-2">
{accentOptions.map(opt => (
<button
key={opt.value}
onClick={() => setAccent(opt.value)}
className="relative h-7 w-7 rounded-full transition-all flex items-center justify-center"
style={{
backgroundColor: opt.color,
border: accent === opt.value ? `2px solid ${opt.color}` : '2px solid transparent',
boxShadow: accent === opt.value ? `0 0 8px ${opt.color}40` : 'none',
}}
aria-pressed={accent === opt.value}
aria-label={`${opt.value} accent`}
>
{accent === opt.value && <Check size={14} style={{ color: 'white' }} />}
</button>
))}
</div>
</div>
</>
)}
{/* Themes Tab */}
{tab === 'themes' && (
<>
<SectionTitle>Dark Themes</SectionTitle>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mb-4">
{darkThemes.map(ct => (
<ThemePreviewCard
key={ct.id}
theme={ct}
active={colorTheme === ct.id}
onClick={() => setColorTheme(ct.id)}
/>
))}
</div>
<SectionTitle>Light Themes</SectionTitle>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mb-4">
{lightThemes.map(ct => (
<ThemePreviewCard
key={ct.id}
theme={ct}
active={colorTheme === ct.id}
onClick={() => setColorTheme(ct.id)}
/>
))}
</div>
{/* Active theme info */}
<div
className="rounded-2xl border p-3 mt-2"
style={{ background: 'var(--pc-bg-surface)', borderColor: 'var(--pc-border)' }}
>
<div className="flex items-center gap-2">
<Palette size={14} style={{ color: 'var(--pc-accent)' }} />
<span className="text-xs font-medium" style={{ color: 'var(--pc-text-primary)' }}>
{colorThemes.find(ct => ct.id === colorTheme)?.name ?? 'Default Dark'}
</span>
<span
className="text-[10px] px-1.5 py-0.5 rounded-full"
style={{ background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }}
>
Active
</span>
</div>
</div>
</>
)}
{/* Typography Tab */}
{tab === 'typography' && (
<>
<SectionTitle>{t('settings.typography')}</SectionTitle>
{/* UI Font */}
<div className="mb-4">
<div className="flex items-center gap-2 text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>
<Type size={14} />
{t('settings.fontUi')}
</div>
<div className="flex flex-wrap gap-1.5">
{uiFontOptions.map(opt => (
<button
key={opt.value}
onClick={() => setUiFont(opt.value)}
className="flex items-center gap-2 px-3 py-2 rounded-xl border text-xs transition-all"
style={uiFont === opt.value
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (uiFont !== opt.value) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (uiFont !== opt.value) e.currentTarget.style.background = 'transparent'; }}
>
<span style={{ fontSize: '14px', fontFamily: uiFontStacks[opt.value] }}>{opt.sample}</span>
<span style={{ fontSize: '11px', color: 'var(--pc-text-faint)' }}>{opt.label}</span>
</button>
))}
</div>
</div>
{/* Mono Font */}
<div className="mb-4">
<div className="flex items-center gap-2 text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>
<CaseSensitive size={14} />
{t('settings.fontMono')}
</div>
<div className="flex flex-wrap gap-1.5">
{monoFontOptions.map(opt => (
<button
key={opt.value}
onClick={() => setMonoFont(opt.value)}
className="flex items-center gap-2 px-3 py-2 rounded-xl border text-xs transition-all"
style={monoFont === opt.value
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (monoFont !== opt.value) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (monoFont !== opt.value) e.currentTarget.style.background = 'transparent'; }}
>
<span style={{ fontSize: '14px', fontFamily: monoFontStacks[opt.value] }}>{opt.sample}</span>
<span style={{ fontSize: '11px', color: 'var(--pc-text-faint)' }}>{opt.label}</span>
</button>
))}
</div>
</div>
{/* UI Font Size */}
<div className="mb-4">
<div className="text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>{t('settings.fontSize')}</div>
<div className="flex gap-1.5 flex-wrap">
{uiSizes.map(size => (
<button
key={size}
onClick={() => setUiFontSize(size)}
className="px-3 py-1.5 rounded-lg border text-xs transition-all"
style={uiFontSize === size
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (uiFontSize !== size) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (uiFontSize !== size) e.currentTarget.style.background = 'transparent'; }}
>
{size}px
</button>
))}
</div>
</div>
{/* Mono Font Size */}
<div className="mb-4">
<div className="text-xs mb-2" style={{ color: 'var(--pc-text-secondary)' }}>{t('settings.fontMonoSize')}</div>
<div className="flex gap-1.5 flex-wrap">
{monoSizes.map(size => (
<button
key={size}
onClick={() => setMonoFontSize(size)}
className="px-3 py-1.5 rounded-lg border text-xs transition-all"
style={monoFontSize === size
? { borderColor: 'var(--pc-accent-dim)', background: 'var(--pc-accent-glow)', color: 'var(--pc-accent-light)' }
: { borderColor: 'var(--pc-border)', color: 'var(--pc-text-muted)', background: 'transparent' }
}
onMouseEnter={(e) => { if (monoFontSize !== size) e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { if (monoFontSize !== size) e.currentTarget.style.background = 'transparent'; }}
>
{size}px
</button>
))}
</div>
</div>
{/* Preview */}
<div
className="rounded-2xl border p-3"
style={{ background: 'var(--pc-bg-surface)', borderColor: 'var(--pc-border)' }}
>
<div
className="text-[11px] uppercase tracking-wide mb-2"
style={{ color: 'var(--pc-text-faint)' }}
>
{t('settings.preview')}
</div>
<div
className="text-sm mb-2"
style={{ color: 'var(--pc-text-primary)', fontFamily: 'var(--pc-font-ui)', fontSize: 'var(--pc-font-size)' }}
>
{t('settings.previewText')}
</div>
<div
className="rounded-xl border p-2 text-[13px]"
style={{ fontFamily: 'var(--pc-font-mono)', fontSize: 'var(--pc-font-size-mono)', color: 'var(--pc-text-primary)', borderColor: 'var(--pc-border)', background: 'var(--pc-bg-code)' }}
>
const hello = 'ZeroClaw'; // typography preview
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { LogOut, Menu, Settings } from 'lucide-react';
import { t } from '@/lib/i18n';
import { useLocaleContext } from '@/App';
import { useAuth } from '@/hooks/useAuth';
import { SettingsModal } from '@/components/SettingsModal';
const routeTitles: Record<string, string> = {
'/': 'nav.dashboard',
'/agent': 'nav.agent',
'/tools': 'nav.tools',
'/cron': 'nav.cron',
'/integrations': 'nav.integrations',
'/memory': 'nav.memory',
'/config': 'nav.config',
'/cost': 'nav.cost',
'/logs': 'nav.logs',
'/doctor': 'nav.doctor',
};
interface HeaderProps {
onMenuToggle: () => void;
}
export default function Header({ onMenuToggle }: HeaderProps) {
const location = useLocation();
const { logout } = useAuth();
const { locale, setAppLocale } = useLocaleContext();
const [settingsOpen, setSettingsOpen] = useState(false);
const titleKey = routeTitles[location.pathname] ?? 'nav.dashboard';
const pageTitle = t(titleKey);
const toggleLanguage = () => {
// Cycle through: en -> zh -> tr -> en
const nextLocale = locale === 'en' ? 'zh' : locale === 'zh' ? 'tr' : 'en';
setAppLocale(nextLocale);
};
return (
<>
<header className="h-14 flex items-center justify-between px-6 border-b animate-fade-in" style={{ background: 'var(--pc-bg-surface)', borderColor: 'var(--pc-border)', backdropFilter: 'blur(12px)', }}>
<div className="flex items-center gap-3">
{/* Hamburger — visible only on mobile */}
<button
type="button"
onClick={onMenuToggle}
className="md:hidden p-1.5 -ml-1.5 rounded-lg transition-colors duration-200"
style={{ color: 'var(--pc-text-muted)' }}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--pc-text-primary)'; e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--pc-text-muted)'; e.currentTarget.style.background = 'transparent'; }}
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
{/* Page title */}
<h1 className="h-9 leading-9 text-lg font-semibold tracking-tight" style={{ color: 'var(--pc-text-primary)' }}>{pageTitle}</h1>
</div>
{/* Right-side controls */}
<div className="flex items-center gap-2 h-9">
{/* Settings */}
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="h-9 w-9 flex items-center justify-center rounded-xl text-xs transition-all"
style={{ color: 'var(--pc-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--pc-text-primary)'; e.currentTarget.style.background = 'var(--pc-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--pc-text-muted)'; e.currentTarget.style.background = 'transparent'; }}
aria-label={t('settings.title')}
>
<Settings className="h-3.5 w-3.5" />
</button>
{/* Language switcher */}
<button
type="button"
onClick={toggleLanguage}
className="h-9 px-3 rounded-xl text-xs font-semibold border transition-all flex items-center"
style={{
borderColor: 'var(--pc-border)',
color: 'var(--pc-text-secondary)',
background: 'var(--pc-bg-elevated)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--pc-accent-dim)';
e.currentTarget.style.color = 'var(--pc-text-primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--pc-border)';
e.currentTarget.style.color = 'var(--pc-text-secondary)';
}}
>
{locale === 'en' ? 'EN' : locale === 'zh' ? 'ZH' : 'TR'}
</button>
{/* Logout */}
<button
type="button"
onClick={logout}
className="h-9 px-3 rounded-xl text-xs transition-all flex items-center gap-1.5"
style={{ color: 'var(--pc-text-muted)', background: 'transparent', border: 'none', cursor: 'pointer' }}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#f87171';
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--pc-text-muted)';
e.currentTarget.style.background = 'transparent';
}}
>
<LogOut className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('auth.logout')}</span>
</button>
</div>
</header>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,35 @@
import { useState, useEffect } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { ErrorBoundary } from '@/App';
export default function Layout() {
const { pathname } = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
// Close sidebar on route change (mobile navigation)
useEffect(() => {
setSidebarOpen(false);
}, [pathname]);
return (
<div className="min-h-screen text-white" style={{ background: 'var(--pc-bg-base)' }}>
{/* Fixed sidebar */}
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Main area offset by sidebar width on desktop, full-width on mobile */}
<div className="md:ml-60 ml-0 flex flex-col flex-1 min-w-0 h-screen">
<Header onMenuToggle={() => setSidebarOpen(true)} />
{/* Page content — ErrorBoundary keyed by pathname so the nav shell
survives a page crash and the boundary resets on route change */}
<main className="flex-1 overflow-y-auto min-h-0">
<ErrorBoundary key={pathname}>
<Outlet />
</ErrorBoundary>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { NavLink } from 'react-router-dom';
import { basePath } from '../../lib/basePath';
import {
LayoutDashboard,
MessageSquare,
Wrench,
Clock,
Puzzle,
Brain,
Settings,
DollarSign,
Activity,
Stethoscope,
Monitor,
} from 'lucide-react';
import { t } from '@/lib/i18n';
const navItems = [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ to: '/agent', icon: MessageSquare, labelKey: 'nav.agent' },
{ to: '/tools', icon: Wrench, labelKey: 'nav.tools' },
{ to: '/cron', icon: Clock, labelKey: 'nav.cron' },
{ to: '/integrations', icon: Puzzle, labelKey: 'nav.integrations' },
{ to: '/memory', icon: Brain, labelKey: 'nav.memory' },
{ to: '/config', icon: Settings, labelKey: 'nav.config' },
{ to: '/cost', icon: DollarSign, labelKey: 'nav.cost' },
{ to: '/logs', icon: Activity, labelKey: 'nav.logs' },
{ to: '/doctor', icon: Stethoscope, labelKey: 'nav.doctor' },
{ to: '/canvas', icon: Monitor, labelKey: 'nav.canvas' },
];
interface SidebarProps {
open: boolean;
onClose: () => void;
}
export default function Sidebar({ open, onClose }: SidebarProps) {
return (
<>
{/* Backdrop — mobile only, visible when sidebar is open */}
{open && (
<div
className="md:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}
role="button"
tabIndex={-1}
aria-label="Close menu"
/>
)}
<aside
className={[
'fixed top-0 left-0 h-screen w-60 flex flex-col border-r z-50',
// Mobile: slide in/out with transition
'max-md:-translate-x-full max-md:transition-transform max-md:duration-200 max-md:ease-out',
open ? 'max-md:translate-x-0' : '',
].join(' ')}
style={{ background: 'var(--pc-bg-base)', borderColor: 'var(--pc-border)' }}
>
{/* Logo / Title */}
<div className="flex items-center gap-3 px-4 py-4 border-b h-14" style={{ borderColor: 'var(--pc-border)' }}>
<div className="relative shrink-0">
<div className="absolute -inset-1.5 rounded-xl" style={{ background: 'linear-gradient(135deg, rgba(var(--pc-accent-rgb), 0.15), rgba(var(--pc-accent-rgb), 0.05))' }} />
<img
src={`${basePath}/_app/zeroclaw-trans.png`}
alt="ZeroClaw"
className="relative h-9 w-9 rounded-xl object-cover"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
}}
/>
</div>
<span className="text-sm font-semibold tracking-wide" style={{ color: 'var(--pc-text-primary)' }}>
ZeroClaw
</span>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
{navItems.map(({ to, icon: Icon, labelKey }, idx) => (
<NavLink
key={to}
to={to}
end={to === '/'}
onClick={onClose}
className={({ isActive }) =>
[
'flex items-center gap-3 px-3 py-2.5 rounded-2xl text-sm font-medium transition-all group',
isActive
? 'text-[var(--pc-accent-light)]'
: 'text-[var(--pc-text-muted)] hover:text-[var(--pc-text-secondary)] hover:bg-[var(--pc-hover)]',
].join(' ')
}
style={({ isActive }) => ({
animationDelay: `${idx * 40}ms`,
...(isActive ? {
background: 'var(--pc-accent-glow)',
border: '1px solid var(--pc-accent-dim)',
} : {}),
})}
>
{({ isActive }) => (
<>
<Icon className={`h-5 w-5 flex-shrink-0 transition-colors ${isActive ? 'text-[var(--pc-accent)]' : 'group-hover:text-[var(--pc-accent)]'}`} />
<span>{t(labelKey)}</span>
</>
)}
</NavLink>
))}
</nav>
{/* Footer */}
<div className="px-5 py-4 border-t text-[10px] uppercase tracking-wider" style={{ borderColor: 'var(--pc-border)', color: 'var(--pc-text-faint)' }}>
ZeroClaw Runtime
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,227 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react';
import { ThemeContext, type ThemeContextValue } from './ThemeContextDef';
import { loadStored, STORAGE_KEY } from './themeStorage';
import type { ThemeMode, AccentColor, UiFont, MonoFont } from './ThemeContextDef';
import { uiFontStacks, monoFontStacks } from './ThemeContextDef';
import { loadUiFont, loadMonoFont } from './fontLoader';
import { colorThemeMap, DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME, type ColorThemeId } from './colorThemes';
/** Accent-only overrides (applied on top of color theme when user picks a custom accent). */
const accents: Record<AccentColor, Record<string, string>> = {
cyan: {
'--pc-accent': '#22d3ee',
'--pc-accent-light': '#67e8f9',
'--pc-accent-dim': 'rgba(34,211,238,0.3)',
'--pc-accent-glow': 'rgba(34,211,238,0.1)',
'--pc-accent-rgb': '34,211,238',
},
violet: {
'--pc-accent': '#8b5cf6',
'--pc-accent-light': '#a78bfa',
'--pc-accent-dim': 'rgba(139,92,246,0.3)',
'--pc-accent-glow': 'rgba(139,92,246,0.1)',
'--pc-accent-rgb': '139,92,246',
},
emerald: {
'--pc-accent': '#10b981',
'--pc-accent-light': '#34d399',
'--pc-accent-dim': 'rgba(16,185,129,0.3)',
'--pc-accent-glow': 'rgba(16,185,129,0.1)',
'--pc-accent-rgb': '16,185,129',
},
amber: {
'--pc-accent': '#f59e0b',
'--pc-accent-light': '#fbbf24',
'--pc-accent-dim': 'rgba(245,158,11,0.3)',
'--pc-accent-glow': 'rgba(245,158,11,0.1)',
'--pc-accent-rgb': '245,158,11',
},
rose: {
'--pc-accent': '#f43f5e',
'--pc-accent-light': '#fb7185',
'--pc-accent-dim': 'rgba(244,63,94,0.3)',
'--pc-accent-glow': 'rgba(244,63,94,0.1)',
'--pc-accent-rgb': '244,63,94',
},
blue: {
'--pc-accent': '#3b82f6',
'--pc-accent-light': '#60a5fa',
'--pc-accent-dim': 'rgba(59,130,246,0.3)',
'--pc-accent-glow': 'rgba(59,130,246,0.1)',
'--pc-accent-rgb': '59,130,246',
},
};
function applyVars(vars: Record<string, string>) {
const root = document.documentElement;
for (const [k, v] of Object.entries(vars)) {
if (k === '--color-scheme') {
root.style.colorScheme = v as 'light' | 'dark';
} else {
root.style.setProperty(k, v);
}
}
}
/** Resolve which color theme to use based on the mode. */
function resolveColorTheme(mode: ThemeMode, colorTheme: ColorThemeId): ColorThemeId {
if (mode === 'system') {
const preferLight = window.matchMedia('(prefers-color-scheme: light)').matches;
const ct = colorThemeMap[colorTheme];
// If the selected theme matches system preference, use it; otherwise pick the right default
if (ct && ((preferLight && ct.scheme === 'light') || (!preferLight && ct.scheme === 'dark'))) {
return colorTheme;
}
return preferLight ? DEFAULT_LIGHT_THEME : DEFAULT_DARK_THEME;
}
if (mode === 'oled') return 'oled-black';
return colorTheme;
}
function resolveThemeScheme(mode: ThemeMode, colorTheme: ColorThemeId): 'dark' | 'light' | 'oled' {
if (mode === 'oled') return 'oled';
const resolved = resolveColorTheme(mode, colorTheme);
const ct = colorThemeMap[resolved];
return ct?.scheme ?? 'dark';
}
interface ThemeSettings {
theme: ThemeMode;
accent: AccentColor;
colorTheme: ColorThemeId;
uiFont: UiFont;
monoFont: MonoFont;
uiFontSize: number;
monoFontSize: number;
}
function fontVars(uiFont: UiFont, monoFont: MonoFont, uiFontSize: number, monoFontSize: number) {
return {
'--pc-font-ui': uiFontStacks[uiFont],
'--pc-font-mono': monoFontStacks[monoFont],
'--pc-font-size': `${uiFontSize}px`,
'--pc-font-size-mono': `${monoFontSize}px`,
};
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [stored] = useState(loadStored);
const [theme, setThemeState] = useState<ThemeMode>(stored.theme);
const [accent, setAccentState] = useState<AccentColor>(stored.accent);
const [colorTheme, setColorThemeState] = useState<ColorThemeId>(stored.colorTheme);
const [uiFont, setUiFontState] = useState<UiFont>(stored.uiFont);
const [monoFont, setMonoFontState] = useState<MonoFont>(stored.monoFont);
const [uiFontSize, setUiFontSizeState] = useState<number>(stored.uiFontSize);
const [monoFontSize, setMonoFontSizeState] = useState<number>(stored.monoFontSize);
const persist = useCallback((s: ThemeSettings) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
theme: s.theme,
accent: s.accent,
colorTheme: s.colorTheme,
uiFont: s.uiFont,
monoFont: s.monoFont,
uiFontSize: s.uiFontSize,
monoFontSize: s.monoFontSize,
}));
}, []);
const applyAll = useCallback((s: ThemeSettings) => {
const resolvedId = resolveColorTheme(s.theme, s.colorTheme);
const ct = colorThemeMap[resolvedId];
const themeVars = ct?.vars ?? colorThemeMap[DEFAULT_DARK_THEME].vars;
// Color theme provides base + its own accent. User accent overrides on top.
applyVars({
...themeVars,
...accents[s.accent],
...fontVars(s.uiFont, s.monoFont, s.uiFontSize, s.monoFontSize),
});
}, []);
const setTheme = useCallback((t: ThemeMode) => {
setThemeState(t);
const next: ThemeSettings = { theme: t, accent, colorTheme, uiFont, monoFont, uiFontSize, monoFontSize };
applyAll(next);
persist(next);
}, [accent, colorTheme, uiFont, monoFont, uiFontSize, monoFontSize, applyAll, persist]);
const setAccent = useCallback((a: AccentColor) => {
setAccentState(a);
const next: ThemeSettings = { theme, accent: a, colorTheme, uiFont, monoFont, uiFontSize, monoFontSize };
applyAll(next);
persist(next);
}, [theme, colorTheme, uiFont, monoFont, uiFontSize, monoFontSize, applyAll, persist]);
const setColorTheme = useCallback((c: ColorThemeId) => {
setColorThemeState(c);
// Auto-adjust theme mode to match the color theme's scheme
const ct = colorThemeMap[c];
let newMode = theme;
if (ct && theme !== 'system') {
if (c === 'oled-black') {
newMode = 'oled';
} else {
newMode = ct.scheme;
}
setThemeState(newMode);
}
const next: ThemeSettings = { theme: newMode, accent, colorTheme: c, uiFont, monoFont, uiFontSize, monoFontSize };
applyAll(next);
persist(next);
}, [theme, accent, uiFont, monoFont, uiFontSize, monoFontSize, applyAll, persist]);
const setUiFont = useCallback((f: UiFont) => {
setUiFontState(f);
loadUiFont(f);
const next: ThemeSettings = { theme, accent, colorTheme, uiFont: f, monoFont, uiFontSize, monoFontSize };
applyAll(next);
persist(next);
}, [theme, accent, colorTheme, applyAll, persist, monoFont, uiFontSize, monoFontSize]);
const setMonoFont = useCallback((f: MonoFont) => {
setMonoFontState(f);
loadMonoFont(f);
const next: ThemeSettings = { theme, accent, colorTheme, uiFont, monoFont: f, uiFontSize, monoFontSize };
applyAll(next);
persist(next);
}, [theme, accent, colorTheme, applyAll, persist, uiFont, uiFontSize, monoFontSize]);
const setUiFontSize = useCallback((size: number) => {
const clamped = Math.min(20, Math.max(12, size));
setUiFontSizeState(clamped);
const next: ThemeSettings = { theme, accent, colorTheme, uiFont, monoFont, uiFontSize: clamped, monoFontSize };
applyAll(next);
persist(next);
}, [theme, accent, colorTheme, applyAll, persist, uiFont, monoFont, monoFontSize]);
const setMonoFontSize = useCallback((size: number) => {
const clamped = Math.min(20, Math.max(12, size));
setMonoFontSizeState(clamped);
const next: ThemeSettings = { theme, accent, colorTheme, uiFont, monoFont, uiFontSize, monoFontSize: clamped };
applyAll(next);
persist(next);
}, [theme, accent, colorTheme, applyAll, persist, uiFont, monoFont, uiFontSize]);
useEffect(() => {
applyAll({ theme, accent, colorTheme, uiFont, monoFont, uiFontSize, monoFontSize });
loadUiFont(uiFont);
loadMonoFont(monoFont);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: light)');
const handler = () => applyAll({ theme, accent, colorTheme, uiFont, monoFont, uiFontSize, monoFontSize });
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme, accent, colorTheme, applyAll, uiFont, monoFont, uiFontSize, monoFontSize]);
const resolvedTheme = resolveThemeScheme(theme, colorTheme);
const value: ThemeContextValue = {
theme, accent, colorTheme, uiFont, monoFont, uiFontSize, monoFontSize,
resolvedTheme, setTheme, setAccent, setColorTheme, setUiFont, setMonoFont, setUiFontSize, setMonoFontSize,
};
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

View File

@@ -0,0 +1,60 @@
import { createContext } from 'react';
import type { ColorThemeId } from './colorThemes';
export type ThemeMode = 'system' | 'dark' | 'light' | 'oled';
export type AccentColor = 'cyan' | 'violet' | 'emerald' | 'amber' | 'rose' | 'blue';
export type UiFont = 'system' | 'inter' | 'segoe' | 'sf';
export type MonoFont = 'jetbrains' | 'fira' | 'cascadia' | 'system-mono';
/** @deprecated Use ThemeMode instead. Kept for storage backward-compat. */
export type ThemeName = ThemeMode;
export const uiFontStacks: Record<UiFont, string> = {
system: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
inter: '"Inter", system-ui, sans-serif',
segoe: '"Segoe UI", system-ui, sans-serif',
sf: '-apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif',
};
export const monoFontStacks: Record<MonoFont, string> = {
jetbrains: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
fira: '"Fira Code", "JetBrains Mono", "Cascadia Code", monospace',
cascadia: '"Cascadia Code", "JetBrains Mono", "Fira Code", monospace',
'system-mono': 'ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", monospace',
};
export interface ThemeContextValue {
theme: ThemeMode;
accent: AccentColor;
colorTheme: ColorThemeId;
uiFont: UiFont;
monoFont: MonoFont;
uiFontSize: number;
monoFontSize: number;
resolvedTheme: 'dark' | 'light' | 'oled';
setTheme: (t: ThemeMode) => void;
setAccent: (a: AccentColor) => void;
setColorTheme: (c: ColorThemeId) => void;
setUiFont: (f: UiFont) => void;
setMonoFont: (f: MonoFont) => void;
setUiFontSize: (size: number) => void;
setMonoFontSize: (size: number) => void;
}
export const ThemeContext = createContext<ThemeContextValue>({
theme: 'dark',
accent: 'cyan',
colorTheme: 'default-dark',
uiFont: 'system',
monoFont: 'jetbrains',
uiFontSize: 15,
monoFontSize: 14,
resolvedTheme: 'dark',
setTheme: () => {},
setAccent: () => {},
setColorTheme: () => {},
setUiFont: () => {},
setMonoFont: () => {},
setUiFontSize: () => {},
setMonoFontSize: () => {},
});

View File

@@ -0,0 +1,285 @@
/**
* Color theme palettes for the ZeroClaw dashboard.
*
* Each theme defines the full set of --pc-* CSS variables.
* Themes are grouped by scheme ('dark' | 'light') so the system
* preference resolver can pick the right default.
*/
export type ColorThemeId =
| 'default-dark' | 'default-light' | 'oled-black'
| 'nord-dark' | 'nord-light'
| 'dracula'
| 'monokai'
| 'solarized-dark' | 'solarized-light'
| 'kanagawa-wave' | 'kanagawa-dragon' | 'kanagawa-lotus'
| 'rose-pine' | 'rose-pine-moon' | 'rose-pine-dawn'
| 'night-owl'
| 'everforest-dark' | 'everforest-light'
| 'cobalt2'
| 'flexoki-dark' | 'flexoki-light'
| 'hacker-green'
| 'material-dark' | 'material-light';
export interface ColorThemeDef {
id: ColorThemeId;
name: string;
scheme: 'dark' | 'light';
/** Preview colors for the settings card [bg, bar1, bar2, bar3, text] */
preview: [string, string, string, string, string];
vars: Record<string, string>;
}
function darkBase(
bgBase: string, bgSurface: string, bgElevated: string,
bgInput: string, bgCode: string,
textPrimary: string, textSecondary: string, textMuted: string, textFaint: string,
accent: string, accentLight: string,
): Record<string, string> {
const r = parseInt(accent.slice(1, 3), 16);
const g = parseInt(accent.slice(3, 5), 16);
const b = parseInt(accent.slice(5, 7), 16);
return {
'--pc-bg-base': bgBase,
'--color-scheme': 'dark',
'--pc-bg-surface': bgSurface,
'--pc-bg-elevated': bgElevated,
'--pc-bg-input': bgInput,
'--pc-bg-sidebar': `${bgBase}f2`,
'--pc-bg-code': bgCode,
'--pc-border': 'rgba(255,255,255,0.08)',
'--pc-border-strong': 'rgba(255,255,255,0.12)',
'--pc-text-primary': textPrimary,
'--pc-text-secondary': textSecondary,
'--pc-text-muted': textMuted,
'--pc-text-faint': textFaint,
'--pc-scrollbar-thumb': textFaint,
'--pc-scrollbar-track': bgSurface,
'--pc-scrollbar-thumb-hover': textMuted,
'--pc-hover': 'rgba(255,255,255,0.05)',
'--pc-hover-strong': 'rgba(255,255,255,0.08)',
'--pc-separator': 'rgba(255,255,255,0.05)',
'--pc-accent': accent,
'--pc-accent-light': accentLight,
'--pc-accent-dim': `rgba(${r},${g},${b},0.3)`,
'--pc-accent-glow': `rgba(${r},${g},${b},0.1)`,
'--pc-accent-rgb': `${r},${g},${b}`,
};
}
function lightBase(
bgBase: string, bgSurface: string, bgElevated: string,
bgInput: string, bgCode: string,
textPrimary: string, textSecondary: string, textMuted: string, textFaint: string,
accent: string, accentLight: string,
): Record<string, string> {
const r = parseInt(accent.slice(1, 3), 16);
const g = parseInt(accent.slice(3, 5), 16);
const b = parseInt(accent.slice(5, 7), 16);
return {
'--pc-bg-base': bgBase,
'--color-scheme': 'light',
'--pc-bg-surface': bgSurface,
'--pc-bg-elevated': bgElevated,
'--pc-bg-input': bgInput,
'--pc-bg-sidebar': `${bgSurface}f2`,
'--pc-bg-code': bgCode,
'--pc-border': 'rgba(0,0,0,0.08)',
'--pc-border-strong': 'rgba(0,0,0,0.12)',
'--pc-text-primary': textPrimary,
'--pc-text-secondary': textSecondary,
'--pc-text-muted': textMuted,
'--pc-text-faint': textFaint,
'--pc-scrollbar-thumb': textFaint,
'--pc-scrollbar-track': bgElevated,
'--pc-scrollbar-thumb-hover': textMuted,
'--pc-hover': 'rgba(0,0,0,0.04)',
'--pc-hover-strong': 'rgba(0,0,0,0.07)',
'--pc-separator': 'rgba(0,0,0,0.06)',
'--pc-accent': accent,
'--pc-accent-light': accentLight,
'--pc-accent-dim': `rgba(${r},${g},${b},0.25)`,
'--pc-accent-glow': `rgba(${r},${g},${b},0.08)`,
'--pc-accent-rgb': `${r},${g},${b}`,
};
}
export const colorThemes: ColorThemeDef[] = [
// ── Defaults ────────────────────────────────────────────────
{
id: 'default-dark', name: 'Default Dark', scheme: 'dark',
preview: ['#1e1e24', '#22d3ee', '#a78bfa', '#f59e0b', '#d4d4d8'],
vars: darkBase('#1e1e24', '#232329', '#27272a', '#1a1a20', '#1a1a20',
'#d4d4d8', '#a1a1aa', '#71717a', '#52525b', '#22d3ee', '#67e8f9'),
},
{
id: 'default-light', name: 'Default Light', scheme: 'light',
preview: ['#f4f4f5', '#22d3ee', '#8b5cf6', '#f59e0b', '#18181b'],
vars: lightBase('#f4f4f5', '#ffffff', '#e4e4e7', '#ffffff', '#f4f4f5',
'#18181b', '#3f3f46', '#71717a', '#a1a1aa', '#0891b2', '#06b6d4'),
},
{
id: 'oled-black', name: 'OLED Black', scheme: 'dark',
preview: ['#000000', '#22d3ee', '#8b5cf6', '#10b981', '#d4d4d8'],
vars: darkBase('#000000', '#0a0a0a', '#141414', '#0a0a0a', '#0a0a0a',
'#d4d4d8', '#a1a1aa', '#71717a', '#3f3f46', '#22d3ee', '#67e8f9'),
},
// ── Nord ────────────────────────────────────────────────────
{
id: 'nord-dark', name: 'Nord Dark', scheme: 'dark',
preview: ['#2e3440', '#88c0d0', '#81a1c1', '#a3be8c', '#eceff4'],
vars: darkBase('#2e3440', '#3b4252', '#434c5e', '#2e3440', '#2e3440',
'#eceff4', '#d8dee9', '#7b88a1', '#4c566a', '#88c0d0', '#8fbcbb'),
},
{
id: 'nord-light', name: 'Nord Light', scheme: 'light',
preview: ['#eceff4', '#5e81ac', '#88c0d0', '#a3be8c', '#2e3440'],
vars: lightBase('#eceff4', '#e5e9f0', '#d8dee9', '#e5e9f0', '#e5e9f0',
'#2e3440', '#3b4252', '#4c566a', '#7b88a1', '#5e81ac', '#81a1c1'),
},
// ── Dracula ─────────────────────────────────────────────────
{
id: 'dracula', name: 'Dracula', scheme: 'dark',
preview: ['#282a36', '#bd93f9', '#ff79c6', '#50fa7b', '#f8f8f2'],
vars: darkBase('#282a36', '#21222c', '#343746', '#1e1f29', '#1e1f29',
'#f8f8f2', '#c0c0d0', '#6272a4', '#44475a', '#bd93f9', '#caa9fa'),
},
// ── Monokai ─────────────────────────────────────────────────
{
id: 'monokai', name: 'Monokai', scheme: 'dark',
preview: ['#272822', '#f92672', '#a6e22e', '#e6db74', '#f8f8f2'],
vars: darkBase('#272822', '#2d2e27', '#3e3d32', '#1e1f1c', '#1e1f1c',
'#f8f8f2', '#c0c0b0', '#75715e', '#49483e', '#f92672', '#fd5fa0'),
},
// ── Solarized ───────────────────────────────────────────────
{
id: 'solarized-dark', name: 'Solarized Dark', scheme: 'dark',
preview: ['#002b36', '#268bd2', '#2aa198', '#b58900', '#839496'],
vars: darkBase('#002b36', '#073642', '#0a4050', '#002028', '#002028',
'#839496', '#93a1a1', '#657b83', '#586e75', '#268bd2', '#6cb6e8'),
},
{
id: 'solarized-light', name: 'Solarized Light', scheme: 'light',
preview: ['#fdf6e3', '#268bd2', '#2aa198', '#b58900', '#073642'],
vars: lightBase('#fdf6e3', '#eee8d5', '#ddd6c1', '#fdf6e3', '#eee8d5',
'#073642', '#586e75', '#657b83', '#93a1a1', '#268bd2', '#2aa198'),
},
// ── Kanagawa ────────────────────────────────────────────────
{
id: 'kanagawa-wave', name: 'Kanagawa Wave', scheme: 'dark',
preview: ['#1f1f28', '#7e9cd8', '#957fb8', '#e6c384', '#dcd7ba'],
vars: darkBase('#1f1f28', '#2a2a37', '#363646', '#16161d', '#16161d',
'#dcd7ba', '#c8c093', '#727169', '#54546d', '#7e9cd8', '#7fb4ca'),
},
{
id: 'kanagawa-dragon', name: 'Kanagawa Dragon', scheme: 'dark',
preview: ['#181616', '#8ba4b0', '#a292a3', '#c4b28a', '#c5c9c5'],
vars: darkBase('#181616', '#201d1d', '#2d2a2a', '#12120f', '#12120f',
'#c5c9c5', '#a6a69c', '#737c73', '#625e5a', '#8ba4b0', '#9cabba'),
},
{
id: 'kanagawa-lotus', name: 'Kanagawa Lotus', scheme: 'light',
preview: ['#f2ecbc', '#4d699b', '#b35b79', '#836f4a', '#1f1f28'],
vars: lightBase('#f2ecbc', '#e7dba0', '#d5cea3', '#f2ecbc', '#e7dba0',
'#1f1f28', '#545464', '#716e61', '#8a8980', '#4d699b', '#6693bf'),
},
// ── Ros\u00e9 Pine ──────────────────────────────────────────────
{
id: 'rose-pine', name: 'Ros\u00e9 Pine', scheme: 'dark',
preview: ['#191724', '#ebbcba', '#c4a7e7', '#f6c177', '#e0def4'],
vars: darkBase('#191724', '#1f1d2e', '#26233a', '#13111e', '#13111e',
'#e0def4', '#908caa', '#6e6a86', '#524f67', '#ebbcba', '#f2d5ce'),
},
{
id: 'rose-pine-moon', name: 'Ros\u00e9 Pine Moon', scheme: 'dark',
preview: ['#232136', '#ea9a97', '#c4a7e7', '#f6c177', '#e0def4'],
vars: darkBase('#232136', '#2a273f', '#393552', '#1b1930', '#1b1930',
'#e0def4', '#908caa', '#6e6a86', '#44415a', '#ea9a97', '#f0b8b6'),
},
{
id: 'rose-pine-dawn', name: 'Ros\u00e9 Pine Dawn', scheme: 'light',
preview: ['#faf4ed', '#d7827e', '#907aa9', '#ea9d34', '#575279'],
vars: lightBase('#faf4ed', '#fffaf3', '#f2e9de', '#fffaf3', '#f2e9de',
'#575279', '#797593', '#9893a5', '#cecacd', '#d7827e', '#b4637a'),
},
// ── Night Owl ───────────────────────────────────────────────
{
id: 'night-owl', name: 'Night Owl', scheme: 'dark',
preview: ['#011627', '#82aaff', '#c792ea', '#addb67', '#d6deeb'],
vars: darkBase('#011627', '#0b2942', '#122d42', '#010e1a', '#010e1a',
'#d6deeb', '#a7bbc7', '#5f7e97', '#37536b', '#82aaff', '#a0c4ff'),
},
// ── Everforest ──────────────────────────────────────────────
{
id: 'everforest-dark', name: 'Everforest Dark', scheme: 'dark',
preview: ['#2d353b', '#a7c080', '#83c092', '#dbbc7f', '#d3c6aa'],
vars: darkBase('#2d353b', '#343f44', '#3d484d', '#272e33', '#272e33',
'#d3c6aa', '#9da9a0', '#7a8478', '#56635f', '#a7c080', '#83c092'),
},
{
id: 'everforest-light', name: 'Everforest Light', scheme: 'light',
preview: ['#fdf6e3', '#8da101', '#35a77c', '#dfa000', '#5c6a72'],
vars: lightBase('#fdf6e3', '#f3ead3', '#e9dfc4', '#f3ead3', '#eee8d5',
'#5c6a72', '#708089', '#829181', '#a6b0a0', '#8da101', '#93b259'),
},
// ── Cobalt2 ─────────────────────────────────────────────────
{
id: 'cobalt2', name: 'Cobalt2', scheme: 'dark',
preview: ['#193549', '#ffc600', '#ff9d00', '#80ffbb', '#ffffff'],
vars: darkBase('#193549', '#1f4662', '#234d6e', '#0d2b3e', '#0d2b3e',
'#ffffff', '#a0c4d8', '#507a8f', '#305a6f', '#ffc600', '#ffd740'),
},
// ── Flexoki ─────────────────────────────────────────────────
{
id: 'flexoki-dark', name: 'Flexoki Dark', scheme: 'dark',
preview: ['#100f0f', '#ce5d97', '#879a39', '#da702c', '#cecdc3'],
vars: darkBase('#100f0f', '#1c1b1a', '#282726', '#100f0f', '#1c1b1a',
'#cecdc3', '#b7b5ac', '#878580', '#575653', '#ce5d97', '#d68fb2'),
},
{
id: 'flexoki-light', name: 'Flexoki Light', scheme: 'light',
preview: ['#fffcf0', '#ce5d97', '#879a39', '#da702c', '#100f0f'],
vars: lightBase('#fffcf0', '#f2f0e5', '#e6e4d9', '#fffcf0', '#f2f0e5',
'#100f0f', '#343331', '#575653', '#878580', '#ce5d97', '#a02f6f'),
},
// ── Hacker Green ────────────────────────────────────────────
{
id: 'hacker-green', name: 'Hacker Green', scheme: 'dark',
preview: ['#0a0e0a', '#00ff41', '#00cc33', '#008f11', '#33ff66'],
vars: darkBase('#0a0e0a', '#0d120d', '#121a12', '#080c08', '#080c08',
'#00ff41', '#00cc33', '#008f11', '#005a0a', '#00ff41', '#33ff66'),
},
// ── Material ────────────────────────────────────────────────
{
id: 'material-dark', name: 'Material Dark', scheme: 'dark',
preview: ['#212121', '#89ddff', '#c792ea', '#ffcb6b', '#eeffff'],
vars: darkBase('#212121', '#292929', '#333333', '#1a1a1a', '#1a1a1a',
'#eeffff', '#b0bec5', '#616161', '#424242', '#89ddff', '#80cbc4'),
},
{
id: 'material-light', name: 'Material Light', scheme: 'light',
preview: ['#fafafa', '#6182b8', '#7c4dff', '#f76d47', '#212121'],
vars: lightBase('#fafafa', '#ffffff', '#eaeaea', '#ffffff', '#f5f5f5',
'#212121', '#424242', '#757575', '#bdbdbd', '#6182b8', '#7c4dff'),
},
];
/** Lookup map for O(1) access by id. */
export const colorThemeMap: Record<ColorThemeId, ColorThemeDef> =
Object.fromEntries(colorThemes.map(t => [t.id, t])) as Record<ColorThemeId, ColorThemeDef>;
/** Default theme ids for system preference resolution. */
export const DEFAULT_DARK_THEME: ColorThemeId = 'default-dark';
export const DEFAULT_LIGHT_THEME: ColorThemeId = 'default-light';

View File

@@ -0,0 +1,25 @@
const loaded: Set<string> = new Set();
export function loadGoogleFont(family: string, weights: string = '400;500;600') {
const id = `gfont-${family.replace(/\s+/g, '-').toLowerCase()}`;
if (loaded.has(id)) return;
loaded.add(id);
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weights}&display=swap`;
document.head.appendChild(link);
}
export function loadUiFont(font: string) {
if (font === 'inter') loadGoogleFont('Inter');
if (font === 'segoe') loadGoogleFont('Segoe UI');
if (font === 'sf') loadGoogleFont('SF Pro Text');
}
export function loadMonoFont(font: string) {
if (font === 'jetbrains') loadGoogleFont('JetBrains Mono');
if (font === 'fira') loadGoogleFont('Fira Code');
if (font === 'cascadia') loadGoogleFont('Cascadia Code');
}

View File

@@ -0,0 +1,66 @@
import type { AccentColor, UiFont, MonoFont, ThemeMode } from './ThemeContextDef';
import { uiFontStacks, monoFontStacks } from './ThemeContextDef';
import type { ColorThemeId } from './colorThemes';
import { colorThemeMap } from './colorThemes';
export const STORAGE_KEY = 'zeroclaw-theme';
export interface StoredTheme {
theme: ThemeMode;
accent: AccentColor;
colorTheme: ColorThemeId;
uiFont: UiFont;
monoFont: MonoFont;
uiFontSize: number;
monoFontSize: number;
}
const DEFAULTS: StoredTheme = {
theme: 'dark',
accent: 'cyan',
colorTheme: 'default-dark',
uiFont: 'system',
monoFont: 'jetbrains',
uiFontSize: 15,
monoFontSize: 14,
};
const validThemes: ThemeMode[] = ['dark', 'light', 'oled', 'system'];
const validAccents: AccentColor[] = ['cyan', 'violet', 'emerald', 'amber', 'rose', 'blue'];
/** Migrate old theme mode to a color theme id for backward compatibility. */
function migrateThemeToColorTheme(themeMode: ThemeMode): ColorThemeId {
switch (themeMode) {
case 'light': return 'default-light';
case 'oled': return 'oled-black';
default: return 'default-dark';
}
}
export function loadStored(): StoredTheme {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
const themeValid = validThemes.includes(parsed.theme);
const accentValid = validAccents.includes(parsed.accent);
const uiFont: UiFont = uiFontStacks[parsed.uiFont as UiFont] ? parsed.uiFont as UiFont : DEFAULTS.uiFont;
const monoFont: MonoFont = monoFontStacks[parsed.monoFont as MonoFont] ? parsed.monoFont as MonoFont : DEFAULTS.monoFont;
const uiFontSize = Number.isFinite(parsed.uiFontSize) ? Math.min(20, Math.max(12, Number(parsed.uiFontSize))) : DEFAULTS.uiFontSize;
const monoFontSize = Number.isFinite(parsed.monoFontSize) ? Math.min(20, Math.max(12, Number(parsed.monoFontSize))) : DEFAULTS.monoFontSize;
// Validate or migrate color theme
let colorTheme: ColorThemeId = DEFAULTS.colorTheme;
if (parsed.colorTheme && colorThemeMap[parsed.colorTheme as ColorThemeId]) {
colorTheme = parsed.colorTheme as ColorThemeId;
} else if (themeValid) {
colorTheme = migrateThemeToColorTheme(parsed.theme);
}
if (themeValid && accentValid) {
return { theme: parsed.theme, accent: parsed.accent, colorTheme, uiFont, monoFont, uiFontSize, monoFontSize };
}
}
} catch { /* ignore */ }
return DEFAULTS;
}

View File

@@ -0,0 +1,174 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
getStatus,
getTools,
getCronJobs,
getIntegrations,
getMemory,
getCost,
getCliTools,
getHealth,
runDoctor,
} from '../lib/api';
import type {
StatusResponse,
ToolSpec,
CronJob,
Integration,
MemoryEntry,
CostSummary,
CliTool,
HealthSnapshot,
DiagResult,
} from '../types/api';
// ---------------------------------------------------------------------------
// Generic async-data hook
// ---------------------------------------------------------------------------
interface UseApiResult<T> {
data: T | null;
error: Error | null;
loading: boolean;
/** Re-fetch the data manually. */
refetch: () => void;
}
function useApiCall<T>(
fetcher: () => Promise<T>,
deps: unknown[] = [],
): UseApiResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const mountedRef = useRef(true);
const triggerRef = useRef(0);
const refetch = useCallback(() => {
triggerRef.current += 1;
setLoading(true);
setError(null);
fetcher()
.then((result) => {
if (mountedRef.current) {
setData(result);
setError(null);
}
})
.catch((err: unknown) => {
if (mountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
}
})
.finally(() => {
if (mountedRef.current) {
setLoading(false);
}
});
}, [fetcher, ...deps]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
mountedRef.current = true;
refetch();
return () => {
mountedRef.current = false;
};
}, [refetch]);
return { data, error, loading, refetch };
}
// ---------------------------------------------------------------------------
// Typed hooks
// ---------------------------------------------------------------------------
/** Fetch agent status from /api/status. */
export function useStatus(): UseApiResult<StatusResponse> {
return useApiCall(getStatus);
}
/** Fetch registered tools from /api/tools. */
export function useTools(): UseApiResult<ToolSpec[]> {
return useApiCall(getTools);
}
/** Fetch cron jobs from /api/cron. */
export function useCronJobs(): UseApiResult<CronJob[]> {
return useApiCall(getCronJobs);
}
/** Fetch integrations from /api/integrations. */
export function useIntegrations(): UseApiResult<Integration[]> {
return useApiCall(getIntegrations);
}
/** Fetch memory entries, optionally filtered by query and category. */
export function useMemory(
query?: string,
category?: string,
): UseApiResult<MemoryEntry[]> {
const fetcher = useCallback(
() => getMemory(query, category),
[query, category],
);
return useApiCall(fetcher, [query, category]);
}
/** Fetch cost summary from /api/cost. */
export function useCost(): UseApiResult<CostSummary> {
return useApiCall(getCost);
}
/** Fetch CLI tools from /api/cli-tools. */
export function useCliTools(): UseApiResult<CliTool[]> {
return useApiCall(getCliTools);
}
/** Fetch health snapshot from /api/health. */
export function useHealth(): UseApiResult<HealthSnapshot> {
return useApiCall(getHealth);
}
/** Run doctor diagnostics from /api/doctor. */
export function useDoctor(): UseApiResult<DiagResult[]> & {
/** Manually trigger a diagnostic run. */
run: () => void;
} {
const [data, setData] = useState<DiagResult[] | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const run = useCallback(() => {
setLoading(true);
setError(null);
runDoctor()
.then((result) => {
if (mountedRef.current) {
setData(result);
setError(null);
}
})
.catch((err: unknown) => {
if (mountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
}
})
.finally(() => {
if (mountedRef.current) {
setLoading(false);
}
});
}, []);
return { data, error, loading, refetch: run, run };
}

View File

@@ -0,0 +1,128 @@
import {
createContext,
useContext,
useState,
useCallback,
useEffect,
type ReactNode,
} from 'react';
import React from 'react';
import {
getToken as readToken,
setToken as writeToken,
clearToken as removeToken,
isAuthenticated as checkAuth,
} from '../lib/auth';
import { pair as apiPair, getPublicHealth } from '../lib/api';
// ---------------------------------------------------------------------------
// Context shape
// ---------------------------------------------------------------------------
export interface AuthState {
/** The current bearer token, or null if not authenticated. */
token: string | null;
/** Whether the user is currently authenticated. */
isAuthenticated: boolean;
/** Whether the server requires pairing. Defaults to true (safe fallback). */
requiresPairing: boolean;
/** True while the initial auth check is in progress. */
loading: boolean;
/** Pair with the agent using a pairing code. Stores the token on success. */
pair: (code: string) => Promise<void>;
/** Clear the stored token and sign out. */
logout: () => void;
}
const AuthContext = createContext<AuthState | null>(null);
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [token, setTokenState] = useState<string | null>(readToken);
const [authenticated, setAuthenticated] = useState<boolean>(checkAuth);
const [requiresPairing, setRequiresPairing] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(!checkAuth());
// On mount: check if server requires pairing at all
useEffect(() => {
if (checkAuth()) return; // already have a token, no need to check
let cancelled = false;
getPublicHealth()
.then((health) => {
if (cancelled) return;
if (!health.require_pairing) {
setRequiresPairing(false);
setAuthenticated(true);
}
})
.catch(() => {
// health endpoint unreachable — fall back to showing pairing dialog
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// Keep state in sync if localStorage is changed in another tab
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === 'zeroclaw_token') {
const t = readToken();
setTokenState(t);
setAuthenticated(t !== null && t.length > 0);
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, []);
const pair = useCallback(async (code: string): Promise<void> => {
const { token: newToken } = await apiPair(code);
writeToken(newToken);
setTokenState(newToken);
setAuthenticated(true);
}, []);
const logout = useCallback((): void => {
removeToken();
setTokenState(null);
setAuthenticated(false);
}, []);
const value: AuthState = {
token,
isAuthenticated: authenticated,
requiresPairing,
loading,
pair,
logout,
};
return React.createElement(AuthContext.Provider, { value }, children);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Access the authentication state from any component inside `<AuthProvider>`.
* Throws if used outside the provider.
*/
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within an <AuthProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,44 @@
import { useState, useEffect, useCallback } from 'react';
interface Device {
id: string;
name: string | null;
device_type: string | null;
paired_at: string;
last_seen: string;
ip_address: string | null;
}
export function useDevices() {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const token = localStorage.getItem('zeroclaw_token') || '';
const fetchDevices = useCallback(async () => {
try {
setLoading(true);
const res = await fetch('/api/devices', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setDevices(data.devices || []);
setError(null);
} else {
setError(`HTTP ${res.status}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
fetchDevices();
}, [fetchDevices]);
return { devices, loading, error, refetch: fetchDevices };
}

View File

@@ -0,0 +1,45 @@
import { createContext, useContext, useCallback, useRef } from 'react';
/**
* In-memory draft store that survives component unmounts but not page reloads.
* Keyed by an arbitrary string (e.g. route path or conversation id).
*/
export interface DraftContextType {
getDraft: (key: string) => string;
setDraft: (key: string, value: string) => void;
clearDraft: (key: string) => void;
}
export const DraftContext = createContext<DraftContextType>({
getDraft: () => '',
setDraft: () => {},
clearDraft: () => {},
});
export function useDraftStore(): DraftContextType {
const store = useRef<Map<string, string>>(new Map());
const getDraft = useCallback((key: string): string => {
return store.current.get(key) ?? '';
}, []);
const setDraft = useCallback((key: string, value: string): void => {
store.current.set(key, value);
}, []);
const clearDraft = useCallback((key: string): void => {
store.current.delete(key);
}, []);
return { getDraft, setDraft, clearDraft };
}
export function useDraft(key: string) {
const { getDraft, setDraft, clearDraft } = useContext(DraftContext);
return {
draft: getDraft(key),
saveDraft: (value: string) => setDraft(key, value),
clearDraft: () => clearDraft(key),
};
}

View File

@@ -0,0 +1,124 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { SSEClient, type SSEClientOptions } from '../lib/sse';
import type { SSEEvent } from '../types/api';
export type SSEConnectionStatus = 'disconnected' | 'connecting' | 'connected';
export interface UseSSEResult {
/** Array of all events received during this session. */
events: SSEEvent[];
/** Current connection status. */
status: SSEConnectionStatus;
/** Manually connect (called automatically on mount). */
connect: () => void;
/** Manually disconnect. */
disconnect: () => void;
/** Clear the event history. */
clearEvents: () => void;
}
export interface UseSSEOptions extends SSEClientOptions {
/** If false, do not connect automatically on mount. Default true. */
autoConnect?: boolean;
/** Maximum number of events to keep in the buffer. Default 500. */
maxEvents?: number;
/** Optional filter: only keep events whose type matches. */
filterTypes?: string[];
}
/**
* React hook that wraps the SSEClient for live event streaming.
*
* Connects on mount (unless `autoConnect` is false), accumulates incoming
* events, and cleans up on unmount.
*/
export function useSSE(options: UseSSEOptions = {}): UseSSEResult {
const {
autoConnect = true,
maxEvents = 500,
filterTypes,
...sseOptions
} = options;
const clientRef = useRef<SSEClient | null>(null);
const [status, setStatus] = useState<SSEConnectionStatus>('disconnected');
const [events, setEvents] = useState<SSEEvent[]>([]);
// Keep filter in a ref so the callback doesn't need to be recreated
const filterRef = useRef(filterTypes);
filterRef.current = filterTypes;
const maxRef = useRef(maxEvents);
maxRef.current = maxEvents;
// Stable reference to the client across renders
const getClient = useCallback((): SSEClient => {
if (!clientRef.current) {
clientRef.current = new SSEClient(sseOptions);
}
return clientRef.current;
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Setup handlers and optionally connect on mount
useEffect(() => {
const client = getClient();
client.onConnect = () => {
setStatus('connected');
};
client.onEvent = (event: SSEEvent) => {
// Apply type filter if configured
if (filterRef.current && filterRef.current.length > 0) {
if (!filterRef.current.includes(event.type)) return;
}
setEvents((prev) => {
const next = [...prev, event];
// Trim to max buffer size
if (next.length > maxRef.current) {
return next.slice(next.length - maxRef.current);
}
return next;
});
};
client.onError = () => {
setStatus('disconnected');
};
if (autoConnect) {
setStatus('connecting');
client.connect();
}
return () => {
client.disconnect();
clientRef.current = null;
};
}, [getClient, autoConnect]);
const connect = useCallback(() => {
const client = getClient();
setStatus('connecting');
client.connect();
}, [getClient]);
const disconnect = useCallback(() => {
const client = getClient();
client.disconnect();
setStatus('disconnected');
}, [getClient]);
const clearEvents = useCallback(() => {
setEvents([]);
}, []);
return {
events,
status,
connect,
disconnect,
clearEvents,
};
}

View File

@@ -0,0 +1,4 @@
import { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContextDef';
export const useTheme = () => useContext(ThemeContext);

View File

@@ -0,0 +1,118 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { WebSocketClient, type WebSocketClientOptions } from '../lib/ws';
import type { WsMessage } from '../types/api';
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
export interface UseWebSocketResult {
/** Send a chat message to the agent. */
sendMessage: (content: string) => void;
/** Array of all messages received during this session. */
messages: WsMessage[];
/** Current connection status. */
status: ConnectionStatus;
/** Manually connect (called automatically on mount). */
connect: () => void;
/** Manually disconnect. */
disconnect: () => void;
/** Clear the message history. */
clearMessages: () => void;
}
export interface UseWebSocketOptions extends WebSocketClientOptions {
/** If false, do not connect automatically on mount. Default true. */
autoConnect?: boolean;
}
/**
* React hook that wraps the WebSocketClient for agent chat.
*
* Connects on mount (unless `autoConnect` is false), accumulates incoming
* messages, and cleans up on unmount.
*/
export function useWebSocket(
options: UseWebSocketOptions = {},
): UseWebSocketResult {
const { autoConnect = true, ...wsOptions } = options;
const clientRef = useRef<WebSocketClient | null>(null);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [messages, setMessages] = useState<WsMessage[]>([]);
// Stable reference to the client across renders
const getClient = useCallback((): WebSocketClient => {
if (!clientRef.current) {
clientRef.current = new WebSocketClient(wsOptions);
}
return clientRef.current;
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Setup handlers and optionally connect on mount
useEffect(() => {
const client = getClient();
client.onOpen = () => {
setStatus('connected');
};
client.onClose = () => {
setStatus('disconnected');
};
client.onMessage = (msg: WsMessage) => {
setMessages((prev) => [...prev, msg]);
};
client.onError = () => {
// Status will be set by onClose which fires after onError
};
if (autoConnect) {
setStatus('connecting');
client.connect();
}
return () => {
client.disconnect();
clientRef.current = null;
};
}, [getClient, autoConnect]);
const connect = useCallback(() => {
const client = getClient();
setStatus('connecting');
client.connect();
}, [getClient]);
const disconnect = useCallback(() => {
const client = getClient();
client.disconnect();
setStatus('disconnected');
}, [getClient]);
const sendMessage = useCallback(
(content: string) => {
const client = getClient();
client.sendMessage(content);
// Optimistically add the user message to the local list
setMessages((prev) => [
...prev,
{ type: 'message', content } as WsMessage,
]);
},
[getClient],
);
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
return {
sendMessage,
messages,
status,
connect,
disconnect,
clearMessages,
};
}

675
third_party/zeroclaw/web/src/index.css vendored Normal file
View File

@@ -0,0 +1,675 @@
@import "tailwindcss";
@theme {
/* Theme-aware colors mapped to CSS custom properties */
--color-pc-base: var(--pc-bg-base);
--color-pc-surface: var(--pc-bg-surface);
--color-pc-elevated: var(--pc-bg-elevated);
--color-pc-input: var(--pc-bg-input);
--color-pc-code: var(--pc-bg-code);
--color-pc-border: var(--pc-border);
--color-pc-border-strong: var(--pc-border-strong);
--color-pc-text: var(--pc-text-primary);
--color-pc-text-secondary: var(--pc-text-secondary);
--color-pc-text-muted: var(--pc-text-muted);
--color-pc-text-faint: var(--pc-text-faint);
--color-pc-accent: var(--pc-accent);
--color-pc-accent-light: var(--pc-accent-light);
--color-pc-accent-dim: var(--pc-accent-dim);
--color-pc-accent-glow: var(--pc-accent-glow);
/* Status colors (fixed across themes) */
--color-status-success: #00e68a;
--color-status-warning: #ffaa00;
--color-status-error: #ff4466;
--color-status-info: #0080ff;
}
:root {
/* Status colors for reference */
--color-status-success: #00e68a;
--color-status-warning: #ffaa00;
--color-status-error: #ff4466;
--color-status-info: #0080ff;
/* Backgrounds */
--pc-bg-base: #1e1e24;
--pc-bg-surface: #232329;
--pc-bg-elevated: #27272a;
--pc-bg-input: #1a1a20;
--pc-bg-code: #1a1a20;
--pc-bg-sidebar: rgba(30, 30, 36, 0.95);
/* Borders */
--pc-border: rgba(255, 255, 255, 0.08);
--pc-border-strong: rgba(255, 255, 255, 0.1);
/* Text */
--pc-text-primary: #d4d4d8;
--pc-text-secondary: #a1a1aa;
--pc-text-muted: #71717a;
--pc-text-faint: #52525b;
/* Accent (cyan) */
--pc-accent: #22d3ee;
--pc-accent-light: #67e8f9;
--pc-accent-dim: rgba(34, 211, 238, 0.3);
--pc-accent-glow: rgba(34, 211, 238, 0.1);
--pc-accent-rgb: 34, 211, 238;
/* Hover */
--pc-hover: rgba(255, 255, 255, 0.05);
--pc-hover-strong: rgba(255, 255, 255, 0.08);
--pc-separator: rgba(255, 255, 255, 0.05);
/* Scrollbar */
--pc-scrollbar-thumb: #52525b;
--pc-scrollbar-track: #27272a;
--pc-scrollbar-thumb-hover: #71717a;
/* Fonts */
--pc-font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--pc-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
--pc-font-size: 15px;
--pc-font-size-mono: 14px;
}
html {
}
body {
background-color: var(--pc-bg-base);
color: var(--pc-text-primary);
font-family: var(--pc-font-ui);
font-size: var(--pc-font-size);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
overflow-x: hidden;
max-width: 100vw;
}
#root {
min-height: 100vh;
}
/* Focus ring */
:focus-visible {
outline: 2px solid var(--pc-accent-dim);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
/* Scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--pc-scrollbar-thumb) var(--pc-scrollbar-track);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--pc-scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--pc-scrollbar-thumb-hover);
}
textarea::-webkit-scrollbar {
width: 4px;
height: 0;
}
textarea::-webkit-scrollbar:horizontal {
display: none;
}
textarea::-webkit-scrollbar-thumb {
background: var(--pc-scrollbar-thumb);
border-radius: 2px;
}
textarea::-webkit-scrollbar-thumb:hover {
background: var(--pc-scrollbar-thumb-hover);
}
textarea {
overflow-x: hidden;
overflow-y: auto;
overflow-wrap: break-word;
word-break: break-word;
}
code, kbd, pre {
font-family: var(--pc-font-mono);
}
/* ── Animations ── */
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes slideInLeft {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slideInUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes bounce-dot {
0%, 80%, 100% { transform: translateY(0); opacity: 0.45; }
40% { transform: translateY(-4px); opacity: 1; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-4px); }
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.animate-fade-in-legacy {
animation: fadeIn 0.4s ease-out both;
}
.animate-fade-in-scale {
animation: fadeInScale 0.3s ease-out both;
}
.animate-slide-in-left {
animation: slideInLeft 0.4s ease-out both;
}
.animate-slide-in-right {
animation: slideInRight 0.4s ease-out both;
}
.animate-slide-in-up {
animation: slideInUp 0.4s ease-out both;
}
.animate-pulse-glow {
animation: fadeIn 2s ease-in-out infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.pulse-dot {
animation: pulse-dot 2s ease-in-out infinite;
}
.bounce-dot {
animation: bounce-dot 0.9s infinite ease-in-out;
}
.bounce-dot:nth-child(1) { animation-delay: 0s; }
.bounce-dot:nth-child(2) { animation-delay: 0.12s; }
.bounce-dot:nth-child(3) { animation-delay: 0.24s; }
/* Stagger delays */
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
.stagger-children > *:nth-child(4) { animation-delay: 180ms; }
.stagger-children > *:nth-child(5) { animation-delay: 240ms; }
.stagger-children > *:nth-child(6) { animation-delay: 300ms; }
.stagger-children > *:nth-child(7) { animation-delay: 360ms; }
.stagger-children > *:nth-child(8) { animation-delay: 420ms; }
.stagger-children > *:nth-child(9) { animation-delay: 480ms; }
.stagger-children > *:nth-child(10) { animation-delay: 540ms; }
/* ── Utility classes ── */
/* Card */
.card {
background: var(--pc-bg-surface);
border: 1px solid var(--pc-border);
border-radius: 1rem;
transition: all 0.3s ease;
}
.card:hover {
background: var(--pc-bg-elevated);
border-color: var(--pc-border-strong);
}
/* Glass card */
.glass-card {
background: var(--pc-bg-surface);
border: 1px solid var(--pc-border);
border-radius: 1rem;
backdrop-filter: blur(16px);
transition: all 0.3s ease;
}
.glass-card:hover {
border-color: var(--pc-border-strong);
background: var(--pc-bg-elevated);
}
/* Surface panel */
.surface-panel {
background: var(--pc-bg-surface);
border: 1px solid var(--pc-border);
border-radius: 1.25rem;
backdrop-filter: blur(16px);
}
/* Electric button (primary action) */
.btn-electric {
background: var(--pc-accent);
color: white;
border: none;
border-radius: 0.75rem;
font-weight: 500;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.btn-electric:hover:not(:disabled) {
opacity: 0.9;
box-shadow: 0 8px 24px rgba(var(--pc-accent-rgb), 0.15);
}
.btn-electric:active:not(:disabled) {
transform: translateY(0);
}
.btn-electric:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Electric input */
.input-electric {
background: var(--pc-bg-input);
border: 1px solid var(--pc-border);
border-radius: 0.75rem;
color: var(--pc-text-primary);
transition: all 0.3s ease;
}
.input-electric:focus {
outline: none;
border-color: var(--pc-accent-dim);
box-shadow: 0 0 0 3px var(--pc-accent-glow);
}
.input-electric::placeholder {
color: var(--pc-text-muted);
}
/* Primary action (pill) */
.btn-primary {
background: var(--pc-accent);
color: white;
border-radius: 1rem;
font-weight: 600;
font-size: 0.875rem;
padding: 0.5rem 1.25rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(var(--pc-accent-rgb), 0.2);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Secondary button */
.btn-secondary {
background: var(--pc-bg-elevated);
color: var(--pc-text-secondary);
border: 1px solid var(--pc-border);
border-radius: 0.75rem;
font-weight: 500;
font-size: 0.875rem;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-secondary:hover:not(:disabled) {
background: var(--pc-hover);
color: var(--pc-text-primary);
border-color: var(--pc-border-strong);
}
.btn-secondary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Icon button */
.btn-icon {
padding: 0.5rem;
border-radius: 0.75rem;
color: var(--pc-text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: var(--pc-hover);
color: var(--pc-text-secondary);
}
/* Danger button */
.btn-danger {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 0.75rem;
font-weight: 500;
font-size: 0.875rem;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.15);
}
.btn-danger:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Status badge */
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid;
}
.badge-success {
background: rgba(0, 230, 138, 0.08);
color: #34d399;
border-color: rgba(0, 230, 138, 0.2);
}
.badge-warning {
background: rgba(255, 170, 0, 0.08);
color: #fbbf24;
border-color: rgba(255, 170, 0, 0.2);
}
.badge-error {
background: rgba(255, 68, 102, 0.08);
color: #f87171;
border-color: rgba(255, 68, 102, 0.2);
}
.badge-info {
background: rgba(0, 128, 255, 0.08);
color: #60a5fa;
border-color: rgba(0, 128, 255, 0.2);
}
/* Status dot */
.status-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
flex-shrink: 0;
}
.status-dot-success {
background: var(--color-status-success);
box-shadow: 0 0 6px var(--color-status-success);
}
.status-dot-warning {
background: var(--color-status-warning);
box-shadow: 0 0 6px var(--color-status-warning);
}
.status-dot-error {
background: var(--color-status-error);
box-shadow: 0 0 6px var(--color-status-error);
}
.status-dot-info {
background: var(--color-status-info);
box-shadow: 0 0 6px var(--color-status-info);
}
/* Glow dot (legacy) */
.glow-dot {
box-shadow: 0 0 6px currentColor;
}
/* Gradient text */
.text-gradient-blue {
background: linear-gradient(135deg, #0080ff, #00d4ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Modal backdrop */
.modal-backdrop {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
}
/* Progress bar */
.progress-bar-animated {
position: relative;
overflow: hidden;
}
.progress-bar-animated::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
/* Table */
.table-electric {
width: 100%;
}
.table-electric thead tr {
border-bottom: 1px solid var(--pc-border);
}
.table-electric thead th {
color: var(--pc-text-muted);
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.75rem 1rem;
position: sticky;
top: 0;
z-index: 1;
/* Match glass-card background so rows do not bleed through on scroll */
background: linear-gradient(135deg, rgba(13, 13, 32, 0.95), rgba(5, 5, 16, 0.90));
backdrop-filter: blur(8px);
}
.table-electric tbody tr {
border-bottom: 1px solid var(--pc-separator);
transition: all 0.2s ease;
}
.table-electric tbody tr:hover {
background: var(--pc-hover);
}
.table-electric tbody td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: var(--pc-text-primary);
}
/* ── Markdown styles ── */
.markdown-body pre {
background: var(--pc-bg-code) !important;
border: 1px solid var(--pc-border);
border-radius: 0.75rem;
padding: 1rem;
overflow-x: auto;
margin: 0.5rem 0;
font-family: var(--pc-font-mono);
font-size: calc(var(--pc-font-size-mono) * 0.9);
line-height: 1.6;
max-width: 100%;
box-sizing: border-box;
}
.markdown-body pre code {
white-space: pre;
word-break: normal;
overflow-wrap: normal;
}
.markdown-body code {
background: var(--pc-accent-glow);
padding: 2px 6px;
border-radius: 6px;
font-size: calc(var(--pc-font-size-mono) * 0.95);
font-family: var(--pc-font-mono);
}
.markdown-body p { margin: 4px 0; }
.markdown-body ul, .markdown-body ol { margin: 4px 0; padding-left: 20px; }
.markdown-body ul { list-style-type: disc; }
.markdown-body ol { list-style-type: decimal; }
.markdown-body li { margin: 2px 0; }
.markdown-body li > ul, .markdown-body li > ol { margin: 2px 0; }
.markdown-body li > ul { list-style-type: circle; }
.markdown-body li > ul > li > ul { list-style-type: square; }
.markdown-body blockquote { border-left: 3px solid var(--pc-accent-dim); padding-left: 12px; margin: 8px 0; opacity: 0.8; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 12px 0 4px; }
.markdown-body a { color: var(--pc-accent-light); text-decoration: underline; }
.markdown-body table { border-collapse: collapse; margin: 8px 0; display: block; overflow-x: auto; max-width: 100%; }
.markdown-body th, .markdown-body td { border: 1px solid var(--pc-border); padding: 6px 12px; }
.markdown-body th { background: var(--pc-accent-glow); }
.markdown-body img { max-width: 100%; border-radius: 8px; }
/* ── Chat markdown (agent bubbles) ── */
.chat-markdown p { margin: 0.5em 0; }
.chat-markdown p:first-child { margin-top: 0; }
.chat-markdown p:last-child { margin-bottom: 0; }
.chat-markdown ul, .chat-markdown ol { margin: 0.5em 0; padding-left: 1.5em; }
.chat-markdown ul { list-style-type: disc; }
.chat-markdown ol { list-style-type: decimal; }
.chat-markdown li { margin: 0.25em 0; }
.chat-markdown li > ul { list-style-type: circle; }
.chat-markdown li > ul > li > ul { list-style-type: square; }
.chat-markdown blockquote { border-left: 3px solid var(--pc-accent-dim); padding-left: 0.75em; margin: 0.5em 0; color: var(--pc-text-muted); }
.chat-markdown h1, .chat-markdown h2, .chat-markdown h3, .chat-markdown h4 { margin: 0.75em 0 0.25em; font-weight: 600; }
.chat-markdown h1 { font-size: 1.25em; }
.chat-markdown h2 { font-size: 1.125em; }
.chat-markdown h3 { font-size: 1em; }
.chat-markdown hr { border: none; border-top: 1px solid var(--pc-border); margin: 0.75em 0; }
.chat-markdown a { color: var(--pc-accent-light); text-decoration: underline; }
.chat-markdown strong { font-weight: 600; color: var(--pc-text-primary); }
.chat-markdown em { font-style: italic; }
.chat-markdown code { background: var(--pc-accent-glow); padding: 0.125em 0.375em; border-radius: 0.375em; font-size: 0.875em; font-family: var(--pc-font-mono); }
.chat-markdown pre { background: var(--pc-bg-code); border: 1px solid var(--pc-border); border-radius: 0.5em; padding: 0.75em; margin: 0.5em 0; overflow-x: auto; font-family: var(--pc-font-mono); font-size: 0.85em; line-height: 1.5; }
.chat-markdown pre code { background: transparent; padding: 0; border-radius: 0; font-size: inherit; }
.chat-markdown table { border-collapse: collapse; margin: 0.5em 0; display: block; overflow-x: auto; max-width: 100%; }
.chat-markdown th, .chat-markdown td { border: 1px solid var(--pc-border); padding: 0.375em 0.75em; font-size: 0.875em; }
.chat-markdown th { background: var(--pc-accent-glow); font-weight: 600; }
.chat-markdown img { max-width: 100%; border-radius: 0.5em; }
/* ── Accessibility: reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-fade-in-legacy,
.animate-fade-in-scale,
.animate-slide-in-left,
.animate-slide-in-right,
.animate-slide-in-up,
.animate-pulse-glow,
.animate-float {
animation: none;
}
.pulse-dot {
animation: none;
}
.bounce-dot {
animation: none;
opacity: 0.7;
}
.progress-bar-animated::after {
animation: none;
}
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

328
third_party/zeroclaw/web/src/lib/api.ts vendored Normal file
View File

@@ -0,0 +1,328 @@
import type {
StatusResponse,
ToolSpec,
CronJob,
CronRun,
Integration,
DiagResult,
MemoryEntry,
CostSummary,
CliTool,
HealthSnapshot,
Session,
ChannelDetail,
} from '../types/api';
import { clearToken, getToken, setToken } from './auth';
import { apiOrigin, basePath } from './basePath';
// ---------------------------------------------------------------------------
// Base fetch wrapper
// ---------------------------------------------------------------------------
export class UnauthorizedError extends Error {
constructor() {
super('Unauthorized');
this.name = 'UnauthorizedError';
}
}
export async function apiFetch<T = unknown>(
path: string,
options: RequestInit = {},
): Promise<T> {
const token = getToken();
const headers = new Headers(options.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
if (
options.body &&
typeof options.body === 'string' &&
!headers.has('Content-Type')
) {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(`${apiOrigin}${basePath}${path}`, { ...options, headers });
if (response.status === 401) {
clearToken();
window.dispatchEvent(new Event('zeroclaw-unauthorized'));
throw new UnauthorizedError();
}
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`API ${response.status}: ${text || response.statusText}`);
}
// Some endpoints may return 204 No Content
if (response.status === 204) {
return undefined as unknown as T;
}
return response.json() as Promise<T>;
}
function unwrapField<T>(value: T | Record<string, T>, key: string): T {
if (value !== null && typeof value === 'object' && !Array.isArray(value) && key in value) {
const unwrapped = (value as Record<string, T | undefined>)[key];
if (unwrapped !== undefined) {
return unwrapped;
}
}
return value as T;
}
// ---------------------------------------------------------------------------
// Pairing
// ---------------------------------------------------------------------------
export async function pair(code: string): Promise<{ token: string }> {
const response = await fetch(`${basePath}/pair`, {
method: 'POST',
headers: { 'X-Pairing-Code': code },
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Pairing failed (${response.status}): ${text || response.statusText}`);
}
const data = (await response.json()) as { token: string };
setToken(data.token);
return data;
}
export async function getAdminPairCode(): Promise<{ pairing_code: string | null; pairing_required: boolean }> {
const response = await fetch('/admin/paircode');
if (!response.ok) {
throw new Error(`Failed to fetch pairing code (${response.status})`);
}
return response.json() as Promise<{ pairing_code: string | null; pairing_required: boolean }>;
}
// ---------------------------------------------------------------------------
// Public health (no auth required)
// ---------------------------------------------------------------------------
export async function getPublicHealth(): Promise<{ require_pairing: boolean; paired: boolean }> {
const response = await fetch(`${basePath}/health`);
if (!response.ok) {
throw new Error(`Health check failed (${response.status})`);
}
return response.json() as Promise<{ require_pairing: boolean; paired: boolean }>;
}
// ---------------------------------------------------------------------------
// Status / Health
// ---------------------------------------------------------------------------
export function getStatus(): Promise<StatusResponse> {
return apiFetch<StatusResponse>('/api/status');
}
export function getHealth(): Promise<HealthSnapshot> {
return apiFetch<HealthSnapshot | { health: HealthSnapshot }>('/api/health').then((data) =>
unwrapField(data, 'health'),
);
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
export function getConfig(): Promise<string> {
return apiFetch<string | { format?: string; content: string }>('/api/config').then((data) =>
typeof data === 'string' ? data : data.content,
);
}
export function putConfig(toml: string): Promise<void> {
return apiFetch<void>('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/toml' },
body: toml,
});
}
// ---------------------------------------------------------------------------
// Tools
// ---------------------------------------------------------------------------
export function getTools(): Promise<ToolSpec[]> {
return apiFetch<ToolSpec[] | { tools: ToolSpec[] }>('/api/tools').then((data) =>
unwrapField(data, 'tools'),
);
}
// ---------------------------------------------------------------------------
// Cron
// ---------------------------------------------------------------------------
export function getCronJobs(): Promise<CronJob[]> {
return apiFetch<CronJob[] | { jobs: CronJob[] }>('/api/cron').then((data) =>
unwrapField(data, 'jobs'),
);
}
export function addCronJob(body: {
name?: string;
command: string;
schedule: string;
enabled?: boolean;
}): Promise<CronJob> {
return apiFetch<CronJob | { status: string; job: CronJob }>('/api/cron', {
method: 'POST',
body: JSON.stringify(body),
}).then((data) => (typeof (data as { job?: CronJob }).job === 'object' ? (data as { job: CronJob }).job : (data as CronJob)));
}
export function deleteCronJob(id: string): Promise<void> {
return apiFetch<void>(`/api/cron/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}
export function patchCronJob(
id: string,
patch: { name?: string; schedule?: string; command?: string },
): Promise<CronJob> {
return apiFetch<CronJob | { status: string; job: CronJob }>(
`/api/cron/${encodeURIComponent(id)}`,
{
method: 'PATCH',
body: JSON.stringify(patch),
},
).then((data) => (typeof (data as { job?: CronJob }).job === 'object' ? (data as { job: CronJob }).job : (data as CronJob)));
}
export function getCronRuns(
jobId: string,
limit: number = 20,
): Promise<CronRun[]> {
const params = new URLSearchParams({ limit: String(limit) });
return apiFetch<CronRun[] | { runs: CronRun[] }>(
`/api/cron/${encodeURIComponent(jobId)}/runs?${params}`,
).then((data) => unwrapField(data, 'runs'));
}
export interface CronSettings {
enabled: boolean;
catch_up_on_startup: boolean;
max_run_history: number;
}
export function getCronSettings(): Promise<CronSettings> {
return apiFetch<CronSettings>('/api/cron/settings');
}
export function patchCronSettings(
patch: Partial<CronSettings>,
): Promise<CronSettings> {
return apiFetch<CronSettings & { status: string }>('/api/cron/settings', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------
export function getIntegrations(): Promise<Integration[]> {
return apiFetch<Integration[] | { integrations: Integration[] }>('/api/integrations').then(
(data) => unwrapField(data, 'integrations'),
);
}
// ---------------------------------------------------------------------------
// Doctor / Diagnostics
// ---------------------------------------------------------------------------
export function runDoctor(): Promise<DiagResult[]> {
return apiFetch<DiagResult[] | { results: DiagResult[]; summary?: unknown }>('/api/doctor', {
method: 'POST',
body: JSON.stringify({}),
}).then((data) => (Array.isArray(data) ? data : data.results));
}
// ---------------------------------------------------------------------------
// Memory
// ---------------------------------------------------------------------------
export function getMemory(
query?: string,
category?: string,
): Promise<MemoryEntry[]> {
const params = new URLSearchParams();
if (query) params.set('query', query);
if (category) params.set('category', category);
const qs = params.toString();
return apiFetch<MemoryEntry[] | { entries: MemoryEntry[] }>(`/api/memory${qs ? `?${qs}` : ''}`).then(
(data) => unwrapField(data, 'entries'),
);
}
export function storeMemory(
key: string,
content: string,
category?: string,
): Promise<void> {
return apiFetch<unknown>('/api/memory', {
method: 'POST',
body: JSON.stringify({ key, content, category }),
}).then(() => undefined);
}
export function deleteMemory(key: string): Promise<void> {
return apiFetch<void>(`/api/memory/${encodeURIComponent(key)}`, {
method: 'DELETE',
});
}
// ---------------------------------------------------------------------------
// Cost
// ---------------------------------------------------------------------------
export function getCost(): Promise<CostSummary> {
return apiFetch<CostSummary | { cost: CostSummary }>('/api/cost').then((data) =>
unwrapField(data, 'cost'),
);
}
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
export function getSessions(): Promise<Session[]> {
return apiFetch<Session[] | { sessions: Session[] }>('/api/sessions').then((data) =>
unwrapField(data, 'sessions'),
);
}
export function getSession(id: string): Promise<Session> {
return apiFetch<Session>(`/api/sessions/${encodeURIComponent(id)}`);
}
// ---------------------------------------------------------------------------
// Channels (detailed)
// ---------------------------------------------------------------------------
export function getChannels(): Promise<ChannelDetail[]> {
return apiFetch<ChannelDetail[] | { channels: ChannelDetail[] }>('/api/channels').then((data) =>
unwrapField(data, 'channels'),
);
}
// ---------------------------------------------------------------------------
// CLI Tools
// ---------------------------------------------------------------------------
export function getCliTools(): Promise<CliTool[]> {
return apiFetch<CliTool[] | { cli_tools: CliTool[] }>('/api/cli-tools').then((data) =>
unwrapField(data, 'cli_tools'),
);
}

View File

@@ -0,0 +1,42 @@
const TOKEN_KEY = 'zeroclaw_token';
/**
* Retrieve the stored authentication token.
*/
export function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
/**
* Store an authentication token.
*/
export function setToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
// localStorage may be unavailable (e.g. in some private browsing modes)
}
}
/**
* Remove the stored authentication token.
*/
export function clearToken(): void {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
// Ignore
}
}
/**
* Returns true if a token is currently stored.
*/
export function isAuthenticated(): boolean {
const token = getToken();
return token !== null && token.length > 0;
}

View File

@@ -0,0 +1,20 @@
// Runtime base path injected by the Rust gateway into index.html.
// Allows the SPA to work under a reverse-proxy path prefix.
// When running inside Tauri, the frontend is served from disk so basePath is
// empty and API calls target the gateway URL directly.
import { isTauri, tauriGatewayUrl } from './tauri';
declare global {
interface Window {
__ZEROCLAW_BASE__?: string;
}
}
/** Gateway path prefix (e.g. "/zeroclaw"), or empty string when served at root. */
export const basePath: string = isTauri()
? ''
: (window.__ZEROCLAW_BASE__ ?? '').replace(/\/+$/, '');
/** Full origin for API requests. Empty when served by the gateway (same-origin). */
export const apiOrigin: string = isTauri() ? tauriGatewayUrl() : '';

1159
third_party/zeroclaw/web/src/lib/i18n.ts vendored Normal file

File diff suppressed because it is too large Load Diff

186
third_party/zeroclaw/web/src/lib/sse.ts vendored Normal file
View File

@@ -0,0 +1,186 @@
import type { SSEEvent } from '../types/api';
import { getToken } from './auth';
import { apiOrigin, basePath } from './basePath';
export type SSEEventHandler = (event: SSEEvent) => void;
export type SSEErrorHandler = (error: Event | Error) => void;
export interface SSEClientOptions {
/** Endpoint path. Defaults to "/api/events". */
path?: string;
/** Delay in ms before attempting reconnect. Doubles on each failure up to maxReconnectDelay. */
reconnectDelay?: number;
/** Maximum reconnect delay in ms. */
maxReconnectDelay?: number;
/** Set to false to disable auto-reconnect. Default true. */
autoReconnect?: boolean;
}
const DEFAULT_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
/**
* SSE client that connects to the ZeroClaw event stream.
*
* Because the native EventSource API does not support custom headers, we use
* the fetch API with a ReadableStream to consume the text/event-stream
* response, allowing us to pass the Authorization bearer token.
*/
export class SSEClient {
private controller: AbortController | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private currentDelay: number;
private intentionallyClosed = false;
public onEvent: SSEEventHandler | null = null;
public onError: SSEErrorHandler | null = null;
public onConnect: (() => void) | null = null;
private readonly path: string;
private readonly reconnectDelay: number;
private readonly maxReconnectDelay: number;
private readonly autoReconnect: boolean;
constructor(options: SSEClientOptions = {}) {
this.path = options.path ?? `${apiOrigin}${basePath}/api/events`;
this.reconnectDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
this.maxReconnectDelay = options.maxReconnectDelay ?? MAX_RECONNECT_DELAY;
this.autoReconnect = options.autoReconnect ?? true;
this.currentDelay = this.reconnectDelay;
}
/** Start consuming the event stream. */
connect(): void {
this.intentionallyClosed = false;
this.clearReconnectTimer();
this.controller = new AbortController();
const token = getToken();
const headers: Record<string, string> = {
Accept: 'text/event-stream',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
fetch(this.path, {
headers,
signal: this.controller.signal,
})
.then((response) => {
if (!response.ok) {
throw new Error(`SSE connection failed: ${response.status}`);
}
if (!response.body) {
throw new Error('SSE response has no body');
}
this.currentDelay = this.reconnectDelay;
this.onConnect?.();
return this.consumeStream(response.body);
})
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === 'AbortError') {
return; // intentional disconnect
}
this.onError?.(err instanceof Error ? err : new Error(String(err)));
this.scheduleReconnect();
});
}
/** Stop consuming events without auto-reconnecting. */
disconnect(): void {
this.intentionallyClosed = true;
this.clearReconnectTimer();
if (this.controller) {
this.controller.abort();
this.controller = null;
}
}
// ---------------------------------------------------------------------------
// Stream consumption
// ---------------------------------------------------------------------------
private async consumeStream(body: ReadableStream<Uint8Array>): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE events are separated by double newlines
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? '';
for (const part of parts) {
this.parseEvent(part);
}
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
this.onError?.(err instanceof Error ? err : new Error(String(err)));
} finally {
reader.releaseLock();
}
// Stream ended schedule reconnect
this.scheduleReconnect();
}
private parseEvent(raw: string): void {
let eventType = 'message';
const dataLines: string[] = [];
for (const line of raw.split('\n')) {
if (line.startsWith('event:')) {
eventType = line.slice(6).trim();
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
// Ignore comments (lines starting with ':') and other fields
}
if (dataLines.length === 0) return;
const dataStr = dataLines.join('\n');
let parsed: SSEEvent;
try {
parsed = JSON.parse(dataStr) as SSEEvent;
parsed.type = parsed.type ?? eventType;
} catch {
parsed = { type: eventType, data: dataStr };
}
this.onEvent?.(parsed);
}
// ---------------------------------------------------------------------------
// Reconnection logic
// ---------------------------------------------------------------------------
private scheduleReconnect(): void {
if (this.intentionallyClosed || !this.autoReconnect) return;
this.reconnectTimer = setTimeout(() => {
this.currentDelay = Math.min(this.currentDelay * 2, this.maxReconnectDelay);
this.connect();
}, this.currentDelay);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}

View File

@@ -0,0 +1,15 @@
// Tauri detection utilities for ZeroClaw Desktop.
declare global {
interface Window {
__TAURI__?: unknown;
__ZEROCLAW_GATEWAY__?: string;
}
}
/** Returns true when running inside a Tauri WebView. */
export const isTauri = (): boolean => '__TAURI__' in window;
/** Gateway base URL when running inside Tauri (defaults to localhost). */
export const tauriGatewayUrl = (): string =>
window.__ZEROCLAW_GATEWAY__ ?? 'http://127.0.0.1:42617';

View File

@@ -0,0 +1,27 @@
/**
* Generate a UUID v4 string.
*
* Uses `crypto.randomUUID()` when available (modern browsers, secure contexts)
* and falls back to a manual implementation backed by `crypto.getRandomValues()`
* for older browsers (e.g. Safari < 15.4, some Electron/Raspberry-Pi builds).
*
* Closes #3303, #3261.
*/
export function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback: RFC 4122 version 4 UUID via getRandomValues
// crypto must exist if we reached here (only randomUUID is missing)
const c = globalThis.crypto;
const bytes = new Uint8Array(16);
c.getRandomValues(bytes);
// Set version (4) and variant (10xx) bits per RFC 4122
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

152
third_party/zeroclaw/web/src/lib/ws.ts vendored Normal file
View File

@@ -0,0 +1,152 @@
import type { WsMessage } from '../types/api';
import { getToken } from './auth';
import { apiOrigin, basePath } from './basePath';
import { isTauri } from './tauri';
import { generateUUID } from './uuid';
export type WsMessageHandler = (msg: WsMessage) => void;
export type WsOpenHandler = () => void;
export type WsCloseHandler = (ev: CloseEvent) => void;
export type WsErrorHandler = (ev: Event) => void;
export interface WebSocketClientOptions {
/** Base URL override. Defaults to current host with ws(s) protocol. */
baseUrl?: string;
/** Delay in ms before attempting reconnect. Doubles on each failure up to maxReconnectDelay. */
reconnectDelay?: number;
/** Maximum reconnect delay in ms. */
maxReconnectDelay?: number;
/** Set to false to disable auto-reconnect. Default true. */
autoReconnect?: boolean;
}
const DEFAULT_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const SESSION_STORAGE_KEY = 'zeroclaw_session_id';
/** Return a stable session ID, persisted in sessionStorage across reconnects. */
function getOrCreateSessionId(): string {
let id = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!id) {
id = generateUUID();
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
}
return id;
}
export class WebSocketClient {
private ws: WebSocket | null = null;
private currentDelay: number;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionallyClosed = false;
public onMessage: WsMessageHandler | null = null;
public onOpen: WsOpenHandler | null = null;
public onClose: WsCloseHandler | null = null;
public onError: WsErrorHandler | null = null;
private readonly baseUrl: string;
private readonly reconnectDelay: number;
private readonly maxReconnectDelay: number;
private readonly autoReconnect: boolean;
constructor(options: WebSocketClientOptions = {}) {
let defaultBase: string;
if (isTauri() && apiOrigin) {
// In Tauri, derive ws URL from the gateway origin.
defaultBase = apiOrigin.replace(/^http/, 'ws');
} else {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
defaultBase = `${protocol}//${window.location.host}`;
}
this.baseUrl = options.baseUrl ?? defaultBase;
this.reconnectDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
this.maxReconnectDelay = options.maxReconnectDelay ?? MAX_RECONNECT_DELAY;
this.autoReconnect = options.autoReconnect ?? true;
this.currentDelay = this.reconnectDelay;
}
/** Open the WebSocket connection. */
connect(): void {
this.intentionallyClosed = false;
this.clearReconnectTimer();
const token = getToken();
const sessionId = getOrCreateSessionId();
const params = new URLSearchParams();
if (token) params.set('token', token);
params.set('session_id', sessionId);
const url = `${this.baseUrl}${basePath}/ws/chat?${params.toString()}`;
const protocols: string[] = ['zeroclaw.v1'];
if (token) protocols.push(`bearer.${token}`);
this.ws = new WebSocket(url, protocols);
this.ws.onopen = () => {
this.currentDelay = this.reconnectDelay;
this.onOpen?.();
};
this.ws.onmessage = (ev: MessageEvent) => {
try {
const msg = JSON.parse(ev.data) as WsMessage;
this.onMessage?.(msg);
} catch {
// Ignore non-JSON frames
}
};
this.ws.onclose = (ev: CloseEvent) => {
this.onClose?.(ev);
this.scheduleReconnect();
};
this.ws.onerror = (ev: Event) => {
this.onError?.(ev);
};
}
/** Send a chat message to the agent. */
sendMessage(content: string): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not connected');
}
this.ws.send(JSON.stringify({ type: 'message', content }));
}
/** Close the connection without auto-reconnecting. */
disconnect(): void {
this.intentionallyClosed = true;
this.clearReconnectTimer();
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
/** Returns true if the socket is open. */
get connected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
// ---------------------------------------------------------------------------
// Reconnection logic
// ---------------------------------------------------------------------------
private scheduleReconnect(): void {
if (this.intentionallyClosed || !this.autoReconnect) return;
this.reconnectTimer = setTimeout(() => {
this.currentDelay = Math.min(this.currentDelay * 2, this.maxReconnectDelay);
this.connect();
}, this.currentDelay);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}

15
third_party/zeroclaw/web/src/main.tsx vendored Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { basePath } from './lib/basePath';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* basePath is injected by the Rust gateway at serve time for reverse-proxy prefix support. */}
<BrowserRouter basename={basePath || '/'}>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,143 @@
export interface StatusResponse {
provider: string | null;
model: string;
temperature: number;
uptime_seconds: number;
gateway_port: number;
locale: string;
memory_backend: string;
paired: boolean;
channels: Record<string, boolean>;
health: HealthSnapshot;
}
export interface HealthSnapshot {
pid: number;
updated_at: string;
uptime_seconds: number;
components: Record<string, ComponentHealth>;
}
export interface ComponentHealth {
status: string;
updated_at: string;
last_ok: string | null;
last_error: string | null;
restart_count: number;
}
export interface ToolSpec {
name: string;
description: string;
parameters: any;
}
export interface CronJob {
id: string;
name: string | null;
expression: string;
command: string;
prompt: string | null;
job_type: string;
schedule: unknown;
enabled: boolean;
delivery: unknown;
delete_after_run: boolean;
created_at: string;
next_run: string;
last_run: string | null;
last_status: string | null;
last_output: string | null;
}
export interface CronRun {
id: number;
job_id: string;
started_at: string;
finished_at: string;
status: string;
output: string | null;
duration_ms: number | null;
}
export interface Integration {
name: string;
description: string;
category: string;
status: 'Available' | 'Active' | 'ComingSoon';
}
export interface DiagResult {
severity: 'ok' | 'warn' | 'error';
category: string;
message: string;
}
export interface MemoryEntry {
id: string;
key: string;
content: string;
category: string;
timestamp: string;
session_id: string | null;
score: number | null;
}
export interface CostSummary {
session_cost_usd: number;
daily_cost_usd: number;
monthly_cost_usd: number;
total_tokens: number;
request_count: number;
by_model: Record<string, ModelStats>;
}
export interface ModelStats {
model: string;
cost_usd: number;
total_tokens: number;
request_count: number;
}
export interface CliTool {
name: string;
path: string;
version: string | null;
category: string;
}
export interface Session {
id: string;
channel: string;
started_at: string;
last_activity: string;
status: 'active' | 'idle' | 'closed';
message_count: number;
}
export interface ChannelDetail {
name: string;
type: string;
enabled: boolean;
status: 'active' | 'inactive' | 'error';
message_count: number;
last_message_at: string | null;
health: 'healthy' | 'degraded' | 'down';
}
export interface SSEEvent {
type: string;
timestamp?: string;
[key: string]: any;
}
export interface WsMessage {
type: 'message' | 'chunk' | 'chunk_reset' | 'thinking' | 'tool_call' | 'tool_result' | 'done' | 'error';
content?: string;
full_response?: string;
name?: string;
args?: any;
output?: string;
message?: string;
code?: string;
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />