feat: refactor sgclaw around zeroclaw compat runtime
This commit is contained in:
255
third_party/zeroclaw/web/src/App.tsx
vendored
Normal file
255
third_party/zeroclaw/web/src/App.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
447
third_party/zeroclaw/web/src/components/SettingsModal.tsx
vendored
Normal file
447
third_party/zeroclaw/web/src/components/SettingsModal.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
124
third_party/zeroclaw/web/src/components/layout/Header.tsx
vendored
Normal file
124
third_party/zeroclaw/web/src/components/layout/Header.tsx
vendored
Normal 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)} />
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
35
third_party/zeroclaw/web/src/components/layout/Layout.tsx
vendored
Normal file
35
third_party/zeroclaw/web/src/components/layout/Layout.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
121
third_party/zeroclaw/web/src/components/layout/Sidebar.tsx
vendored
Normal file
121
third_party/zeroclaw/web/src/components/layout/Sidebar.tsx
vendored
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
227
third_party/zeroclaw/web/src/contexts/ThemeContext.tsx
vendored
Normal file
227
third_party/zeroclaw/web/src/contexts/ThemeContext.tsx
vendored
Normal 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>;
|
||||
}
|
||||
60
third_party/zeroclaw/web/src/contexts/ThemeContextDef.ts
vendored
Normal file
60
third_party/zeroclaw/web/src/contexts/ThemeContextDef.ts
vendored
Normal 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: () => {},
|
||||
});
|
||||
285
third_party/zeroclaw/web/src/contexts/colorThemes.ts
vendored
Normal file
285
third_party/zeroclaw/web/src/contexts/colorThemes.ts
vendored
Normal 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';
|
||||
25
third_party/zeroclaw/web/src/contexts/fontLoader.ts
vendored
Normal file
25
third_party/zeroclaw/web/src/contexts/fontLoader.ts
vendored
Normal 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');
|
||||
}
|
||||
66
third_party/zeroclaw/web/src/contexts/themeStorage.ts
vendored
Normal file
66
third_party/zeroclaw/web/src/contexts/themeStorage.ts
vendored
Normal 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;
|
||||
}
|
||||
174
third_party/zeroclaw/web/src/hooks/useApi.ts
vendored
Normal file
174
third_party/zeroclaw/web/src/hooks/useApi.ts
vendored
Normal 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 };
|
||||
}
|
||||
128
third_party/zeroclaw/web/src/hooks/useAuth.ts
vendored
Normal file
128
third_party/zeroclaw/web/src/hooks/useAuth.ts
vendored
Normal 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;
|
||||
}
|
||||
44
third_party/zeroclaw/web/src/hooks/useDevices.ts
vendored
Normal file
44
third_party/zeroclaw/web/src/hooks/useDevices.ts
vendored
Normal 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 };
|
||||
}
|
||||
45
third_party/zeroclaw/web/src/hooks/useDraft.ts
vendored
Normal file
45
third_party/zeroclaw/web/src/hooks/useDraft.ts
vendored
Normal 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),
|
||||
};
|
||||
}
|
||||
124
third_party/zeroclaw/web/src/hooks/useSSE.ts
vendored
Normal file
124
third_party/zeroclaw/web/src/hooks/useSSE.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
4
third_party/zeroclaw/web/src/hooks/useTheme.ts
vendored
Normal file
4
third_party/zeroclaw/web/src/hooks/useTheme.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext } from '../contexts/ThemeContextDef';
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
118
third_party/zeroclaw/web/src/hooks/useWebSocket.ts
vendored
Normal file
118
third_party/zeroclaw/web/src/hooks/useWebSocket.ts
vendored
Normal 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
675
third_party/zeroclaw/web/src/index.css
vendored
Normal 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
328
third_party/zeroclaw/web/src/lib/api.ts
vendored
Normal 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'),
|
||||
);
|
||||
}
|
||||
42
third_party/zeroclaw/web/src/lib/auth.ts
vendored
Normal file
42
third_party/zeroclaw/web/src/lib/auth.ts
vendored
Normal 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;
|
||||
}
|
||||
20
third_party/zeroclaw/web/src/lib/basePath.ts
vendored
Normal file
20
third_party/zeroclaw/web/src/lib/basePath.ts
vendored
Normal 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
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
186
third_party/zeroclaw/web/src/lib/sse.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
third_party/zeroclaw/web/src/lib/tauri.ts
vendored
Normal file
15
third_party/zeroclaw/web/src/lib/tauri.ts
vendored
Normal 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';
|
||||
27
third_party/zeroclaw/web/src/lib/uuid.ts
vendored
Normal file
27
third_party/zeroclaw/web/src/lib/uuid.ts
vendored
Normal 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
152
third_party/zeroclaw/web/src/lib/ws.ts
vendored
Normal 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
15
third_party/zeroclaw/web/src/main.tsx
vendored
Normal 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>
|
||||
);
|
||||
409
third_party/zeroclaw/web/src/pages/AgentChat.tsx
vendored
Normal file
409
third_party/zeroclaw/web/src/pages/AgentChat.tsx
vendored
Normal file
@@ -0,0 +1,409 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Send, Bot, User, AlertCircle, Copy, Check } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { WsMessage } from '@/types/api';
|
||||
import { WebSocketClient } from '@/lib/ws';
|
||||
import { generateUUID } from '@/lib/uuid';
|
||||
import { useDraft } from '@/hooks/useDraft';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'agent';
|
||||
content: string;
|
||||
thinking?: string;
|
||||
markdown?: boolean;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
const DRAFT_KEY = 'agent-chat';
|
||||
|
||||
export default function AgentChat() {
|
||||
const { draft, saveDraft, clearDraft } = useDraft(DRAFT_KEY);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState(draft);
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocketClient | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const pendingContentRef = useRef('');
|
||||
const pendingThinkingRef = useRef('');
|
||||
// Snapshot of thinking captured at chunk_reset, so it survives the reset.
|
||||
const capturedThinkingRef = useRef('');
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [streamingThinking, setStreamingThinking] = useState('');
|
||||
|
||||
// Persist draft to in-memory store so it survives route changes
|
||||
useEffect(() => {
|
||||
saveDraft(input);
|
||||
}, [input, saveDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocketClient();
|
||||
|
||||
ws.onOpen = () => {
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
ws.onClose = (ev: CloseEvent) => {
|
||||
setConnected(false);
|
||||
if (ev.code !== 1000 && ev.code !== 1001) {
|
||||
setError(`Connection closed unexpectedly (code: ${ev.code}). Please check your configuration.`);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onError = () => {
|
||||
setError(t('agent.connection_error'));
|
||||
};
|
||||
|
||||
ws.onMessage = (msg: WsMessage) => {
|
||||
switch (msg.type) {
|
||||
case 'thinking':
|
||||
setTyping(true);
|
||||
pendingThinkingRef.current += msg.content ?? '';
|
||||
setStreamingThinking(pendingThinkingRef.current);
|
||||
break;
|
||||
|
||||
case 'chunk':
|
||||
setTyping(true);
|
||||
pendingContentRef.current += msg.content ?? '';
|
||||
setStreamingContent(pendingContentRef.current);
|
||||
break;
|
||||
|
||||
case 'chunk_reset':
|
||||
// Server signals that the authoritative done message follows.
|
||||
// Snapshot thinking before clearing display state.
|
||||
capturedThinkingRef.current = pendingThinkingRef.current;
|
||||
pendingContentRef.current = '';
|
||||
pendingThinkingRef.current = '';
|
||||
setStreamingContent('');
|
||||
setStreamingThinking('');
|
||||
break;
|
||||
|
||||
case 'message':
|
||||
case 'done': {
|
||||
const content = msg.full_response ?? msg.content ?? pendingContentRef.current;
|
||||
const thinking = capturedThinkingRef.current || pendingThinkingRef.current || undefined;
|
||||
if (content) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: generateUUID(),
|
||||
role: 'agent',
|
||||
content,
|
||||
thinking,
|
||||
markdown: true,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
pendingContentRef.current = '';
|
||||
pendingThinkingRef.current = '';
|
||||
capturedThinkingRef.current = '';
|
||||
setStreamingContent('');
|
||||
setStreamingThinking('');
|
||||
setTyping(false);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_call':
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: generateUUID(),
|
||||
role: 'agent',
|
||||
content: `${t('agent.tool_call_prefix')} ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: generateUUID(),
|
||||
role: 'agent',
|
||||
content: `${t('agent.tool_result_prefix')} ${msg.output ?? ''}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: generateUUID(),
|
||||
role: 'agent',
|
||||
content: `${t('agent.error_prefix')} ${msg.message ?? t('agent.unknown_error')}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
if (msg.code === 'AGENT_INIT_FAILED' || msg.code === 'AUTH_ERROR' || msg.code === 'PROVIDER_ERROR') {
|
||||
setError(`Configuration error: ${msg.message}. Please check your provider settings (API key, model, etc.).`);
|
||||
} else if (msg.code === 'INVALID_JSON' || msg.code === 'UNKNOWN_MESSAGE_TYPE' || msg.code === 'EMPTY_CONTENT') {
|
||||
setError(`Message error: ${msg.message}`);
|
||||
}
|
||||
setTyping(false);
|
||||
pendingContentRef.current = '';
|
||||
pendingThinkingRef.current = '';
|
||||
setStreamingContent('');
|
||||
setStreamingThinking('');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.connect();
|
||||
wsRef.current = ws;
|
||||
|
||||
return () => {
|
||||
ws.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, typing, streamingContent]);
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || !wsRef.current?.connected) return;
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: generateUUID(),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
wsRef.current.sendMessage(trimmed);
|
||||
setTyping(true);
|
||||
pendingContentRef.current = '';
|
||||
pendingThinkingRef.current = '';
|
||||
} catch {
|
||||
setError(t('agent.send_error'));
|
||||
}
|
||||
|
||||
setInput('');
|
||||
clearDraft();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
|
||||
};
|
||||
|
||||
const handleCopy = useCallback((msgId: string, content: string) => {
|
||||
const onSuccess = () => {
|
||||
setCopiedId(msgId);
|
||||
setTimeout(() => setCopiedId((prev) => (prev === msgId ? null : prev)), 2000);
|
||||
};
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(content).then(onSuccess).catch(() => {
|
||||
// Fallback for insecure contexts (HTTP)
|
||||
fallbackCopy(content) && onSuccess();
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(content) && onSuccess();
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fallback copy using a temporary textarea for HTTP contexts
|
||||
* where navigator.clipboard is unavailable.
|
||||
*/
|
||||
function fallbackCopy(text: string): boolean {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
{/* Connection status bar */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 border-b flex items-center gap-2 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171', }}>
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center animate-fade-in" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
<div className="h-16 w-16 rounded-3xl flex items-center justify-center mb-4 animate-float" style={{ background: 'var(--pc-accent-glow)' }}>
|
||||
<Bot className="h-8 w-8" style={{ color: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
<p className="text-lg font-semibold mb-1" style={{ color: 'var(--pc-text-primary)' }}>ZeroClaw Agent</p>
|
||||
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>{t('agent.start_conversation')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`group flex items-start gap-3 ${
|
||||
msg.role === 'user' ? 'flex-row-reverse animate-slide-in-right' : 'animate-slide-in-left'
|
||||
}`}
|
||||
style={{ animationDelay: `${Math.min(idx * 30, 200)}ms` }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 w-9 h-9 rounded-2xl flex items-center justify-center border"
|
||||
style={{
|
||||
background: msg.role === 'user' ? 'var(--pc-accent)' : 'var(--pc-bg-elevated)',
|
||||
borderColor: msg.role === 'user' ? 'var(--pc-accent)' : 'var(--pc-border)',
|
||||
}}
|
||||
>
|
||||
{msg.role === 'user' ? (
|
||||
<User className="h-4 w-4 text-white" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" style={{ color: 'var(--pc-accent)' }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative max-w-[75%]">
|
||||
<div
|
||||
className="rounded-2xl px-4 py-3 border"
|
||||
style={
|
||||
msg.role === 'user'
|
||||
? { background: 'var(--pc-accent-glow)', borderColor: 'var(--pc-accent-dim)', color: 'var(--pc-text-primary)', }
|
||||
: { background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)', color: 'var(--pc-text-primary)', }
|
||||
}
|
||||
>
|
||||
{msg.thinking && (
|
||||
<details className="mb-2">
|
||||
<summary className="text-xs cursor-pointer select-none" style={{ color: 'var(--pc-text-muted)' }}>Thinking</summary>
|
||||
<pre className="text-xs mt-1 whitespace-pre-wrap break-words leading-relaxed overflow-auto max-h-60 p-2 rounded-lg" style={{ color: 'var(--pc-text-muted)', background: 'var(--pc-bg-surface)' }}>{msg.thinking}</pre>
|
||||
</details>
|
||||
)}
|
||||
{msg.markdown ? (
|
||||
<div className="text-sm break-words leading-relaxed chat-markdown"><ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown></div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{msg.content}</p>
|
||||
)}
|
||||
<p
|
||||
className="text-[10px] mt-1.5" style={{ color: msg.role === 'user' ? 'var(--pc-accent-light)' : 'var(--pc-text-faint)' }}>
|
||||
{msg.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(msg.id, msg.content)}
|
||||
aria-label={t('agent.copy_message')}
|
||||
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-all p-1.5 rounded-xl"
|
||||
style={{ background: 'var(--pc-bg-elevated)', border: '1px solid var(--pc-border)', color: 'var(--pc-text-muted)', }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--pc-text-primary)'; e.currentTarget.style.borderColor = 'var(--pc-accent-dim)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--pc-text-muted)'; e.currentTarget.style.borderColor = 'var(--pc-border)'; }}
|
||||
>
|
||||
{copiedId === msg.id ? (
|
||||
<Check className="h-3 w-3" style={{ color: '#34d399' }} />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{typing && (
|
||||
<div className="flex items-start gap-3 animate-fade-in">
|
||||
<div className="flex-shrink-0 w-9 h-9 rounded-2xl flex items-center justify-center border" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)' }}>
|
||||
<Bot className="h-4 w-4" style={{ color: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
{streamingContent || streamingThinking ? (
|
||||
<div className="rounded-2xl px-4 py-3 border max-w-[75%]" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)', color: 'var(--pc-text-primary)' }}>
|
||||
{streamingThinking && (
|
||||
<details className="mb-2" open={!streamingContent}>
|
||||
<summary className="text-xs cursor-pointer select-none" style={{ color: 'var(--pc-text-muted)' }}>Thinking{!streamingContent && '...'}</summary>
|
||||
<pre className="text-xs mt-1 whitespace-pre-wrap break-words leading-relaxed overflow-auto max-h-60 p-2 rounded-lg" style={{ color: 'var(--pc-text-muted)', background: 'var(--pc-bg-surface)' }}>{streamingThinking}</pre>
|
||||
</details>
|
||||
)}
|
||||
{streamingContent && <p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{streamingContent}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl px-4 py-3 border flex items-center gap-1.5" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)' }}>
|
||||
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
|
||||
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
|
||||
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t p-4" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-surface)' }}>
|
||||
<div className="flex items-center gap-3 max-w-4xl mx-auto">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={handleTextareaChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={connected ? t('agent.type_message') : t('agent.connecting')}
|
||||
disabled={!connected}
|
||||
className="input-electric flex-1 px-4 text-sm resize-none disabled:opacity-40"
|
||||
style={{ minHeight: '44px', maxHeight: '200px', paddingTop: '10px', paddingBottom: '10px' }}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSend}
|
||||
disabled={!connected || !input.trim()}
|
||||
className="btn-electric flex-shrink-0 rounded-2xl flex items-center justify-center"
|
||||
style={{ color: 'white', width: '40px', height: '40px' }}
|
||||
>
|
||||
<Send className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center mt-2 gap-2">
|
||||
<span
|
||||
className="status-dot"
|
||||
style={connected
|
||||
? { background: 'var(--color-status-success)', boxShadow: '0 0 6px var(--color-status-success)' }
|
||||
: { background: 'var(--color-status-error)', boxShadow: '0 0 6px var(--color-status-error)' }
|
||||
}
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: 'var(--pc-text-faint)' }}>
|
||||
{connected ? t('agent.connected_status') : t('agent.disconnected_status')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
355
third_party/zeroclaw/web/src/pages/Canvas.tsx
vendored
Normal file
355
third_party/zeroclaw/web/src/pages/Canvas.tsx
vendored
Normal file
@@ -0,0 +1,355 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Monitor, Trash2, History, RefreshCw } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { basePath } from '@/lib/basePath';
|
||||
import { getToken } from '@/lib/auth';
|
||||
|
||||
interface CanvasFrame {
|
||||
frame_id: string;
|
||||
content_type: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface WsCanvasMessage {
|
||||
type: string;
|
||||
canvas_id: string;
|
||||
frame?: CanvasFrame;
|
||||
}
|
||||
|
||||
export default function Canvas() {
|
||||
const [canvasId, setCanvasId] = useState('default');
|
||||
const [canvasIdInput, setCanvasIdInput] = useState('default');
|
||||
const [currentFrame, setCurrentFrame] = useState<CanvasFrame | null>(null);
|
||||
const [history, setHistory] = useState<CanvasFrame[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [canvasList, setCanvasList] = useState<string[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Build WebSocket URL for canvas
|
||||
const getWsUrl = useCallback((id: string) => {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const base = basePath || '';
|
||||
return `${proto}//${location.host}${base}/ws/canvas/${encodeURIComponent(id)}`;
|
||||
}, []);
|
||||
|
||||
// Connect to canvas WebSocket
|
||||
const connectWs = useCallback((id: string) => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
const protocols = token ? ['zeroclaw.v1', `bearer.${token}`] : ['zeroclaw.v1'];
|
||||
const ws = new WebSocket(getWsUrl(id), protocols);
|
||||
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = () => setConnected(false);
|
||||
ws.onerror = () => setConnected(false);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: WsCanvasMessage = JSON.parse(event.data);
|
||||
if (msg.type === 'frame' && msg.frame) {
|
||||
if (msg.frame.content_type === 'clear') {
|
||||
setCurrentFrame(null);
|
||||
setHistory([]);
|
||||
} else {
|
||||
setCurrentFrame(msg.frame);
|
||||
setHistory((prev) => [...prev.slice(-49), msg.frame!]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
}, [getWsUrl]);
|
||||
|
||||
// Connect on mount and when canvasId changes
|
||||
useEffect(() => {
|
||||
connectWs(canvasId);
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [canvasId, connectWs]);
|
||||
|
||||
// Fetch canvas list periodically
|
||||
useEffect(() => {
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const data = await apiFetch<{ canvases: string[] }>('/api/canvas');
|
||||
setCanvasList(data.canvases || []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
fetchList();
|
||||
const interval = setInterval(fetchList, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Render content into the iframe
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current || !currentFrame) return;
|
||||
if (currentFrame.content_type === 'eval') return; // eval frames are special
|
||||
|
||||
const iframe = iframeRef.current;
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) return;
|
||||
|
||||
let html = currentFrame.content;
|
||||
if (currentFrame.content_type === 'svg') {
|
||||
html = `<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#1a1a2e;}</style></head><body>${currentFrame.content}</body></html>`;
|
||||
} else if (currentFrame.content_type === 'markdown') {
|
||||
// Simple markdown-to-HTML: render as preformatted text with basic styling
|
||||
const escaped = currentFrame.content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
html = `<!DOCTYPE html><html><head><style>body{margin:1rem;font-family:system-ui,sans-serif;color:#e0e0e0;background:#1a1a2e;line-height:1.6;}pre{white-space:pre-wrap;word-wrap:break-word;}</style></head><body><pre>${escaped}</pre></body></html>`;
|
||||
} else if (currentFrame.content_type === 'text') {
|
||||
const escaped = currentFrame.content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
html = `<!DOCTYPE html><html><head><style>body{margin:1rem;font-family:monospace;color:#e0e0e0;background:#1a1a2e;white-space:pre-wrap;}</style></head><body>${escaped}</body></html>`;
|
||||
}
|
||||
|
||||
doc.open();
|
||||
doc.write(html);
|
||||
doc.close();
|
||||
}, [currentFrame]);
|
||||
|
||||
const handleSwitchCanvas = () => {
|
||||
if (canvasIdInput.trim()) {
|
||||
setCanvasId(canvasIdInput.trim());
|
||||
setCurrentFrame(null);
|
||||
setHistory([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
await apiFetch(`/api/canvas/${encodeURIComponent(canvasId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
setCurrentFrame(null);
|
||||
setHistory([]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHistoryFrame = (frame: CanvasFrame) => {
|
||||
setCurrentFrame(frame);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4 h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Monitor className="h-6 w-6" style={{ color: 'var(--pc-accent)' }} />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
Live Canvas
|
||||
</h1>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{
|
||||
background: connected ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.15)',
|
||||
color: connected ? '#22c55e' : '#ef4444',
|
||||
}}
|
||||
>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="p-2 rounded-lg transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--pc-bg-elevated)', color: 'var(--pc-text-muted)' }}
|
||||
title="Toggle history"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-2 rounded-lg transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--pc-bg-elevated)', color: 'var(--pc-text-muted)' }}
|
||||
title="Clear canvas"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => connectWs(canvasId)}
|
||||
className="p-2 rounded-lg transition-colors hover:opacity-80"
|
||||
style={{ background: 'var(--pc-bg-elevated)', color: 'var(--pc-text-muted)' }}
|
||||
title="Reconnect"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={canvasIdInput}
|
||||
onChange={(e) => setCanvasIdInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSwitchCanvas()}
|
||||
placeholder="Canvas ID"
|
||||
className="px-3 py-1.5 rounded-lg text-sm border"
|
||||
style={{
|
||||
background: 'var(--pc-bg-elevated)',
|
||||
borderColor: 'var(--pc-border)',
|
||||
color: 'var(--pc-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSwitchCanvas}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium"
|
||||
style={{ background: 'var(--pc-accent)', color: '#fff' }}
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
{canvasList.length > 0 && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<span className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>Active:</span>
|
||||
{canvasList.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => {
|
||||
setCanvasIdInput(id);
|
||||
setCanvasId(id);
|
||||
setCurrentFrame(null);
|
||||
setHistory([]);
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-xs font-mono transition-colors"
|
||||
style={{
|
||||
background: id === canvasId ? 'var(--pc-accent-dim)' : 'var(--pc-bg-elevated)',
|
||||
color: id === canvasId ? 'var(--pc-accent)' : 'var(--pc-text-muted)',
|
||||
borderColor: 'var(--pc-border)',
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex gap-4 min-h-0">
|
||||
{/* Canvas viewer */}
|
||||
<div
|
||||
className="flex-1 rounded-lg border overflow-hidden"
|
||||
style={{ borderColor: 'var(--pc-border)', background: '#1a1a2e' }}
|
||||
>
|
||||
{currentFrame ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
sandbox="allow-scripts"
|
||||
className="w-full h-full border-0"
|
||||
title={`Canvas: ${canvasId}`}
|
||||
style={{ background: '#1a1a2e' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Monitor
|
||||
className="h-12 w-12 mx-auto mb-3 opacity-30"
|
||||
style={{ color: 'var(--pc-text-muted)' }}
|
||||
/>
|
||||
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
Waiting for content on canvas <span className="font-mono">"{canvasId}"</span>
|
||||
</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--pc-text-muted)', opacity: 0.6 }}>
|
||||
The agent can push content here using the canvas tool
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History panel */}
|
||||
{showHistory && (
|
||||
<div
|
||||
className="w-64 rounded-lg border overflow-y-auto"
|
||||
style={{
|
||||
borderColor: 'var(--pc-border)',
|
||||
background: 'var(--pc-bg-elevated)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 border-b text-xs font-medium sticky top-0"
|
||||
style={{
|
||||
borderColor: 'var(--pc-border)',
|
||||
background: 'var(--pc-bg-elevated)',
|
||||
color: 'var(--pc-text-muted)',
|
||||
}}
|
||||
>
|
||||
Frame History ({history.length})
|
||||
</div>
|
||||
{history.length === 0 ? (
|
||||
<p className="p-3 text-xs" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
No frames yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 p-2">
|
||||
{[...history].reverse().map((frame) => (
|
||||
<button
|
||||
key={frame.frame_id}
|
||||
onClick={() => handleSelectHistoryFrame(frame)}
|
||||
className="w-full text-left px-2 py-1.5 rounded text-xs transition-colors"
|
||||
style={{
|
||||
background:
|
||||
currentFrame?.frame_id === frame.frame_id
|
||||
? 'var(--pc-accent-dim)'
|
||||
: 'transparent',
|
||||
color: 'var(--pc-text-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono truncate" style={{ color: 'var(--pc-accent)' }}>
|
||||
{frame.content_type}
|
||||
</span>
|
||||
<span style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{new Date(frame.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="truncate mt-0.5"
|
||||
style={{ color: 'var(--pc-text-muted)', fontSize: '0.65rem' }}
|
||||
>
|
||||
{frame.content.substring(0, 60)}
|
||||
{frame.content.length > 60 ? '...' : ''}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Frame info bar */}
|
||||
{currentFrame && (
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-1.5 rounded-lg text-xs"
|
||||
style={{ background: 'var(--pc-bg-elevated)', color: 'var(--pc-text-muted)' }}
|
||||
>
|
||||
<span>
|
||||
Type: <span className="font-mono">{currentFrame.content_type}</span> | Frame:{' '}
|
||||
<span className="font-mono">{currentFrame.frame_id.substring(0, 8)}</span>
|
||||
</span>
|
||||
<span>{new Date(currentFrame.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
third_party/zeroclaw/web/src/pages/Config.tsx
vendored
Normal file
233
third_party/zeroclaw/web/src/pages/Config.tsx
vendored
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Settings,
|
||||
Save,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { getConfig, putConfig } from '@/lib/api';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightweight zero-dependency TOML syntax highlighter.
|
||||
// Produces an HTML string. The <pre> overlay sits behind the <textarea> so
|
||||
// the textarea remains the editable surface; the pre just provides colour.
|
||||
// ---------------------------------------------------------------------------
|
||||
function highlightToml(raw: string): string {
|
||||
const lines = raw.split('\n');
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const escaped = line
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Section header [section] or [[array]]
|
||||
if (/^\s*\[/.test(escaped)) {
|
||||
result.push(`<span style="color:#67e8f9;font-weight:600">${escaped}</span>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Comment line
|
||||
if (/^\s*#/.test(escaped)) {
|
||||
result.push(`<span style="color:#52525b;font-style:italic">${escaped}</span>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key = value line
|
||||
const kvMatch = escaped.match(/^(\s*)([A-Za-z0-9_\-.]+)(\s*=\s*)(.*)$/);
|
||||
if (kvMatch) {
|
||||
const [, indent, key, eq, rawValue] = kvMatch;
|
||||
const value = colorValue(rawValue ?? '');
|
||||
result.push(
|
||||
`${indent}<span style="color:#a78bfa">${key}</span>`
|
||||
+ `<span style="color:#71717a">${eq}</span>${value}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(escaped);
|
||||
}
|
||||
|
||||
return result.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function colorValue(v: string): string {
|
||||
const trimmed = v.trim();
|
||||
const commentIdx = findUnquotedHash(trimmed);
|
||||
if (commentIdx !== -1) {
|
||||
const valueCore = trimmed.slice(0, commentIdx).trimEnd();
|
||||
const comment = `<span style="color:#52525b;font-style:italic">${trimmed.slice(commentIdx)}</span>`;
|
||||
const leading = v.slice(0, v.indexOf(trimmed));
|
||||
return leading + colorScalar(valueCore) + ' ' + comment;
|
||||
}
|
||||
return colorScalar(v);
|
||||
}
|
||||
|
||||
function findUnquotedHash(s: string): number {
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (c === "'" && !inDouble) inSingle = !inSingle;
|
||||
else if (c === '"' && !inSingle) inDouble = !inDouble;
|
||||
else if (c === '#' && !inSingle && !inDouble) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function colorScalar(v: string): string {
|
||||
const t = v.trim();
|
||||
if (t === 'true' || t === 'false')
|
||||
return `<span style="color:#34d399">${v}</span>`;
|
||||
if (/^-?\d[\d_]*(\.[\d_]*)?([eE][+-]?\d+)?$/.test(t))
|
||||
return `<span style="color:#fbbf24">${v}</span>`;
|
||||
if (t.startsWith('"') || t.startsWith("'"))
|
||||
return `<span style="color:#86efac">${v}</span>`;
|
||||
if (t.startsWith('[') || t.startsWith('{'))
|
||||
return `<span style="color:#e2e8f0">${v}</span>`;
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(t))
|
||||
return `<span style="color:#fb923c">${v}</span>`;
|
||||
return v;
|
||||
}
|
||||
|
||||
export default function Config() {
|
||||
const [config, setConfig] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
const syncScroll = useCallback(() => {
|
||||
if (preRef.current && textareaRef.current) {
|
||||
preRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
preRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getConfig()
|
||||
.then((data) => { setConfig(typeof data === 'string' ? data : JSON.stringify(data, null, 2)); })
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await putConfig(config);
|
||||
setSuccess(t('config.save_success'));
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('config.save_error'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-dismiss success after 4 seconds
|
||||
useEffect(() => {
|
||||
if (!success) return;
|
||||
const timer = setTimeout(() => setSuccess(null), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [success]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-6 gap-6 animate-fade-in overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>{t('config.configuration_title')}</h2>
|
||||
</div>
|
||||
<button onClick={handleSave} disabled={saving} className="btn-electric flex items-center gap-2 text-sm px-4 py-2">
|
||||
<Save className="h-4 w-4" />{saving ? t('config.saving') : t('config.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sensitive fields note */}
|
||||
<div className="flex items-start gap-3 rounded-2xl p-4 border" style={{ borderColor: 'rgba(255, 170, 0, 0.2)', background: 'rgba(255, 170, 0, 0.05)' }}>
|
||||
<ShieldAlert className="h-5 w-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-status-warning)' }} />
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--color-status-warning)' }}>
|
||||
{t('config.sensitive_title')}
|
||||
</p>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'rgba(255, 170, 0, 0.7)' }}>
|
||||
{t('config.sensitive_hint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success message */}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 rounded-xl p-3 border animate-fade-in" style={{ borderColor: 'rgba(0, 230, 138, 0.2)', background: 'rgba(0, 230, 138, 0.06)' }}>
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-success)' }} />
|
||||
<span className="text-sm" style={{ color: 'var(--color-status-success)' }}>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-xl p-3 border animate-fade-in" style={{ borderColor: 'rgba(239, 68, 68, 0.2)', background: 'rgba(239, 68, 68, 0.06)' }}>
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-error)' }} />
|
||||
<span className="text-sm" style={{ color: 'var(--color-status-error)' }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Editor */}
|
||||
<div className="card overflow-hidden rounded-2xl flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-accent-glow)' }}>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{t('config.toml_label')}
|
||||
</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--pc-text-faint)' }}>
|
||||
{config.split('\n').length} {t('config.lines')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<pre
|
||||
ref={preRef}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 text-sm p-4 font-mono overflow-auto whitespace-pre pointer-events-none m-0"
|
||||
style={{ background: 'var(--pc-bg-base)', tabSize: 4 }}
|
||||
dangerouslySetInnerHTML={{ __html: highlightToml(config) }}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={config}
|
||||
onChange={(e) => setConfig(e.target.value)}
|
||||
onScroll={syncScroll}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
setConfig(config.slice(0, start) + ' ' + config.slice(end));
|
||||
requestAnimationFrame(() => { el.selectionStart = el.selectionEnd = start + 2; });
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
className="absolute inset-0 w-full h-full text-sm p-4 resize-none focus:outline-none font-mono caret-white"
|
||||
style={{ background: 'transparent', color: 'transparent', tabSize: 4 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
third_party/zeroclaw/web/src/pages/Cost.tsx
vendored
Normal file
149
third_party/zeroclaw/web/src/pages/Cost.tsx
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Hash,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import type { CostSummary } from '@/types/api';
|
||||
import { getCost } from '@/lib/api';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
function formatUSD(value: number): string {
|
||||
return `$${value.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export default function Cost() {
|
||||
const [cost, setCost] = useState<CostSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getCost().then(setCost).catch((err) => setError(err.message)).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
|
||||
{t('cost.load_error')}: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !cost) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const models = Object.values(cost.by_model);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
|
||||
{[
|
||||
{ icon: DollarSign, accent: 'var(--pc-accent)', bg: 'rgba(var(--pc-accent-rgb), 0.08)', label: t('cost.session_cost'), value: formatUSD(cost.session_cost_usd) },
|
||||
{ icon: TrendingUp, accent: 'var(--color-status-success)', bg: 'rgba(0, 230, 138, 0.08)', label: t('cost.daily_cost'), value: formatUSD(cost.daily_cost_usd) },
|
||||
{ icon: Layers, accent: '#a78bfa', bg: 'rgba(167, 139, 250, 0.08)', label: t('cost.monthly_cost'), value: formatUSD(cost.monthly_cost_usd) },
|
||||
{ icon: Hash, accent: 'var(--color-status-warning)', bg: 'rgba(255, 170, 0, 0.08)', label: t('cost.total_requests'), value: cost.request_count.toLocaleString() },
|
||||
].map(({ icon: Icon, accent, bg, label, value }) => (
|
||||
<div key={label} className="card p-5 animate-slide-in-up">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 rounded-2xl" style={{ background: bg, color: accent }}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-xs uppercase tracking-wider font-medium" style={{ color: 'var(--pc-text-muted)' }}>{label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold font-mono" style={{ color: 'var(--pc-text-primary)' }}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Token Statistics */}
|
||||
<div className="card p-5 animate-slide-in-up" style={{ animationDelay: '200ms' }}>
|
||||
<h3 className="text-sm font-semibold mb-4 uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{t('cost.token_statistics')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: t('cost.total_tokens'), value: cost.total_tokens.toLocaleString() },
|
||||
{ label: t('cost.avg_tokens_per_request'), value: cost.request_count > 0 ? Math.round(cost.total_tokens / cost.request_count).toLocaleString() : '0' },
|
||||
{ label: t('cost.cost_per_1k_tokens'), value: cost.total_tokens > 0 ? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000) : '$0.0000' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="rounded-2xl p-4 border" style={{ background: 'var(--pc-accent-glow)', borderColor: 'var(--pc-border)' }}>
|
||||
<p className="text-xs uppercase tracking-wider" style={{ color: 'var(--pc-text-muted)' }}>{label}</p>
|
||||
<p className="text-xl font-bold mt-1 font-mono" style={{ color: 'var(--pc-text-primary)' }}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Breakdown Table */}
|
||||
<div className="card overflow-hidden animate-slide-in-up rounded-2xl" style={{ animationDelay: '300ms' }}>
|
||||
<div className="px-5 py-4 border-b" style={{ borderColor: 'var(--pc-border)' }}>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{t('cost.model_breakdown')}
|
||||
</h3>
|
||||
</div>
|
||||
{models.length === 0 ? (
|
||||
<div className="p-8 text-center" style={{ color: 'var(--pc-text-faint)' }}>
|
||||
{t('cost.no_model_data')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table-electric">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('cost.model')}</th>
|
||||
<th className="text-right">{t('cost.cost')}</th>
|
||||
<th className="text-right">{t('cost.tokens')}</th>
|
||||
<th className="text-right">{t('cost.requests')}</th>
|
||||
<th>{t('cost.share')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.sort((a, b) => b.cost_usd - a.cost_usd).map((m) => {
|
||||
const share = cost.monthly_cost_usd > 0 ? (m.cost_usd / cost.monthly_cost_usd) * 100 : 0;
|
||||
return (
|
||||
<tr key={m.model}>
|
||||
<td className="font-medium text-sm" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{m.model}
|
||||
</td>
|
||||
<td className="text-right font-mono text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{formatUSD(m.cost_usd)}
|
||||
</td>
|
||||
<td className="text-right text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{m.total_tokens.toLocaleString()}
|
||||
</td>
|
||||
<td className="text-right text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{m.request_count.toLocaleString()}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--pc-hover)' }}>
|
||||
<div
|
||||
className="h-full rounded-full progress-bar-animated transition-all duration-700"
|
||||
style={{ width: `${Math.max(share, 2)}%`, background: 'var(--pc-accent)' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono w-10 text-right" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{share.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
531
third_party/zeroclaw/web/src/pages/Cron.tsx
vendored
Normal file
531
third_party/zeroclaw/web/src/pages/Cron.tsx
vendored
Normal file
@@ -0,0 +1,531 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Clock,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import type { CronJob, CronRun } from '@/types/api';
|
||||
import {
|
||||
getCronJobs,
|
||||
addCronJob,
|
||||
deleteCronJob,
|
||||
getCronRuns,
|
||||
getCronSettings,
|
||||
patchCronSettings,
|
||||
patchCronJob,
|
||||
} from '@/lib/api';
|
||||
import type { CronSettings } from '@/lib/api';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '-';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms === null || ms === undefined) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const secs = ms / 1000;
|
||||
if (secs < 60) return `${secs.toFixed(1)}s`;
|
||||
return `${(secs / 60).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
function RunHistoryPanel({ jobId }: { jobId: string }) {
|
||||
const [runs, setRuns] = useState<CronRun[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRuns = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getCronRuns(jobId, 20)
|
||||
.then(setRuns)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => { fetchRuns(); }, [fetchRuns]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-xs" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
<div className="h-4 w-4 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
|
||||
Loading run history...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs" style={{ color: 'var(--color-status-error)' }}>
|
||||
{t('cron.load_run_history_error')}: {error}
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchRuns}
|
||||
className="btn-icon">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<span className="text-xs" style={{ color: 'var(--pc-text-faint)' }}>{t('cron.no_runs')}</span>
|
||||
<button
|
||||
onClick={fetchRuns}
|
||||
className="btn-icon"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{t('cron.recent_runs')} ({runs.length})
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchRuns}
|
||||
className="btn-icon"
|
||||
title="Refresh runs"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto">
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="rounded-xl px-3 py-2 text-xs border" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{run.status === 'ok' ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" style={{ color: 'var(--color-status-success)' }} />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5" style={{ color: 'var(--color-status-error)' }} />
|
||||
)}
|
||||
<span style={{ color: 'var(--pc-text-secondary)' }}>{run.status}</span>
|
||||
</div>
|
||||
<span style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{formatDuration(run.duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
<span>{formatDate(run.started_at)}</span>
|
||||
</div>
|
||||
{run.output && (
|
||||
<pre className="mt-1.5 rounded-lg p-2 text-xs overflow-x-auto max-h-24 whitespace-pre-wrap break-words font-mono" style={{ background: 'var(--pc-bg-base)', color: 'var(--pc-text-secondary)' }}>
|
||||
{run.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Cron() {
|
||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [expandedJob, setExpandedJob] = useState<string | null>(null);
|
||||
const [settings, setSettings] = useState<CronSettings | null>(null);
|
||||
const [togglingCatchUp, setTogglingCatchUp] = useState(false);
|
||||
|
||||
// Unified modal: null = closed, 'add' = adding, CronJob = editing
|
||||
const [modalJob, setModalJob] = useState<CronJob | 'add' | null>(null);
|
||||
|
||||
// Shared form state for both add and edit
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formSchedule, setFormSchedule] = useState('');
|
||||
const [formCommand, setFormCommand] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const isEditing = modalJob !== null && modalJob !== 'add';
|
||||
|
||||
const openAddModal = () => {
|
||||
setFormName('');
|
||||
setFormSchedule('');
|
||||
setFormCommand('');
|
||||
setFormError(null);
|
||||
setModalJob('add');
|
||||
};
|
||||
|
||||
const openEditModal = (job: CronJob) => {
|
||||
setFormName(job.name ?? '');
|
||||
setFormSchedule(job.expression);
|
||||
setFormCommand(job.prompt ?? job.command);
|
||||
setFormError(null);
|
||||
setModalJob(job);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalJob(null);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const fetchJobs = () => {
|
||||
setLoading(true);
|
||||
getCronJobs().then(setJobs).catch((err) => setError(err.message)).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const fetchSettings = () => {
|
||||
getCronSettings().then(setSettings).catch(() => {});
|
||||
};
|
||||
|
||||
const toggleCatchUp = async () => {
|
||||
if (!settings) return;
|
||||
setTogglingCatchUp(true);
|
||||
try {
|
||||
const updated = await patchCronSettings({
|
||||
catch_up_on_startup: !settings.catch_up_on_startup,
|
||||
});
|
||||
setSettings(updated);
|
||||
} catch {
|
||||
// silently fail — user can retry
|
||||
} finally {
|
||||
setTogglingCatchUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formSchedule.trim() || !formCommand.trim()) {
|
||||
setFormError(t('cron.validation_error'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
if (isEditing) {
|
||||
const updated = await patchCronJob((modalJob as CronJob).id, {
|
||||
name: formName.trim() || undefined,
|
||||
schedule: formSchedule.trim(),
|
||||
command: formCommand.trim(),
|
||||
});
|
||||
setJobs((prev) => prev.map((j) => (j.id === updated.id ? updated : j)));
|
||||
} else {
|
||||
const job = await addCronJob({
|
||||
name: formName.trim() || undefined,
|
||||
schedule: formSchedule.trim(),
|
||||
command: formCommand.trim(),
|
||||
});
|
||||
setJobs((prev) => [...prev, job]);
|
||||
}
|
||||
closeModal();
|
||||
} catch (err: unknown) {
|
||||
setFormError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(isEditing ? 'cron.edit_error' : 'cron.add_error'),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteCronJob(id);
|
||||
setJobs((prev) => prev.filter((j) => j.id !== id));
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('cron.delete_error'));
|
||||
} finally {
|
||||
setConfirmDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusIcon = (status: string | null) => {
|
||||
if (!status) return null;
|
||||
switch (status.toLowerCase()) {
|
||||
case 'ok':
|
||||
case 'success':
|
||||
return <CheckCircle className="h-4 w-4" style={{ color: 'var(--color-status-success)' }} />;
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4" style={{ color: 'var(--color-status-error)' }} />;
|
||||
default:
|
||||
return <AlertCircle className="h-4 w-4" style={{ color: 'var(--color-status-warning)' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
|
||||
{t('cron.load_error')}: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-6 gap-6 animate-fade-in overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{t('cron.scheduled_tasks')} ({jobs.length})
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />{t('cron.add_job')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Catch-up toggle */}
|
||||
{settings && (
|
||||
<div className="glass-card px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
Catch up missed jobs on startup
|
||||
</span>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
Run all overdue jobs when ZeroClaw starts after downtime
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleCatchUp}
|
||||
disabled={togglingCatchUp}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-300 focus:outline-none ${
|
||||
settings.catch_up_on_startup
|
||||
? 'bg-[#0080ff]'
|
||||
: 'bg-[#1a1a3e]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform duration-300 ${
|
||||
settings.catch_up_on_startup
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unified Add / Edit Modal */}
|
||||
{modalJob !== null && (
|
||||
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">
|
||||
<div className="surface-panel p-6 w-full max-w-md mx-4 animate-fade-in-scale">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{isEditing ? t('cron.edit_modal_title') : t('cron.add_modal_title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="btn-icon"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{formError && (
|
||||
<div className="mb-4 rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{t('cron.name_optional')}
|
||||
</label>
|
||||
<input type="text" value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="e.g. Daily cleanup" className="input-electric w-full px-3 py-2.5 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{t('cron.schedule_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
|
||||
</label>
|
||||
<input type="text" value={formSchedule} onChange={(e) => setFormSchedule(e.target.value)} placeholder="e.g. 0 0 * * * (cron expression)" className="input-electric w-full px-3 py-2.5 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{t('cron.command_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={formCommand}
|
||||
onChange={(e) => setFormCommand(e.target.value)}
|
||||
placeholder="e.g. cleanup --older-than 7d"
|
||||
rows={4}
|
||||
className="input-electric w-full px-3 py-2.5 text-sm resize-y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="btn-secondary px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
{t('cron.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="btn-electric px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
{submitting
|
||||
? t(isEditing ? 'cron.saving' : 'cron.adding')
|
||||
: t(isEditing ? 'cron.save' : 'cron.add_job')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jobs Table */}
|
||||
{jobs.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<Clock className="h-10 w-10 mx-auto mb-3" style={{ color: 'var(--pc-text-faint)' }} />
|
||||
<p style={{ color: 'var(--pc-text-muted)' }}>{t('cron.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-auto rounded-2xl flex-1 min-h-0">
|
||||
<table className="table-electric">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('cron.id')}</th>
|
||||
<th>{t('cron.name')}</th>
|
||||
<th>{t('cron.command')}</th>
|
||||
<th>{t('cron.next_run')}</th>
|
||||
<th>{t('cron.last_status')}</th>
|
||||
<th>{t('cron.enabled')}</th>
|
||||
<th className="text-right">{t('cron.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<React.Fragment key={job.id}>
|
||||
<tr>
|
||||
<td className="font-mono text-xs">
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedJob((prev) =>
|
||||
prev === job.id ? null : job.id,
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1 btn-icon"
|
||||
title="Toggle run history"
|
||||
>
|
||||
{expandedJob === job.id ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{job.id.slice(0, 8)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="font-medium text-sm" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{job.name ?? '-'}
|
||||
</td>
|
||||
<td className="font-mono text-xs max-w-[200px] truncate" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{job.prompt ?? job.command}
|
||||
</td>
|
||||
<td className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{formatDate(job.next_run)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{statusIcon(job.last_status)}
|
||||
<span className="text-xs capitalize" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{job.last_status ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border"
|
||||
style={job.enabled ? { color: 'var(--color-status-success)', borderColor: 'rgba(0, 230, 138, 0.2)', background: 'rgba(0, 230, 138, 0.06)' } : { color: 'var(--pc-text-faint)', borderColor: 'var(--pc-border)', background: 'transparent' }}>
|
||||
{job.enabled ? t('cron.enabled_status') : t('cron.disabled_status')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => openEditModal(job)}
|
||||
className="btn-icon"
|
||||
title={t('cron.edit')}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{confirmDelete === job.id ? (
|
||||
<div className="flex items-center justify-end gap-2 animate-fade-in">
|
||||
<span className="text-xs" style={{ color: 'var(--color-status-error)' }}>
|
||||
{t('cron.confirm_delete')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDelete(job.id)}
|
||||
className="text-xs font-medium"
|
||||
style={{ color: 'var(--color-status-error)' }}
|
||||
>
|
||||
{t('cron.yes')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="text-xs font-medium"
|
||||
style={{ color: 'var(--pc-text-muted)' }}
|
||||
>
|
||||
{t('cron.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(job.id)}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedJob === job.id && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ background: 'var(--pc-bg-elevated)' }}>
|
||||
<RunHistoryPanel jobId={job.id} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
921
third_party/zeroclaw/web/src/pages/Dashboard.tsx
vendored
Normal file
921
third_party/zeroclaw/web/src/pages/Dashboard.tsx
vendored
Normal file
@@ -0,0 +1,921 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Cpu,
|
||||
Clock,
|
||||
Globe,
|
||||
Database,
|
||||
Activity,
|
||||
DollarSign,
|
||||
Radio,
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
MessageSquare,
|
||||
ChevronRight,
|
||||
Hash,
|
||||
Wifi,
|
||||
} from 'lucide-react';
|
||||
import type { StatusResponse, CostSummary, Session, ChannelDetail } from '@/types/api';
|
||||
import { getStatus, getCost, getSessions, getChannels } from '@/lib/api';
|
||||
import { useSSE } from '@/hooks/useSSE';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
type TabId = 'overview' | 'sessions' | 'channels';
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatUSD(value: number): string {
|
||||
return `$${value.toFixed(4)}`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
try {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function healthColor(status: string): string {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'ok':
|
||||
case 'healthy':
|
||||
return 'var(--color-status-success)';
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
case 'degraded':
|
||||
return 'var(--color-status-warning)';
|
||||
default:
|
||||
return 'var(--color-status-error)';
|
||||
}
|
||||
}
|
||||
|
||||
function healthBorder(status: string): string {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'ok':
|
||||
case 'healthy':
|
||||
return 'rgba(0, 230, 138, 0.2)';
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
case 'degraded':
|
||||
return 'rgba(255, 170, 0, 0.2)';
|
||||
default:
|
||||
return 'rgba(255, 68, 102, 0.2)';
|
||||
}
|
||||
}
|
||||
|
||||
function healthBg(status: string): string {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'ok':
|
||||
case 'healthy':
|
||||
return 'rgba(0, 230, 138, 0.05)';
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
case 'degraded':
|
||||
return 'rgba(255, 170, 0, 0.05)';
|
||||
default:
|
||||
return 'rgba(255, 68, 102, 0.05)';
|
||||
}
|
||||
}
|
||||
|
||||
function sessionStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'var(--color-status-success)';
|
||||
case 'idle':
|
||||
return 'var(--color-status-warning)';
|
||||
default:
|
||||
return 'var(--pc-text-faint)';
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_CARDS = [
|
||||
{
|
||||
icon: Cpu,
|
||||
accent: "var(--pc-accent)",
|
||||
labelKey: "dashboard.provider_model",
|
||||
getValue: (s: StatusResponse) => s.provider ?? "Unknown",
|
||||
getSub: (s: StatusResponse) => s.model ?? "",
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
accent: "#34d399",
|
||||
labelKey: "dashboard.uptime",
|
||||
getValue: (s: StatusResponse) => formatUptime(s.uptime_seconds),
|
||||
getSub: () => t("dashboard.since_last_restart"),
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
accent: "#a78bfa",
|
||||
labelKey: "dashboard.gateway_port",
|
||||
getValue: (s: StatusResponse) => `:${s.gateway_port}`,
|
||||
getSub: () => "",
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
accent: "#fbbf24",
|
||||
labelKey: "dashboard.memory_backend",
|
||||
getValue: (s: StatusResponse) => s.memory_backend,
|
||||
getSub: (s: StatusResponse) =>
|
||||
`${t("dashboard.paired")}: ${s.paired ? t("dashboard.paired_yes") : t("dashboard.paired_no")}`,
|
||||
},
|
||||
];
|
||||
|
||||
const TABS: { id: TabId; labelKey: string; icon: typeof LayoutDashboard }[] = [
|
||||
{ id: 'overview', labelKey: 'dashboard.tab_overview', icon: LayoutDashboard },
|
||||
{ id: 'sessions', labelKey: 'dashboard.tab_sessions', icon: Users },
|
||||
{ id: 'channels', labelKey: 'dashboard.tab_channels', icon: Wifi },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overview Tab (existing dashboard content)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OverviewTab({
|
||||
status,
|
||||
cost,
|
||||
showAllChannels,
|
||||
setShowAllChannels,
|
||||
}: {
|
||||
status: StatusResponse;
|
||||
cost: CostSummary;
|
||||
showAllChannels: boolean;
|
||||
setShowAllChannels: (fn: (v: boolean) => boolean) => void;
|
||||
}) {
|
||||
const maxCost = Math.max(
|
||||
cost.session_cost_usd,
|
||||
cost.daily_cost_usd,
|
||||
cost.monthly_cost_usd,
|
||||
0.001
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Status Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
|
||||
{STATUS_CARDS.map(({ icon: Icon, accent, labelKey, getValue, getSub }) => (
|
||||
<div key={labelKey} className="card p-5 animate-slide-in-up">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 rounded-2xl" style={{ background: `rgba(var(--pc-accent-rgb), 0.08)`, color: accent, }}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-xs uppercase tracking-wider font-medium" style={{ color: "var(--pc-text-muted)" }}>{t(labelKey)}</span>
|
||||
</div>
|
||||
<p className="text-lg font-semibold truncate capitalize" style={{ color: "var(--pc-text-primary)" }}>{getValue(status)}</p>
|
||||
<p className="text-sm truncate" style={{ color: "var(--pc-text-muted)" }}>{getSub(status)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 stagger-children">
|
||||
{/* Cost Widget */}
|
||||
<div className="card p-5 animate-slide-in-up">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<DollarSign className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: "var(--pc-text-primary)" }}>{t("dashboard.cost_overview")}</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
label: t("dashboard.session_label"),
|
||||
value: cost.session_cost_usd,
|
||||
color: "var(--pc-accent)",
|
||||
},
|
||||
{
|
||||
label: t("dashboard.daily_label"),
|
||||
value: cost.daily_cost_usd,
|
||||
color: "#34d399",
|
||||
},
|
||||
{
|
||||
label: t("dashboard.monthly_label"),
|
||||
value: cost.monthly_cost_usd,
|
||||
color: "#a78bfa",
|
||||
},
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label}>
|
||||
<div className="flex justify-between text-sm mb-1.5">
|
||||
<span style={{ color: "var(--pc-text-muted)" }}>{label}</span>
|
||||
<span
|
||||
className="font-medium font-mono"
|
||||
style={{ color: "var(--pc-text-primary)" }}
|
||||
>
|
||||
{formatUSD(value)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="w-full h-1.5 rounded-full overflow-hidden"
|
||||
style={{ background: "var(--pc-hover)" }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full progress-bar-animated transition-all duration-700 ease-out"
|
||||
style={{
|
||||
width: `${Math.max((value / maxCost) * 100, 2)}%`,
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="mt-5 pt-4 border-t flex justify-between text-sm"
|
||||
style={{ borderColor: "var(--pc-border)" }}
|
||||
>
|
||||
<span style={{ color: "var(--pc-text-muted)" }}>
|
||||
{t("dashboard.total_tokens_label")}
|
||||
</span>
|
||||
<span className="font-mono" style={{ color: "var(--pc-text-primary)" }}>
|
||||
{cost.total_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span style={{ color: "var(--pc-text-muted)" }}>
|
||||
{t("dashboard.requests_label")}
|
||||
</span>
|
||||
<span className="font-mono" style={{ color: "var(--pc-text-primary)" }}>
|
||||
{cost.request_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Channels */}
|
||||
<div className="card p-5 animate-slide-in-up">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Radio className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
|
||||
<h2
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--pc-text-primary)" }}
|
||||
>
|
||||
{t("dashboard.channels")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAllChannels((v) => !v)}
|
||||
className="ml-auto flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium border transition-all"
|
||||
style={
|
||||
showAllChannels
|
||||
? {
|
||||
background: "rgba(var(--pc-accent-rgb), 0.1)",
|
||||
borderColor: "rgba(var(--pc-accent-rgb), 0.3)",
|
||||
color: "var(--pc-accent-light)",
|
||||
}
|
||||
: {
|
||||
background: "rgba(0, 230, 138, 0.08)",
|
||||
borderColor: "rgba(0, 230, 138, 0.25)",
|
||||
color: "#34d399",
|
||||
}
|
||||
}
|
||||
aria-label={
|
||||
showAllChannels
|
||||
? t("dashboard.filter_active")
|
||||
: t("dashboard.filter_all")
|
||||
}
|
||||
>
|
||||
{showAllChannels
|
||||
? t("dashboard.filter_all")
|
||||
: t("dashboard.filter_active")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 overflow-y-auto max-h-48 pr-1">
|
||||
{Object.entries(status.channels).length === 0 ? (
|
||||
<p className="text-sm" style={{ color: "var(--pc-text-faint)" }}>
|
||||
{t("dashboard.no_channels")}
|
||||
</p>
|
||||
) : (() => {
|
||||
const entries = Object.entries(status.channels).filter(
|
||||
([, active]) => showAllChannels || active
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="text-sm" style={{ color: "var(--pc-text-faint)" }}>
|
||||
{t("dashboard.no_active_channels")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return entries.map(([name, active]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between py-2.5 px-3 rounded-xl transition-all"
|
||||
style={{ background: "var(--pc-bg-elevated)" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--pc-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "var(--pc-bg-elevated)";
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-medium capitalize"
|
||||
style={{ color: "var(--pc-text-primary)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="status-dot"
|
||||
style={
|
||||
active
|
||||
? {
|
||||
background: "var(--color-status-success)",
|
||||
boxShadow: "0 0 6px var(--color-status-success)",
|
||||
}
|
||||
: { background: "var(--pc-text-faint)" }
|
||||
}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: "var(--pc-text-muted)" }}>
|
||||
{active ? t("dashboard.active") : t("dashboard.inactive")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-5 animate-slide-in-up">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Activity className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
|
||||
<h2
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--pc-text-primary)" }}
|
||||
>
|
||||
{t("dashboard.component_health")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.entries(status.health.components).length === 0 ? (
|
||||
<p
|
||||
className="text-sm col-span-2"
|
||||
style={{ color: "var(--pc-text-faint)" }}
|
||||
>
|
||||
{t("dashboard.no_components")}
|
||||
</p>
|
||||
) : (
|
||||
Object.entries(status.health.components).map(([name, comp]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="rounded-2xl p-3 transition-all"
|
||||
style={{
|
||||
border: `1px solid ${healthBorder(comp.status)}`,
|
||||
background: healthBg(comp.status),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1.02)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1)";
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{
|
||||
background: healthColor(comp.status),
|
||||
boxShadow: `0 0 6px ${healthColor(comp.status)}`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-medium truncate capitalize"
|
||||
style={{ color: "var(--pc-text-primary)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs capitalize" style={{ color: "var(--pc-text-muted)" }}>
|
||||
{comp.status}
|
||||
</p>
|
||||
{comp.restart_count > 0 && (
|
||||
<p
|
||||
className="text-xs mt-1"
|
||||
style={{ color: "var(--color-status-warning)" }}
|
||||
>
|
||||
{t("dashboard.restarts")}: {comp.restart_count}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sessions Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SessionsTab() {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
|
||||
|
||||
const { events } = useSSE({
|
||||
filterTypes: ['session_update', 'session_created', 'session_closed'],
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
const loadSessions = useCallback(() => {
|
||||
getSessions()
|
||||
.then((data) => {
|
||||
setSessions(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// React to SSE events for real-time updates
|
||||
useEffect(() => {
|
||||
if (events.length === 0) return;
|
||||
loadSessions();
|
||||
}, [events.length, loadSessions]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-6 w-6 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--pc-border)", borderTopColor: "var(--pc-accent)" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: "var(--pc-text-muted)" }}>
|
||||
{t("dashboard.loading_sessions")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-4"
|
||||
style={{ background: "rgba(239, 68, 68, 0.08)", borderColor: "rgba(239, 68, 68, 0.2)", color: "#f87171" }}
|
||||
>
|
||||
{t("dashboard.load_sessions_error")}: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Session List */}
|
||||
<div className="lg:col-span-2 card p-5 animate-slide-in-up">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Users className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
|
||||
<h2
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--pc-text-primary)" }}
|
||||
>
|
||||
{t("dashboard.sessions_title")}
|
||||
</h2>
|
||||
<span
|
||||
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
||||
style={{ background: "rgba(var(--pc-accent-rgb), 0.1)", color: "var(--pc-accent)" }}
|
||||
>
|
||||
{sessions.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-sm py-8 text-center" style={{ color: "var(--pc-text-faint)" }}>
|
||||
{t("dashboard.no_sessions")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 overflow-y-auto max-h-96">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => setSelectedSession(session)}
|
||||
className="w-full text-left flex items-center justify-between py-3 px-4 rounded-xl transition-all"
|
||||
style={{
|
||||
background: selectedSession?.id === session.id
|
||||
? "rgba(var(--pc-accent-rgb), 0.08)"
|
||||
: "var(--pc-bg-elevated)",
|
||||
border: selectedSession?.id === session.id
|
||||
? "1px solid rgba(var(--pc-accent-rgb), 0.2)"
|
||||
: "1px solid transparent",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedSession?.id !== session.id) {
|
||||
e.currentTarget.style.background = "var(--pc-hover)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedSession?.id !== session.id) {
|
||||
e.currentTarget.style.background = "var(--pc-bg-elevated)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="text-sm font-medium font-mono truncate"
|
||||
style={{ color: "var(--pc-text-primary)" }}
|
||||
>
|
||||
{session.id.slice(0, 8)}...
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] uppercase font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: `${sessionStatusColor(session.status)}15`,
|
||||
color: sessionStatusColor(session.status),
|
||||
}}
|
||||
>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs" style={{ color: "var(--pc-text-muted)" }}>
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash className="h-3 w-3" />
|
||||
{session.channel}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{session.message_count}
|
||||
</span>
|
||||
<span>{formatRelative(session.last_activity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
style={{ color: "var(--pc-text-faint)" }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session Details Panel */}
|
||||
<div className="card p-5 animate-slide-in-up">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Activity className="h-5 w-5" style={{ color: "var(--pc-accent)" }} />
|
||||
<h2
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
style={{ color: "var(--pc-text-primary)" }}
|
||||
>
|
||||
{t("dashboard.session_details")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{selectedSession ? (
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: t("dashboard.session_id"), value: selectedSession.id },
|
||||
{ label: t("dashboard.session_channel"), value: selectedSession.channel },
|
||||
{ label: t("dashboard.session_started"), value: formatTime(selectedSession.started_at) },
|
||||
{ label: t("dashboard.session_last_activity"), value: formatRelative(selectedSession.last_activity) },
|
||||
{ label: t("dashboard.session_status"), value: selectedSession.status },
|
||||
{ label: t("dashboard.session_messages"), value: String(selectedSession.message_count) },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-xs uppercase tracking-wider mb-1" style={{ color: "var(--pc-text-faint)" }}>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className="text-sm font-medium capitalize truncate"
|
||||
style={{ color: "var(--pc-text-primary)" }}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="pt-3 mt-3 border-t"
|
||||
style={{ borderColor: "var(--pc-border)" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{
|
||||
background: sessionStatusColor(selectedSession.status),
|
||||
boxShadow: `0 0 6px ${sessionStatusColor(selectedSession.status)}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs capitalize" style={{ color: "var(--pc-text-muted)" }}>
|
||||
{selectedSession.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm py-8 text-center" style={{ color: "var(--pc-text-faint)" }}>
|
||||
{t("dashboard.session_history")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channels Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ChannelsTab() {
|
||||
const [channels, setChannels] = useState<ChannelDetail[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { events } = useSSE({
|
||||
filterTypes: ['channel_update', 'channel_status'],
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
const loadChannels = useCallback(() => {
|
||||
getChannels()
|
||||
.then((data) => {
|
||||
setChannels(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels();
|
||||
}, [loadChannels]);
|
||||
|
||||
// React to SSE events for real-time updates
|
||||
useEffect(() => {
|
||||
if (events.length === 0) return;
|
||||
loadChannels();
|
||||
}, [events.length, loadChannels]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-6 w-6 border-2 rounded-full animate-spin"
|
||||
style={{ borderColor: "var(--pc-border)", borderTopColor: "var(--pc-accent)" }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: "var(--pc-text-muted)" }}>
|
||||
{t("dashboard.loading_channels")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-4"
|
||||
style={{ background: "rgba(239, 68, 68, 0.08)", borderColor: "rgba(239, 68, 68, 0.2)", color: "#f87171" }}
|
||||
>
|
||||
{t("dashboard.load_channels_error")}: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div className="card p-5 animate-slide-in-up">
|
||||
<p className="text-sm py-8 text-center" style={{ color: "var(--pc-text-faint)" }}>
|
||||
{t("dashboard.no_channels_detail")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 stagger-children">
|
||||
{channels.map((channel) => (
|
||||
<div
|
||||
key={channel.name}
|
||||
className="card p-5 animate-slide-in-up transition-all"
|
||||
style={{
|
||||
border: `1px solid ${healthBorder(channel.health)}`,
|
||||
background: healthBg(channel.health),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(-2px)";
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px ${healthBorder(channel.health)}`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-2xl"
|
||||
style={{ background: `rgba(var(--pc-accent-rgb), 0.08)`, color: "var(--pc-accent)" }}
|
||||
>
|
||||
<Radio className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
className="text-sm font-semibold capitalize"
|
||||
style={{ color: "var(--pc-text-primary)" }}
|
||||
>
|
||||
{channel.name}
|
||||
</h3>
|
||||
<span className="text-xs" style={{ color: "var(--pc-text-muted)" }}>
|
||||
{channel.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{
|
||||
background: healthColor(channel.health),
|
||||
boxShadow: `0 0 6px ${healthColor(channel.health)}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
className="text-[10px] uppercase font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: channel.status === 'active'
|
||||
? 'rgba(0, 230, 138, 0.1)'
|
||||
: channel.status === 'error'
|
||||
? 'rgba(255, 68, 102, 0.1)'
|
||||
: 'rgba(var(--pc-accent-rgb), 0.08)',
|
||||
color: channel.status === 'active'
|
||||
? '#34d399'
|
||||
: channel.status === 'error'
|
||||
? '#f87171'
|
||||
: 'var(--pc-text-muted)',
|
||||
}}
|
||||
>
|
||||
{channel.status}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] uppercase font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: channel.enabled
|
||||
? 'rgba(0, 230, 138, 0.1)'
|
||||
: 'rgba(255, 68, 102, 0.1)',
|
||||
color: channel.enabled ? '#34d399' : '#f87171',
|
||||
}}
|
||||
>
|
||||
{channel.enabled ? t("dashboard.channel_enabled") : t("dashboard.channel_disabled")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
className="pt-3 border-t space-y-2"
|
||||
style={{ borderColor: "var(--pc-border)" }}
|
||||
>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span style={{ color: "var(--pc-text-muted)" }}>{t("dashboard.channel_messages")}</span>
|
||||
<span className="font-mono" style={{ color: "var(--pc-text-primary)" }}>
|
||||
{channel.message_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span style={{ color: "var(--pc-text-muted)" }}>{t("dashboard.channel_last_message")}</span>
|
||||
<span className="font-mono" style={{ color: "var(--pc-text-primary)" }}>
|
||||
{channel.last_message_at ? formatRelative(channel.last_message_at) : t("dashboard.never")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span style={{ color: "var(--pc-text-muted)" }}>{t("dashboard.health")}</span>
|
||||
<span className="capitalize" style={{ color: healthColor(channel.health) }}>
|
||||
{channel.health}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Dashboard Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Dashboard() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [cost, setCost] = useState<CostSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAllChannels, setShowAllChannels] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getStatus(), getCost()])
|
||||
.then(([s, c]) => {
|
||||
setStatus(s);
|
||||
setCost(c);
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="rounded-2xl border p-4" style={{ background: "rgba(239, 68, 68, 0.08)", borderColor: "rgba(239, 68, 68, 0.2)", color: "#f87171", }}>
|
||||
{t("dashboard.load_error")}: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status || !cost) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: "var(--pc-border)", borderTopColor: "var(--pc-accent)", }}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Tab Navigation */}
|
||||
<div
|
||||
className="flex items-center gap-1 p-1 rounded-2xl"
|
||||
style={{ background: "var(--pc-bg-elevated)" }}
|
||||
>
|
||||
{TABS.map(({ id, labelKey, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all"
|
||||
style={
|
||||
activeTab === id
|
||||
? {
|
||||
background: "var(--pc-bg-primary)",
|
||||
color: "var(--pc-accent)",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)",
|
||||
}
|
||||
: {
|
||||
background: "transparent",
|
||||
color: "var(--pc-text-muted)",
|
||||
}
|
||||
}
|
||||
onMouseEnter={(e) => {
|
||||
if (activeTab !== id) {
|
||||
e.currentTarget.style.color = "var(--pc-text-primary)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (activeTab !== id) {
|
||||
e.currentTarget.style.color = "var(--pc-text-muted)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab
|
||||
status={status}
|
||||
cost={cost}
|
||||
showAllChannels={showAllChannels}
|
||||
setShowAllChannels={setShowAllChannels}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'sessions' && <SessionsTab />}
|
||||
{activeTab === 'channels' && <ChannelsTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
third_party/zeroclaw/web/src/pages/Doctor.tsx
vendored
Normal file
216
third_party/zeroclaw/web/src/pages/Doctor.tsx
vendored
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Stethoscope,
|
||||
Play,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import type { DiagResult } from '@/types/api';
|
||||
import { runDoctor } from '@/lib/api';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
function severityIcon(severity: DiagResult['severity']) {
|
||||
switch (severity) {
|
||||
case 'ok':
|
||||
return <CheckCircle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-success)' }} />;
|
||||
case 'warn':
|
||||
return <AlertTriangle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-warning)' }} />;
|
||||
case 'error':
|
||||
return <XCircle className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--color-status-error)' }} />;
|
||||
}
|
||||
}
|
||||
|
||||
function severityBg(severity: DiagResult['severity']): string {
|
||||
switch (severity) {
|
||||
case 'ok':
|
||||
return 'rgba(0, 230, 138, 0.04)';
|
||||
case 'warn':
|
||||
return 'rgba(255, 170, 0, 0.04)';
|
||||
case 'error':
|
||||
return 'rgba(239, 68, 68, 0.04)';
|
||||
}
|
||||
}
|
||||
|
||||
function severityBorder(severity: DiagResult['severity']): string {
|
||||
switch (severity) {
|
||||
case 'ok':
|
||||
return 'border-[rgba(0,230,138,0.3)]';
|
||||
case 'warn':
|
||||
return 'border-[rgba(255,170,0,0.3)]';
|
||||
case 'error':
|
||||
return 'border-[rgba(239,68,68,0.3)]';
|
||||
}
|
||||
}
|
||||
|
||||
export default function Doctor() {
|
||||
const [results, setResults] = useState<DiagResult[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleRun = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
try {
|
||||
const data = await runDoctor();
|
||||
setResults(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to run diagnostics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const okCount = results?.filter((r) => r.severity === 'ok').length ?? 0;
|
||||
const warnCount = results?.filter((r) => r.severity === 'warn').length ?? 0;
|
||||
const errorCount = results?.filter((r) => r.severity === 'error').length ?? 0;
|
||||
|
||||
const grouped =
|
||||
results?.reduce<Record<string, DiagResult[]>>((acc, item) => {
|
||||
const key = item.category;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(item);
|
||||
return acc;
|
||||
}, {}) ?? {};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Stethoscope className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>{t('doctor.diagnostics_title')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={loading}
|
||||
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('doctor.running_btn')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{t('doctor.run_diagnostics')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-xl p-4 border animate-fade-in" style={{ background: 'rgba(239,68,68,0.06)', borderColor: 'rgba(239,68,68,0.2)', color: '#f87171' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading spinner */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 animate-fade-in">
|
||||
<div className="h-12 w-12 border-2 rounded-full animate-spin mb-4" style={{ borderColor: 'rgba(255,255,255,0.1)', borderTopColor: 'var(--pc-accent)' }}/>
|
||||
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>{t('doctor.running_desc')}</p>
|
||||
<p className="text-[13px] mt-1" style={{ color: 'var(--pc-text-faint)' }}>
|
||||
{t('doctor.running_hint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results && !loading && (
|
||||
<>
|
||||
{/* Summary Bar */}
|
||||
<div className="flex items-center gap-4 p-4 rounded-xl border animate-fade-in" style={{ background: 'var(--pc-bg-surface)', borderColor: 'var(--pc-border)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5" style={{ color: 'var(--color-status-success)' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{okCount}{' '}<span className="text-sm font-normal" style={{ color: 'var(--pc-text-muted)' }}>ok</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-5" style={{ background: 'var(--pc-border)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" style={{ color: 'var(--color-status-warning)' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{warnCount}{' '}
|
||||
<span className="text-sm font-normal" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
warning{warnCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-5" style={{ background: 'var(--pc-border)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-5 w-5" style={{ color: 'var(--color-status-error)' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{errorCount}{' '}
|
||||
<span className="text-sm font-normal" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
error{errorCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Overall indicator */}
|
||||
<div className="ml-auto">
|
||||
{errorCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border" style={{ background: 'rgba(239,68,68,0.06)', borderColor: 'rgba(239,68,68,0.3)', color: '#f87171' }}>
|
||||
{t('doctor.issues_found')}
|
||||
</span>
|
||||
) : warnCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border" style={{ background: 'rgba(255,170,0,0.06)', borderColor: 'rgba(255,170,0,0.3)', color: '#fbbf24' }}>
|
||||
{t('doctor.warnings_summary')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border" style={{ background: 'rgba(0,230,138,0.06)', borderColor: 'rgba(0,230,138,0.3)', color: '#34d399' }}>
|
||||
{t('doctor.all_clear')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grouped Results */}
|
||||
{Object.entries(grouped)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3 capitalize" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{category}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{items.map((result, idx) => (
|
||||
<div
|
||||
key={`${category}-${idx}`}
|
||||
className={`flex items-start gap-3 rounded-xl border p-3 ${severityBorder(result.severity,)} ${severityBg(result.severity)}`}
|
||||
>
|
||||
{severityIcon(result.severity)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm" style={{ color: 'var(--pc-text-primary)' }}>{result.message}</p>
|
||||
<p className="text-xs capitalize mt-0.5" style={{ color: 'var(--pc-text-faint)' }}>
|
||||
{result.severity}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!results && !loading && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-[var(--pc-text-muted)]">
|
||||
<div className="h-16 w-16 rounded-2xl flex items-center justify-center mb-4 animate-float" style={{ background: 'linear-gradient(135deg, var(--pc-accent-glow), transparent)' }}>
|
||||
<Stethoscope className="h-8 w-8" style={{ color: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
<p className="text-lg font-semibold mb-1" style={{ color: 'var(--pc-text-primary)' }}>{t('doctor.system_diagnostics')}</p>
|
||||
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{t('doctor.empty_hint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
third_party/zeroclaw/web/src/pages/Integrations.tsx
vendored
Normal file
153
third_party/zeroclaw/web/src/pages/Integrations.tsx
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Puzzle, Check, Zap, Clock } from 'lucide-react';
|
||||
import type { Integration } from '@/types/api';
|
||||
import { getIntegrations } from '@/lib/api';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
function statusBadge(status: Integration['status']) {
|
||||
switch (status) {
|
||||
case 'Active':
|
||||
return {
|
||||
icon: Check,
|
||||
label: t('integrations.status_active'),
|
||||
color: 'var(--color-status-success)',
|
||||
border: 'rgba(0, 230, 138, 0.2)',
|
||||
bg: 'rgba(0, 230, 138, 0.06)'
|
||||
};
|
||||
case 'Available':
|
||||
return {
|
||||
icon: Zap,
|
||||
label: t('integrations.status_available'),
|
||||
color: 'var(--pc-accent)',
|
||||
border: 'var(--pc-accent-dim)',
|
||||
bg: 'var(--pc-accent-glow)'
|
||||
};
|
||||
case 'ComingSoon':
|
||||
return {
|
||||
icon: Clock,
|
||||
label: t('integrations.status_coming_soon'),
|
||||
color: 'var(--pc-text-muted)',
|
||||
border: 'var(--pc-border)',
|
||||
bg: 'transparent'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Integrations() {
|
||||
const [integrations, setIntegrations] = useState<Integration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
getIntegrations().then(setIntegrations).catch((err) => setError(err.message)).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const categories = ['all',
|
||||
...Array.from(new Set(integrations.map((i) => i.category))).sort()
|
||||
];
|
||||
const filtered =
|
||||
activeCategory === 'all'
|
||||
? integrations
|
||||
: integrations.filter((i) => i.category === activeCategory);
|
||||
|
||||
// Group by category for display
|
||||
const grouped = filtered.reduce<Record<string, Integration[]>>((acc, item) => {
|
||||
const key = item.category;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
|
||||
{t('integrations.load_error')}: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
<div className="flex items-center gap-2">
|
||||
<Puzzle className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{t('integrations.title')} ({integrations.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Category Filter Tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className="px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all capitalize"
|
||||
style={activeCategory === cat
|
||||
? { background: 'var(--pc-accent)', color: 'white' }
|
||||
: { color: 'var(--pc-text-muted)', border: '1px solid var(--pc-border)', background: 'transparent' }
|
||||
}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grouped Integration Cards */}
|
||||
{Object.keys(grouped).length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<Puzzle className="h-10 w-10 mx-auto mb-3" style={{ color: 'var(--pc-text-faint)' }} />
|
||||
<p style={{ color: 'var(--pc-text-muted)' }}>{t('integrations.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-3 capitalize" style={{ color: 'var(--pc-text-faint)' }}>
|
||||
{category}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
|
||||
{items.map((integration) => {
|
||||
const badge = statusBadge(integration.status);
|
||||
const BadgeIcon = badge.icon;
|
||||
return (
|
||||
<div
|
||||
key={integration.name}
|
||||
className="card p-5 animate-slide-in-up"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-semibold truncate" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{integration.name}
|
||||
</h4>
|
||||
<p className="text-sm mt-1 line-clamp-2" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{integration.description}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className="flex-shrink-0 inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-semibold border"
|
||||
style={badge}
|
||||
>
|
||||
<BadgeIcon className="h-3 w-3" />
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
282
third_party/zeroclaw/web/src/pages/Logs.tsx
vendored
Normal file
282
third_party/zeroclaw/web/src/pages/Logs.tsx
vendored
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
Pause,
|
||||
Play,
|
||||
ArrowDown,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import type { SSEEvent } from '@/types/api';
|
||||
import { SSEClient } from '@/lib/sse';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
function formatTimestamp(ts?: string): string {
|
||||
if (!ts) return new Date().toLocaleTimeString();
|
||||
return new Date(ts).toLocaleTimeString();
|
||||
}
|
||||
|
||||
function eventTypeStyle(type: string): { color: string; bg: string; border: string } {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'error':
|
||||
return { color: 'var(--color-status-error)', bg: 'rgba(239, 68, 68, 0.06)', border: 'rgba(239, 68, 68, 0.2)' };
|
||||
case 'warn':
|
||||
case 'warning':
|
||||
return { color: 'var(--color-status-warning)', bg: 'rgba(255, 170, 0, 0.06)', border: 'rgba(255, 170, 0, 0.2)' };
|
||||
case 'tool_call':
|
||||
case 'tool_result':
|
||||
case 'tool_call_start':
|
||||
return { color: '#a78bfa', bg: 'rgba(167, 139, 250, 0.06)', border: 'rgba(167, 139, 250, 0.2)' };
|
||||
case 'llm_request':
|
||||
return { color: '#38bdf8', bg: 'rgba(56, 189, 248, 0.06)', border: 'rgba(56, 189, 248, 0.2)' };
|
||||
case 'agent_start':
|
||||
case 'agent_end':
|
||||
return { color: '#34d399', bg: 'rgba(52, 211, 153, 0.06)', border: 'rgba(52, 211, 153, 0.2)' };
|
||||
case 'message':
|
||||
case 'chat':
|
||||
return { color: 'var(--pc-accent)', bg: 'var(--pc-accent-glow)', border: 'var(--pc-accent-dim)' };
|
||||
case 'health':
|
||||
case 'status':
|
||||
return { color: 'var(--color-status-success)', bg: 'rgba(0, 230, 138, 0.06)', border: 'rgba(0, 230, 138, 0.2)' };
|
||||
default:
|
||||
return { color: 'var(--pc-text-muted)', bg: 'var(--pc-hover)', border: 'var(--pc-border)' };
|
||||
}
|
||||
}
|
||||
|
||||
interface LogEntry { id: string; event: SSEEvent; }
|
||||
|
||||
export default function Logs() {
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [infoDismissed, setInfoDismissed] = useState(false);
|
||||
const [typeFilters, setTypeFilters] = useState<Set<string>>(new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sseRef = useRef<SSEClient | null>(null);
|
||||
const pausedRef = useRef(false);
|
||||
const entryIdRef = useRef(0);
|
||||
|
||||
// Keep pausedRef in sync
|
||||
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
||||
|
||||
useEffect(() => {
|
||||
const client = new SSEClient();
|
||||
|
||||
client.onConnect = () => {
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
client.onError = () => {
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
client.onEvent = (event: SSEEvent) => {
|
||||
if (pausedRef.current) return;
|
||||
entryIdRef.current += 1;
|
||||
const entry: LogEntry = {
|
||||
id: `log-${entryIdRef.current}`,
|
||||
event,
|
||||
};
|
||||
setEntries((prev) => {
|
||||
const next = [...prev, entry];
|
||||
return next.length > 500 ? next.slice(-500) : next;
|
||||
});
|
||||
};
|
||||
client.connect();
|
||||
sseRef.current = client;
|
||||
return () => {
|
||||
client.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
if (autoScroll && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [entries, autoScroll]);
|
||||
|
||||
// Detect user scroll to toggle auto-scroll
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
setAutoScroll(isAtBottom);
|
||||
}, []);
|
||||
|
||||
const jumpToBottom = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
setAutoScroll(true);
|
||||
};
|
||||
|
||||
const allTypes = Array.from(new Set(entries.map((e) => e.event.type))).sort();
|
||||
|
||||
const toggleTypeFilter = (type: string) => {
|
||||
setTypeFilters((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredEntries = typeFilters.size === 0 ? entries : entries.filter((e) => typeFilters.has(e.event.type));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b animate-fade-in" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-surface)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>{t('logs.live_logs')}</h2>
|
||||
<span className="text-[10px] font-mono ml-2" style={{ color: 'var(--pc-text-faint)' }}>
|
||||
{filteredEntries.length} {t('logs.events')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Pause/Resume */}
|
||||
<button
|
||||
onClick={() => setPaused(!paused)}
|
||||
className="btn-electric flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold"
|
||||
style={{ background: paused ? 'var(--color-status-success)' : 'var(--color-status-warning)', color: 'white' }}
|
||||
>
|
||||
{paused ? (
|
||||
<>
|
||||
<Play className="h-3.5 w-3.5" /> {t('logs.resume')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="h-3.5 w-3.5" /> {t('logs.pause')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Jump to Bottom */}
|
||||
{!autoScroll && (
|
||||
<button onClick={jumpToBottom} className="btn-electric flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold">
|
||||
<ArrowDown className="h-3.5 w-3.5" />{t('logs.jump_to_bottom')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event type filters */}
|
||||
{allTypes.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-6 py-2 border-b overflow-x-auto" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-base)' }}>
|
||||
<Filter className="h-3.5 w-3.5 flex-shrink-0" style={{ color: 'var(--pc-text-faint)' }} />
|
||||
<span className="text-[10px] uppercase tracking-wider flex-shrink-0" style={{ color: 'var(--pc-text-faint)' }}>{t('logs.filter_label')}:</span>
|
||||
{allTypes.map((type) => (
|
||||
<label key={type} className="flex items-center gap-1.5 cursor-pointer flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={typeFilters.has(type)}
|
||||
onChange={() => toggleTypeFilter(type)}
|
||||
className="rounded"
|
||||
style={{ accentColor: 'var(--pc-accent)' }}
|
||||
/>
|
||||
<span className="text-[10px] capitalize" style={{ color: 'var(--pc-text-muted)' }}>{type}</span>
|
||||
</label>
|
||||
))}
|
||||
{typeFilters.size > 0 && (
|
||||
<button
|
||||
onClick={() => setTypeFilters(new Set())}
|
||||
className="text-[10px] flex-shrink-0 ml-1 transition-colors"
|
||||
style={{ color: 'var(--pc-accent)' }}>
|
||||
{t('logs.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Informational banner — what appears here and what does not */}
|
||||
{!infoDismissed && (
|
||||
<div className="flex items-start gap-3 px-6 py-3 border-b flex-shrink-0" style={{ borderColor: 'rgba(56, 189, 248, 0.2)', background: 'rgba(56, 189, 248, 0.05)' }}>
|
||||
<div className="flex-1 text-xs" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
<span className="font-semibold" style={{ color: '#38bdf8' }}>What appears here: </span>
|
||||
agent activity over SSE — LLM requests, tool calls, agent start/end, and errors.
|
||||
{' '}<span className="font-semibold" style={{ color: 'var(--pc-text-muted)' }}>What does not: </span>
|
||||
daemon stdout and <code>RUST_LOG</code> tracing output go to the terminal or log file, not this stream.
|
||||
{' '}To see tracing logs, run the daemon with <code>RUST_LOG=info zeroclaw</code> and check your terminal.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setInfoDismissed(true)}
|
||||
className="flex-shrink-0 text-[10px] btn-icon"
|
||||
aria-label="Dismiss"
|
||||
style={{ color: 'var(--pc-text-faint)' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log entries */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-2 min-h-0"
|
||||
>
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full animate-fade-in" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
<Activity className="h-10 w-10 mb-3" style={{ color: 'var(--pc-text-faint)' }} />
|
||||
<p className="text-sm">
|
||||
{paused
|
||||
? t('logs.paused_hint')
|
||||
: t('logs.waiting_hint')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredEntries.map((entry) => {
|
||||
const { event } = entry;
|
||||
const style = eventTypeStyle(event.type);
|
||||
const detail =
|
||||
event.message ??
|
||||
event.content ??
|
||||
event.data ??
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(event).filter(
|
||||
([k]) => k !== 'type' && k !== 'timestamp',
|
||||
),
|
||||
),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="card rounded-xl p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-[10px] font-mono whitespace-nowrap mt-0.5" style={{ color: 'var(--pc-text-faint)' }}>
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border capitalize flex-shrink-0"
|
||||
style={style}
|
||||
>
|
||||
{event.type}
|
||||
</span>
|
||||
<p className="text-sm break-all min-w-0" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{typeof detail === 'string' ? detail : JSON.stringify(detail)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{/* Footer: connection status */}
|
||||
<div className="flex items-center justify-center gap-2 px-6 py-2 border-t flex-shrink-0" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-surface)' }}>
|
||||
<span className="status-dot" style={
|
||||
connected ? { background: 'var(--color-status-success)', boxShadow: '0 0 6px var(--color-status-success)' } : { background: 'var(--color-status-error)', boxShadow: '0 0 6px var(--color-status-error)' }
|
||||
} />
|
||||
<span className="text-[10px]" style={{ color: 'var(--pc-text-faint)' }}>
|
||||
{connected ? t('logs.connected') : t('logs.disconnected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
third_party/zeroclaw/web/src/pages/Memory.tsx
vendored
Normal file
275
third_party/zeroclaw/web/src/pages/Memory.tsx
vendored
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Brain,
|
||||
Search,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import type { MemoryEntry } from '@/types/api';
|
||||
import { getMemory, storeMemory, deleteMemory } from '@/lib/api';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max) + '...';
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export default function Memory() {
|
||||
const [entries, setEntries] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formKey, setFormKey] = useState('');
|
||||
const [formContent, setFormContent] = useState('');
|
||||
const [formCategory, setFormCategory] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetchEntries = (q?: string, cat?: string) => {
|
||||
setLoading(true);
|
||||
getMemory(q || undefined, cat || undefined)
|
||||
.then(setEntries)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries();
|
||||
}, []);
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchEntries(search, categoryFilter);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
};
|
||||
const categories = Array.from(new Set(entries.map((e) => e.category))).sort();
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!formKey.trim() || !formContent.trim()) { setFormError(t('memory.validation_error')); return; }
|
||||
setSubmitting(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
await storeMemory(
|
||||
formKey.trim(),
|
||||
formContent.trim(),
|
||||
formCategory.trim() || undefined,
|
||||
);
|
||||
fetchEntries(search, categoryFilter);
|
||||
setShowForm(false);
|
||||
setFormKey(''); setFormContent(''); setFormCategory('');
|
||||
} catch (err: unknown) {
|
||||
setFormError(err instanceof Error ? err.message : t('memory.store_error'));
|
||||
} finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
try {
|
||||
await deleteMemory(key);
|
||||
setEntries((prev) => prev.filter((e) => e.key !== key));
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('memory.delete_error'));
|
||||
} finally {
|
||||
setConfirmDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (error && entries.length === 0) {
|
||||
return (
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
|
||||
{t('memory.load_error')}: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-6 gap-6 animate-fade-in overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{t('memory.memory_title')} ({entries.length})
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={() => setShowForm(true)} className="btn-electric flex items-center gap-2 text-sm px-4 py-2">
|
||||
<Plus className="h-4 w-4" />{t('memory.add_memory')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: 'var(--pc-text-faint)' }} />
|
||||
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={handleKeyDown} placeholder={t('memory.search_placeholder')} className="input-electric w-full pl-10 pr-4 py-2.5 text-sm" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: 'var(--pc-text-faint)' }} />
|
||||
<select value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} className="input-electric pl-10 pr-8 py-2.5 text-sm appearance-none cursor-pointer">
|
||||
<option value="">{t('memory.all_categories')}</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleSearch} className="btn-electric px-4 py-2.5 text-sm">{t('memory.search_button')}</button>
|
||||
</div>
|
||||
|
||||
{/* Error banner (non-fatal) */}
|
||||
{error && (
|
||||
<div className="rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Memory Form Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">
|
||||
<div className="surface-panel p-6 w-full max-w-md mx-4 animate-fade-in-scale">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--pc-text-primary)' }}>{t('memory.add_modal_title')}</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setFormError(null);
|
||||
}}
|
||||
className="btn-icon">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{formError && (
|
||||
<div className="mb-4 rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{t('memory.key_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
|
||||
</label>
|
||||
<input type="text" value={formKey} onChange={(e) => setFormKey(e.target.value)} placeholder="e.g. user_preferences" className="input-electric w-full px-3 py-2.5 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{t('memory.content_required')} <span style={{ color: 'var(--color-status-error)' }}>*</span>
|
||||
</label>
|
||||
<textarea value={formContent} onChange={(e) => setFormContent(e.target.value)} placeholder="Memory content..." rows={4} className="input-electric w-full px-3 py-2.5 text-sm resize-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5 uppercase tracking-wider" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{t('memory.category_optional')}
|
||||
</label>
|
||||
<input type="text" value={formCategory} onChange={(e) => setFormCategory(e.target.value)} placeholder="e.g. preferences, context, facts" className="input-electric w-full px-3 py-2.5 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setFormError(null);
|
||||
}}
|
||||
className="btn-secondary px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
{t('memory.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd} disabled={submitting} className="btn-electric px-4 py-2 text-sm font-medium">{submitting ? t('memory.saving') : t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memory Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<Brain className="h-10 w-10 mx-auto mb-3" style={{ color: 'var(--pc-text-faint)' }} />
|
||||
<p style={{ color: 'var(--pc-text-muted)' }}>{t('memory.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-x-auto rounded-2xl">
|
||||
<table className="table-electric">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('memory.key')}</th>
|
||||
<th>{t('memory.content')}</th>
|
||||
<th>{t('memory.category')}</th>
|
||||
<th>{t('memory.timestamp')}</th>
|
||||
<th className="text-right">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="font-mono text-xs" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{entry.key}
|
||||
</td>
|
||||
<td className="max-w-[300px] text-sm" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
<span title={entry.content}>
|
||||
{truncate(entry.content, 80)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold capitalize border" style={{ borderColor: 'var(--pc-border)', color: 'var(--pc-text-secondary)', background: 'var(--pc-accent-glow)' }}>
|
||||
{entry.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-xs whitespace-nowrap" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{formatDate(entry.timestamp)}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
{confirmDelete === entry.key ? (
|
||||
<div className="flex items-center justify-end gap-2 animate-fade-in">
|
||||
<span className="text-xs" style={{ color: 'var(--color-status-error)' }}>
|
||||
{t('memory.delete_confirm')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDelete(entry.key)}
|
||||
className="text-xs font-medium" style={{ color: 'var(--color-status-error)' }}
|
||||
>
|
||||
{t('memory.yes')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="text-xs font-medium" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{t('memory.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(entry.key)}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
third_party/zeroclaw/web/src/pages/Pairing.tsx
vendored
Normal file
177
third_party/zeroclaw/web/src/pages/Pairing.tsx
vendored
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Smartphone, Trash2 } from 'lucide-react';
|
||||
import { getAdminPairCode } from '../lib/api';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string | null;
|
||||
device_type: string | null;
|
||||
paired_at: string;
|
||||
last_seen: string;
|
||||
ip_address: string | null;
|
||||
}
|
||||
|
||||
export default function Pairing() {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pairingCode, setPairingCode] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const token = localStorage.getItem('zeroclaw_token') || '';
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/devices', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDevices(data.devices || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load devices');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Fetch the current pairing code on mount (if one is active)
|
||||
useEffect(() => {
|
||||
getAdminPairCode()
|
||||
.then((data) => {
|
||||
if (data.pairing_code) {
|
||||
setPairingCode(data.pairing_code);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Admin endpoint not reachable — code will show after clicking "Pair New Device"
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchDevices(); }, [fetchDevices]);
|
||||
|
||||
const handleInitiatePairing = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/pairing/initiate', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPairingCode(data.pairing_code);
|
||||
} else {
|
||||
setError('Failed to generate pairing code');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to generate pairing code');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeDevice = async (deviceId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/devices/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
setDevices(devices.filter(d => d.id !== deviceId));
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to revoke device');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{t('pairing.title')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleInitiatePairing}
|
||||
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
|
||||
>
|
||||
<Smartphone className="h-4 w-4" />
|
||||
{t('pairing.pair_new_device')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border p-3 text-sm animate-fade-in" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pairingCode && (
|
||||
<div className="card p-6 text-center rounded-2xl">
|
||||
<p className="text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--pc-text-muted)' }}>{t('pairing.pairing_code')}</p>
|
||||
<div className="text-4xl font-mono font-bold tracking-[0.4em] py-4" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{pairingCode}
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>{t('pairing.code_hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card rounded-2xl overflow-hidden">
|
||||
<div className="px-5 py-4 border-b" style={{ borderColor: 'var(--pc-border)' }}>
|
||||
<h3 className="text-sm font-semibold" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{t('pairing.paired_devices')} ({devices.length})
|
||||
</h3>
|
||||
</div>
|
||||
{devices.length === 0 ? (
|
||||
<div className="p-8 text-center" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{t('pairing.no_devices')}
|
||||
</div>
|
||||
) : (
|
||||
<table className="table-electric">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('pairing.name')}</th>
|
||||
<th>{t('pairing.type')}</th>
|
||||
<th>{t('pairing.paired')}</th>
|
||||
<th>{t('pairing.last_seen')}</th>
|
||||
<th>IP</th>
|
||||
<th className="text-right">{t('pairing.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((device) => (
|
||||
<tr key={device.id}>
|
||||
<td style={{ color: 'var(--pc-text-primary)' }}>{device.name || 'Unnamed'}</td>
|
||||
<td style={{ color: 'var(--pc-text-secondary)' }}>{device.device_type || 'Unknown'}</td>
|
||||
<td className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{new Date(device.paired_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{new Date(device.last_seen).toLocaleString()}
|
||||
</td>
|
||||
<td className="font-mono text-xs" style={{ color: 'var(--pc-text-secondary)' }}>
|
||||
{device.ip_address || '-'}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<button
|
||||
onClick={() => handleRevokeDevice(device.id)}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
third_party/zeroclaw/web/src/pages/Tools.tsx
vendored
Normal file
202
third_party/zeroclaw/web/src/pages/Tools.tsx
vendored
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Wrench,
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import type { ToolSpec, CliTool } from '@/types/api';
|
||||
import { getTools, getCliTools } from '@/lib/api';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export default function Tools() {
|
||||
const [tools, setTools] = useState<ToolSpec[]>([]);
|
||||
const [cliTools, setCliTools] = useState<CliTool[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedTool, setExpandedTool] = useState<string | null>(null);
|
||||
const [agentSectionOpen, setAgentSectionOpen] = useState(true);
|
||||
const [cliSectionOpen, setCliSectionOpen] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getTools(), getCliTools()])
|
||||
.then(([t, c]) => { setTools(t); setCliTools(c); })
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = tools.filter((t) =>
|
||||
t.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const filteredCli = cliTools.filter((t) =>
|
||||
t.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
t.category.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6 animate-fade-in">
|
||||
<div className="rounded-2xl border p-4" style={{ background: 'rgba(239, 68, 68, 0.08)', borderColor: 'rgba(239, 68, 68, 0.2)', color: '#f87171' }}>
|
||||
{t('tools.load_error')}: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="h-8 w-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--pc-border)', borderTopColor: 'var(--pc-accent)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4" style={{ color: 'var(--pc-text-faint)' }} />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('tools.search')}
|
||||
className="input-electric w-full pl-10 pr-4 py-2.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent Tools Grid */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setAgentSectionOpen((v) => !v)}
|
||||
className="flex items-center gap-2 mb-4 w-full text-left group"
|
||||
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: 0 }}
|
||||
aria-expanded={agentSectionOpen}
|
||||
aria-controls="agent-tools-section"
|
||||
>
|
||||
<Wrench className="h-5 w-5" style={{ color: 'var(--pc-accent)' }} />
|
||||
<span className="text-sm font-semibold uppercase tracking-wider flex-1" role="heading" aria-level={2} style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{t('tools.agent_tools')} ({filtered.length})
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="h-4 w-4 opacity-40 group-hover:opacity-100"
|
||||
style={{ color: 'var(--pc-text-muted)', transform: agentSectionOpen ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 0.2s ease, opacity 0.2s ease' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div id="agent-tools-section">
|
||||
{agentSectionOpen && (filtered.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: 'var(--pc-text-muted)' }}>{t('tools.empty')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
|
||||
{filtered.map((tool) => {
|
||||
const isExpanded = expandedTool === tool.name;
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="card overflow-hidden animate-slide-in-up"
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedTool(isExpanded ? null : tool.name)}
|
||||
className="w-full text-left p-4 transition-all"
|
||||
style={{ background: 'transparent' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--pc-hover)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Package className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--pc-accent)' }} />
|
||||
<h3 className="text-sm font-semibold truncate" style={{ color: 'var(--pc-text-primary)' }}>{tool.name}</h3>
|
||||
</div>
|
||||
{isExpanded
|
||||
? <ChevronDown className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--pc-accent)' }} />
|
||||
: <ChevronRight className="h-4 w-4 flex-shrink-0" style={{ color: 'var(--pc-text-faint)' }} />
|
||||
}
|
||||
</div>
|
||||
<p className="text-sm mt-2 line-clamp-2" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{tool.description}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{isExpanded && tool.parameters && (
|
||||
<div className="border-t p-4 animate-fade-in" style={{ borderColor: 'var(--pc-border)' }}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{t('tools.parameter_schema')}
|
||||
</p>
|
||||
<pre className="text-xs rounded-xl p-3 overflow-x-auto max-h-64 overflow-y-auto font-mono" style={{ background: 'var(--pc-bg-base)', color: 'var(--pc-text-secondary)' }}>
|
||||
{JSON.stringify(tool.parameters, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CLI Tools Section */}
|
||||
{filteredCli.length > 0 && (
|
||||
<div className="animate-slide-in-up" style={{ animationDelay: '200ms' }}>
|
||||
<button
|
||||
onClick={() => setCliSectionOpen((v) => !v)}
|
||||
className="flex items-center gap-2 mb-4 w-full text-left group"
|
||||
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: 0 }}
|
||||
aria-expanded={cliSectionOpen}
|
||||
aria-controls="cli-tools-section"
|
||||
>
|
||||
<Terminal className="h-5 w-5" style={{ color: 'var(--color-status-success)' }} />
|
||||
<span className="text-sm font-semibold uppercase tracking-wider flex-1" role="heading" aria-level={2} style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{t('tools.cli_tools')} ({filteredCli.length})
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="h-4 w-4 opacity-40 group-hover:opacity-100"
|
||||
style={{ color: 'var(--pc-text-muted)', transform: cliSectionOpen ? 'rotate(0deg)' : 'rotate(-90deg)', transition: 'transform 0.2s ease, opacity 0.2s ease' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div id="cli-tools-section">
|
||||
{cliSectionOpen && <div className="card overflow-hidden rounded-2xl">
|
||||
<table className="table-electric">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('tools.name')}</th>
|
||||
<th>{t('tools.path')}</th>
|
||||
<th>{t('tools.version')}</th>
|
||||
<th>{t('tools.category')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCli.map((tool) => (
|
||||
<tr key={tool.name}>
|
||||
<td className="font-medium text-sm" style={{ color: 'var(--pc-text-primary)' }}>
|
||||
{tool.name}
|
||||
</td>
|
||||
<td className="font-mono text-xs truncate max-w-[200px]" style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{tool.path}
|
||||
</td>
|
||||
<td style={{ color: 'var(--pc-text-muted)' }}>
|
||||
{tool.version ?? '-'}
|
||||
</td>
|
||||
<td>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold capitalize border" style={{ borderColor: 'var(--pc-border)', color: 'var(--pc-text-secondary)', background: 'var(--pc-accent-glow)' }}>
|
||||
{tool.category}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
third_party/zeroclaw/web/src/types/api.ts
vendored
Normal file
143
third_party/zeroclaw/web/src/types/api.ts
vendored
Normal 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;
|
||||
}
|
||||
1
third_party/zeroclaw/web/src/vite-env.d.ts
vendored
Normal file
1
third_party/zeroclaw/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user