feat: refactor sgclaw around zeroclaw compat runtime
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user