* feat(gateway): add Live Canvas (A2UI) tool and real-time web viewer Add a Live Canvas system that enables the agent to push rendered content (HTML, SVG, Markdown, text) to a web-visible canvas in real time. Backend: - src/tools/canvas.rs: CanvasTool with render/snapshot/clear/eval actions, backed by a shared CanvasStore (Arc<RwLock<HashMap>>) with per-canvas broadcast channels for real-time updates - src/gateway/canvas.rs: REST endpoints (GET/POST/DELETE /api/canvas/:id, GET /api/canvas/:id/history, GET /api/canvas) and WebSocket endpoint (WS /ws/canvas/:id) for real-time frame delivery Frontend: - web/src/pages/Canvas.tsx: Canvas viewer page with WebSocket connection, iframe sandbox rendering, canvas switcher, frame history panel Registration: - CanvasTool registered in all_tools_with_runtime (always available) - Canvas routes wired into gateway router - CanvasStore added to AppState - Canvas page added to App.tsx router and Sidebar navigation - i18n keys added for en/zh/tr locales Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(config): fix pre-existing test compilation errors in schema.rs - Remove #[cfg(unix)] gate on `use tempfile::TempDir` import since TempDir is used unconditionally in bootstrap file tests - Add explicit type annotations on tokio::fs::* calls to resolve type inference failures (create_dir_all, write, read_to_string) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(gateway): share CanvasStore between tool and REST API The CanvasTool and gateway AppState each created their own CanvasStore, so content rendered via the tool never appeared in the REST API. Create the CanvasStore once in the gateway, pass it to all_tools_with_runtime via a new optional parameter, and reuse the same instance in AppState. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(gateway): address critical security and reliability bugs in Live Canvas - Validate content_type in REST POST endpoint against allowed set, preventing injection of "eval" frames via the REST API - Enforce MAX_CONTENT_SIZE (256KB) limit on REST POST endpoint, matching tool-side validation to prevent memory exhaustion - Add MAX_CANVAS_COUNT (100) limit to prevent unbounded canvas creation and memory exhaustion from CanvasStore - Handle broadcast RecvError::Lagged in WebSocket handler gracefully instead of disconnecting the client - Make MAX_CONTENT_SIZE and ALLOWED_CONTENT_TYPES pub for gateway reuse - Update CanvasStore::render and subscribe to return Option for canvas count enforcement --------- Co-authored-by: Giulio V <vannini.gv@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: rareba <rareba@users.noreply.github.com>
256 lines
9.0 KiB
TypeScript
256 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|