import type { AdminAutomationRecord, AdminChannelMessage, AdminChannelRecord, AdminIoTDevice, AdminLlmConfig, AdminLlmProvider, AdminObservabilityMetrics, AdminPluginRecord, AdminRagHit, AdminRagResponse, AdminTraceSpan, AdminUserRecord, ApiErrorShape, ApiKeyIssueResult, AuditRecord, BackthinkItem, AutomationCreateResult, AutomationRunResult, ChannelTestResult, ChatMessage, ChatSyncResult, LlmProviderRequest, LlmTestRequest, LlmTestResult, LoginResult, MemoryAddRequest, MemoryAddResult, MemoryRecord, MemorySearchRequest, MemorySearchResult, Mode, PersonaRecord, PersonaUpsertRequest, KookFriendList, KookMessageTestResult, KookViewResponse, RagIndexRequest, Role, SessionMeta, SessionRecord, } from "./types"; import { getClientId } from "./client"; /** * 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); } /** True when the JWT's `exp` (unix seconds) is already in the past. Used client-side * to recognise a handshake rejection as "token expired" before the browser ever * exposes the 401 (WS handshake failures surface only as a generic close). */ export function isTokenExpired(token: string): boolean { const parts = token.split("."); if (parts.length < 2) return false; 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 { exp?: number }; const exp = payload.exp; return typeof exp === "number" && exp > 0 && Math.floor(Date.now() / 1000) >= exp; } catch { return false; } } /** Subscribe to an "auth expired" event. The REST layer fires this exactly once * when a non-login request comes back 401/unauthorized, after already clearing * the stored token; the WS layer subscribes to stop reconnecting + flag the UI. */ type AuthExpiredHandler = () => void; const authExpiredHandlers = new Set(); export function onAuthExpired(handler: AuthExpiredHandler): () => void { authExpiredHandlers.add(handler); return () => { authExpiredHandlers.delete(handler); }; } function fireAuthExpired(): void { authExpiredHandlers.forEach((h) => h()); } /** 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; } /** Result of saving/deleting a channel account config (persisted to channels.json). */ export interface ChannelConfigSaveResult { persisted: boolean; path: string; platform: string; accountId: string; } async function request( path: string, init: RequestInit = {}, opts: { skipAuthExpired?: boolean } = {} ): 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) { const code = body.error?.code ?? (res.status === 401 ? "unauthorized" : "unknown"); // A 401/unauthorized on a logged-in (non-login) call means the stored token is // gone/expired: clear it and notify the WS layer so it stops reconnecting, then // throw a dedicated code so the UI can prompt re-login. The login call itself is // exempt — a bad password is a per-request failure, not a stale session. if (!opts.skipAuthExpired && (res.status === 401 || code === "unauthorized")) { setAuthToken(null); fireAuthExpired(); throw new ApiError(res.status, "auth_expired", "登录已过期,请重新登录"); } throw new ApiError( res.status, code, 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 }), }, { skipAuthExpired: true } ); } /** 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. The body carries the sender's * persistent per-browser deviceId (design/16 §4.5) so the persisted user and * assistant messages are tagged with the concrete client. */ export function chatSync( sessionId: string, text: string ): Promise { return request("/chat", { method: "POST", body: JSON.stringify({ sessionId, text, deviceId: getClientId() }), }); } /** 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. `deviceId` mirrors the client's per-browser id onto the * persisted message (design/16 §4.5). */ 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, deviceId: getClientId() }), }); } /** One chat message as returned by GET /api/v1/sessions/{id}/messages. Mirrors * the `chat_messages` row; `mode`/`mood`/`image`/`thinking` are optional. */ export interface MessageDto { id: string; sessionId: string; role: "user" | "assistant" | "system"; content: string; mode?: Mode; mood?: string; image?: string; thinking?: string; /** Source-platform tag (design/16 §4.4): "web" or an IM channel platform. */ platform?: string; /** Concrete client/device that produced the message (design/16 §4.5). */ deviceId?: string; /** Sender display name of a third-party message (design/16 §4.6). Blank for the owner. */ senderName?: string; createdAt: number; } /** Map a wire message into a display ChatMessage (carrying its sessionId). */ function toChatMessage(m: MessageDto): ChatMessage { return { id: m.id, sessionId: m.sessionId, role: m.role, content: m.content, mode: m.mode, mood: m.mood, image: m.image, thinking: m.thinking, platform: m.platform, deviceId: m.deviceId, senderName: m.senderName, createdAt: m.createdAt, }; } /** GET /api/v1/sessions — the main + secondary sessions for this web account. */ export function fetchSessions(): Promise { return request("/sessions"); } /** POST /api/v1/sessions — creates a new secondary session. */ export function createSubSession(title?: string): Promise { return request("/sessions", { method: "POST", body: JSON.stringify(title ? { title } : {}), }); } /** GET /api/v1/sessions/{id}/messages?before=&limit=&platform= — hot-paginated * history, optionally filtered to one source platform (design/16 §4.4). The wire * returns newest-first, but callers render chronologically (oldest first), so we * reverse here: page 1 is the most recent `limit` messages in display order, and * `before` (the oldest message's createdAt) walks upward. The Web client omits * `platform` for the full shared main session; an IM adapter passes its own * platform to see only its lane. */ export async function fetchMessages( sessionId: string, before?: number, limit = 50, platform?: string ): Promise { const qs = new URLSearchParams(); qs.set("limit", String(limit)); if (before !== undefined) qs.set("before", String(before)); if (platform) qs.set("platform", platform); const list = await request( `/sessions/${encodeURIComponent(sessionId)}/messages?${qs.toString()}` ); return list.map(toChatMessage).reverse(); } /** DELETE /api/v1/sessions/{id} — removes a secondary session (and its messages). */ export function deleteSession( sessionId: string ): Promise<{ id: string; deleted: true }> { return request<{ id: string; deleted: true }>( `/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" } ); } /** 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 — the persistent main + secondary sessions (design/16). * The main session (fixed id) is always present, so the panel is never empty in a * history-wired deployment. */ 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 }), }); } /** POST /api/v1/admin/rag — imports one knowledge document (chunk + index), so it * becomes keyword/searchable in the project index. */ export function adminIndexRag(req: RagIndexRequest): Promise<{ name: string }> { return request<{ name: string }>("/admin/rag", { method: "POST", body: JSON.stringify(req), }); } /* --- Admin write endpoints (owner-only, audited; real store or honest 501). --- */ /** POST /api/v1/admin/personas — registers a persona (persisted to YAML + registry). */ export function adminCreatePersona(req: PersonaUpsertRequest): Promise { return request("/admin/personas", { method: "POST", body: JSON.stringify(req), }); } /** PUT /api/v1/admin/personas/{id} — edits an existing persona. */ export function adminUpdatePersona(id: string, req: PersonaUpsertRequest): Promise { return request(`/admin/personas/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(req), }); } /** POST /api/v1/admin/users — add user/role. No persistent user store yet, so the * backend answers honestly with a 501 ("M10"). */ export function adminCreateUser(req: { username: string; role: string }): Promise { return request("/admin/users", { method: "POST", body: JSON.stringify(req), }); } /** POST /api/v1/admin/users/apikey — mints a key; the plaintext comes back once. */ export function adminIssueApiKey(role: string): Promise { return request("/admin/users/apikey", { method: "POST", body: JSON.stringify({ role }), }); } /** DELETE /api/v1/admin/users/apikey/{id} — revokes a key by masked id. */ export function adminRevokeApiKey(id: string): Promise<{ id: string; revoked: boolean }> { return request<{ id: string; revoked: boolean }>(`/admin/users/apikey/${encodeURIComponent(id)}`, { method: "DELETE", }); } /** POST /api/v1/admin/iot/{id}/control — sends a control instruction to a device. */ export function adminIoTControl(id: string, action: string, params?: Record): Promise { return request(`/admin/iot/${encodeURIComponent(id)}/control`, { method: "POST", body: JSON.stringify({ action, params }), }); } /** POST /api/v1/admin/iot/scenarios — honest 501 (no scenario store yet). */ export function adminIoTScenario(req: { name: string }): Promise<{ name: string }> { return request<{ name: string }>("/admin/iot/scenarios", { method: "POST", body: JSON.stringify(req) }); } /** POST /api/v1/admin/iot/rules — honest 501 (no IoT rule store yet). */ export function adminIoTRule(req: { name: string; expression: string }): Promise<{ name: string }> { return request<{ name: string }>("/admin/iot/rules", { method: "POST", body: JSON.stringify(req) }); } /** POST /api/v1/admin/automations — registers a real cron/event automation. */ export function adminCreateAutomation(req: { name: string; kind: string; expression: string; }): Promise { return request("/admin/automations", { method: "POST", body: JSON.stringify(req), }); } /** POST /api/v1/admin/automations/{id}/run — manually triggers a rule. */ export function adminRunAutomation(id: string): Promise { return request(`/admin/automations/${encodeURIComponent(id)}/run`, { method: "POST" }); } /** DELETE /api/v1/admin/automations/{id} — removes a rule (cron or event). */ export function adminDeleteAutomation(id: string): Promise<{ id: string; deleted: boolean }> { return request<{ id: string; deleted: boolean }>(`/admin/automations/${encodeURIComponent(id)}`, { method: "DELETE" }); } /** POST /api/v1/admin/llm — saves a provider group to models.json and hot-reloads * the running LLM stack (design/04 §3.1). The provider is the wire protocol. */ export function adminAddLlmProvider(req: LlmProviderRequest): Promise<{ provider: AdminLlmProvider; persisted: boolean; path?: string; }> { return request<{ provider: AdminLlmProvider; persisted: boolean; path?: string }>("/admin/llm", { method: "POST", body: JSON.stringify(req), }); } /** POST /api/v1/admin/llm/test — performs a real model call and reports the result. */ export function adminTestLlm(req: LlmTestRequest): Promise { return request("/admin/llm/test", { method: "POST", body: JSON.stringify(req) }); } /** POST /api/v1/admin/channels/open — connects a registered channel adapter. */ export function adminChannelOpen(name: string): Promise<{ name: string; connected: boolean }> { return request<{ name: string; connected: boolean }>("/admin/channels/open", { method: "POST", body: JSON.stringify({ name }), }); } /** POST /api/v1/admin/channels/close — disconnects a registered channel adapter. */ export function adminChannelClose(name: string): Promise<{ name: string; connected: boolean }> { return request<{ name: string; connected: boolean }>("/admin/channels/close", { method: "POST", body: JSON.stringify({ name }), }); } /** POST /api/v1/admin/channels/test — structural identity test (actor × whitelist). */ export function adminChannelTest(req: { platform: string; accountId: string; isGroup: boolean; }): Promise { return request("/admin/channels/test", { method: "POST", body: JSON.stringify(req) }); } /** GET /api/v1/admin/channels/messages?adapter= — one adapter config's * (platform:account) send/receive message log, newest first. */ export function adminChannelMessages(adapter: string, limit?: number): Promise { const q = new URLSearchParams({ adapter }); if (limit && limit > 0) q.set("limit", String(limit)); return request(`/admin/channels/messages?${q.toString()}`); } /** GET /api/v1/admin/channels/messages — adapter overview log across all adapters. */ export function adminChannelLogOverview(limit?: number): Promise { const q = limit && limit > 0 ? `?limit=${limit}` : ""; return request(`/admin/channels/messages${q}`); } /** PUT /api/v1/admin/channels/{platform}/config — save an account config + hot-reload. */ export function adminSaveChannelConfig( platform: string, req: { id?: string; token?: string; baseURL?: string; remoteURL?: string; listenAddr?: string; mode?: string; accessToken?: string; selfID?: string; config?: Record; allowGroups?: string[]; denyGroups?: string[]; allowUsers?: string[]; denyUsers?: string[]; groupMsgPerMin?: number; msgPerMin?: number; maxMessages?: number; }, ): Promise { return request(`/admin/channels/${platform}/config`, { method: "PUT", body: JSON.stringify(req), }); } /** DELETE /api/v1/admin/channels/{platform}/config?id=… — delete an account + hot-reload. */ export function adminDeleteChannelAccount(platform: string, id: string): Promise { return request(`/admin/channels/${platform}/config?id=${encodeURIComponent(id)}`, { method: "DELETE", }); } /** GET /api/v1/admin/channels/{platform}/view — one platform account's aggregated * panel data (friends / servers / channels / members / private chats), owner only. */ export function adminChannelPlatformView(platform: string, accountId?: string): Promise { const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; return request(`/admin/channels/${encodeURIComponent(platform)}/view${q}`); } /** GET /api/v1/admin/channels/{platform}/friends?type= — friend/request/blocked list. */ export function adminChannelPlatformFriends( platform: string, type: string, accountId?: string, ): Promise { const q = new URLSearchParams(); if (type) q.set("type", type); if (accountId) q.set("accountId", accountId); const qs = q.toString(); return request(`/admin/channels/${encodeURIComponent(platform)}/friends${qs ? `?${qs}` : ""}`); } /** POST /api/v1/admin/channels/{platform}/friend/handle — accept/reject a friend request. */ export function adminChannelFriendHandle( platform: string, id: number, accept: boolean, accountId?: string, ): Promise<{ handled: boolean; id: number; accept: boolean }> { const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; return request<{ handled: boolean; id: number; accept: boolean }>( `/admin/channels/${encodeURIComponent(platform)}/friend/handle${q}`, { method: "POST", body: JSON.stringify({ id, accept }) }, ); } /** POST /api/v1/admin/channels/{platform}/message/test — send one test message. */ export function adminChannelMessageTest( platform: string, targetId: string, content: string, accountId?: string, ): Promise { const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; return request(`/admin/channels/${encodeURIComponent(platform)}/message/test${q}`, { method: "POST", body: JSON.stringify({ targetId, content }), }); } /** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */ export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise { return request("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) }); } /** POST /api/v1/admin/plugins/uninstall — removes a capability from the registry. */ export function adminPluginUninstall(req: { name: string }): Promise<{ name: string; uninstalled: boolean }> { return request<{ name: string; uninstalled: boolean }>("/admin/plugins/uninstall", { method: "POST", body: JSON.stringify(req), }); } /** POST /api/v1/admin/memories/search — real retrieval over the memory store. */ export function adminMemorySearch(req: MemorySearchRequest): Promise { return request("/admin/memories/search", { method: "POST", body: JSON.stringify(req) }); } /** POST /api/v1/admin/memories — writes one memory item (manual "添加记忆"). */ export function adminAddMemory(req: MemoryAddRequest): Promise { return request("/admin/memories", { method: "POST", body: JSON.stringify(req), }); } /** GET /api/v1/admin/sessions/{id}/export — exports a session as JSON. */ export function adminExportSession(id: string): Promise<{ session: SessionRecord }> { return request<{ session: SessionRecord }>(`/admin/sessions/${encodeURIComponent(id)}/export`); } /** GET /api/v1/admin/observability/metrics — gateway request metrics. */ export function adminObservabilityMetrics(): Promise { return request("/admin/observability/metrics"); } /** GET /api/v1/admin/observability/trace?limit=N — most recent traced spans. */ export function adminObservabilityTrace(limit?: number): Promise { return request(`/admin/observability/trace${limitQuery(limit)}`); } /** GET /api/v1/admin/observability/audit?limit=N — most recent audit entries. */ export function adminObservabilityAudit(limit?: number): Promise { return request(`/admin/observability/audit${limitQuery(limit)}`); } /** Builds an optional ?limit=N query suffix (empty when no positive limit). */ function limitQuery(limit?: number): string { return limit && limit > 0 ? `?limit=${limit}` : ""; } /** GET /api/v1/admin/backthink — persisted background-think records (newest first). */ export function adminBackthink(limit?: number): Promise { return request(`/admin/backthink${limitQuery(limit)}`); }