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