import type { AdminAutomationRecord, AdminChannelRecord, AdminIoTDevice, AdminLlmConfig, AdminPluginRecord, AdminRagHit, AdminRagResponse, AdminUserRecord, ApiErrorShape, ChatSyncResult, LoginResult, MemoryRecord, Mode, PersonaRecord, Role, SessionRecord, } from "./types"; /** * REST client for the Cyrene v2 gateway. The gateway wraps every success in * `{data}` and every failure in `{error:{code,message}}` (Docs/api-client.md). * We use same-origin relative paths so the same code works behind the Vite dev * proxy and inside the single-binary gateway on :8080. */ export class ApiError extends Error { readonly status: number; readonly code?: string; constructor(status: number, code: string | undefined, message: string) { super(message); this.name = "ApiError"; this.status = status; this.code = code; } } const API_BASE = "/api/v1"; const TOKEN_KEY = "cyrene.jwt"; const ROLE_KEY = "cyrene.role"; const API_KEY_KEY = "cyrene.apikey"; export function getAuthToken(): string | null { return localStorage.getItem(TOKEN_KEY); } export function setAuthToken(token: string | null): void { if (token) { localStorage.setItem(TOKEN_KEY, token); setStoredRole(decodeJwtRole(token)); } else { localStorage.removeItem(TOKEN_KEY); setStoredRole(null); } } /** Decode the RBAC role from a JWT payload (client-side only; never verified). * base64url is converted to base64 before decoding to survive `-`/`_`. */ export function decodeJwtRole(token: string): Role | null { const parts = token.split("."); if (parts.length < 2) return null; try { const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); const payload = JSON.parse(atob(padded)) as Record; const r = payload.role; return r === "owner" || r === "admin" || r === "viewer" ? r : null; } catch { return null; } } /** Persisted RBAC role, refreshed when the token changes. Falls back to * decoding the token so a session created before role persistence still works. */ export function getStoredRole(): Role | null { const r = localStorage.getItem(ROLE_KEY); if (r === "owner" || r === "admin" || r === "viewer") return r; const token = getAuthToken(); return token ? decodeJwtRole(token) : null; } export function setStoredRole(role: Role | null): void { if (role) localStorage.setItem(ROLE_KEY, role); else localStorage.removeItem(ROLE_KEY); } /** Optional API key for admin endpoints (X-API-Key). */ export function getApiKey(): string | null { return localStorage.getItem(API_KEY_KEY); } export function setApiKey(key: string | null): void { if (key) localStorage.setItem(API_KEY_KEY, key); else localStorage.removeItem(API_KEY_KEY); } interface Envelope { data?: unknown; error?: ApiErrorShape; } async function request( path: string, init: RequestInit = {} ): Promise { const headers = new Headers(init.headers); headers.set("Content-Type", "application/json"); headers.set("X-API-Version", "1"); const token = getAuthToken(); if (token) headers.set("Authorization", `Bearer ${token}`); const apiKey = getApiKey(); if (apiKey) headers.set("X-API-Key", apiKey); let res: Response; try { res = await fetch(`${API_BASE}${path}`, { ...init, headers }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : "网络请求失败"; throw new ApiError(0, "network_error", msg); } let body: Envelope; try { body = (await res.json()) as Envelope; } catch { throw new ApiError(res.status, "bad_response", "服务端返回了无法解析的响应"); } if (!res.ok || body.error) { throw new ApiError( res.status, body.error?.code ?? "unknown", body.error?.message ?? `请求失败(${res.status})` ); } return body.data as T; } /** POST /api/v1/auth/login — exchanges user/password for a JWT. */ export function login(username: string, password: string): Promise { return request("/auth/login", { method: "POST", body: JSON.stringify({ username, password }), }); } /** GET /api/v1/health — liveness probe. */ export function health(): Promise<{ status: string }> { return request<{ status: string }>("/health"); } /** POST /api/v1/chat — one synchronous agent turn. */ export function chatSync( sessionId: string, text: string ): Promise { return request("/chat", { method: "POST", body: JSON.stringify({ sessionId, text }), }); } /** POST /api/v1/chat with stream:true — triggers streaming over the WS channel. * `image` (a base64 data: URI or URL) is attached for the vision path; the * gateway's chat body currently has no image field, so an unconfigured backend * simply ignores it. */ export function chatStream( sessionId: string, text: string, image?: string ): Promise<{ stream: true }> { return request<{ stream: true }>("/chat", { method: "POST", body: JSON.stringify({ sessionId, text, image, stream: true }), }); } /** GET /api/v1/sessions — known channel sessions. */ export function fetchSessions(): Promise { return request("/sessions"); } /** GET /api/v1/personas — persona summaries. */ export function fetchPersonas(): Promise { return request("/personas"); } /** Is this error a "backend has not implemented it yet" case (404/501/403 on a * not-yet-wired route) rather than a real connectivity problem? */ export function isNotImplemented(err: unknown): boolean { if (err instanceof ApiError) { return err.status === 404 || err.status === 501 || err.code === "not_found"; } return false; } /* ---------------------------------------------------------------------------- * Admin API (/api/v1/admin/*). Reference: Docs/api-client.md §2 + design/10. * Implemented endpoints return real data; the rest are contract-shaped and are * expected to 404 until the backend wires them — callers show a graceful shell. * -------------------------------------------------------------------------- */ /** GET /api/v1/admin/personas */ export function adminListPersonas(): Promise { return request("/admin/personas"); } /** GET /api/v1/admin/sessions */ export function adminListSessions(): Promise { return request("/admin/sessions"); } /** GET /api/v1/admin/memories */ export function adminListMemories(): Promise { return request("/admin/memories"); } /** GET /api/v1/admin/plugins */ export function adminListPlugins(): Promise { return request("/admin/plugins"); } /** POST /api/v1/admin/plugins/policy — owner-only, audited. */ export function adminSetPluginPolicy( name: string, allowInGroup: boolean, allowInStranger: boolean ): Promise { return request("/admin/plugins/policy", { method: "POST", body: JSON.stringify({ name, allowInGroup, allowInStranger }), }); } /** GET /api/v1/admin/channels */ export function adminListChannels(): Promise { return request("/admin/channels"); } /** GET /api/v1/admin/iot */ export function adminListIoT(): Promise { return request("/admin/iot"); } // --- Planned M10 endpoints (contract-shaped, backend may 404). --- /** GET /api/v1/admin/users */ export function adminListUsers(): Promise { return request("/admin/users"); } /** GET /api/v1/admin/llm */ export function adminGetLlm(): Promise { return request("/admin/llm"); } /** GET /api/v1/admin/automations */ export function adminListAutomations(): Promise { return request("/admin/automations"); } /** GET /api/v1/admin/rag — lists knowledge documents; when query is present the * backend also returns matching hits. */ export function adminListRag(query?: string): Promise { const qs = query ? `?query=${encodeURIComponent(query)}` : ""; return request(`/admin/rag${qs}`); } /** POST /api/v1/admin/rag/query — runs a real knowledge-base query. */ export function adminSearchRag( query: string ): Promise<{ query: string; hits: AdminRagHit[] }> { return request<{ query: string; hits: AdminRagHit[] }>("/admin/rag/query", { method: "POST", body: JSON.stringify({ query }), }); } /** POST /api/v1/admin/rag/delete — deletes a document. The backend returns a * clear 501 until M10 lands the write path (upload/chunk/embed/delete). */ export function adminDeleteRag(id: string): Promise<{ id: string }> { return request<{ id: string }>("/admin/rag/delete", { method: "POST", body: JSON.stringify({ id }), }); }