feat: refactor sgclaw around zeroclaw compat runtime

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

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

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

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

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