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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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