diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 06c9951..f790c46 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -1,789 +1,109 @@ -import type { - AdminAutomationRecord, - AdminChannelMessage, - AdminChannelRecord, - AdminIoTDevice, - AdminLlmConfig, - AdminLlmProvider, - AdminNotification, - AdminNotificationQuery, - AdminObservabilityMetrics, - AdminPluginRecord, - AdminRagHit, - AdminRagResponse, - AdminTraceSpan, - AdminUserRecord, - ApiErrorShape, - ApiKeyIssueResult, - AuditRecord, - BackthinkItem, - AutomationCreateResult, - AutomationRunResult, - ChannelTestResult, - ChatMessage, - ChatSyncResult, - InstalledPlugin, - LlmProviderRequest, - LlmTestRequest, - LlmTestResult, - LoginResult, - MemoryAddRequest, - MemoryAddResult, - MemoryRecord, - MemorySearchRequest, - MemorySearchResult, - Mode, - PersonaRecord, - PersonaUpsertRequest, - PluginInstallRequest, - PluginInstallResult, - KookFriendList, - KookMessageTestResult, - KookViewResponse, - RagIndexRequest, - Role, - SessionMeta, - SystemBackupResp, - SystemRestoreResp, - SystemRevokeKeysResp, - SystemRotateJwtResp, - WeixinLoginQRResponse, - WeixinLoginStatusResponse, - 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. + * Public REST surface for the Cyrene v2 gateway, kept as a single import path + * (`lib/api`) so call sites never change. The implementation is split by domain + * under `lib/api/`: + * + * core.ts transport (request/ApiError) + token/role/API-key state + * auth.ts /auth/login, /health + * chat.ts /chat (sync + streaming) + * sessions.ts /sessions (list/create/delete, message history) + * personas.ts /personas + * admin.ts /admin/* CRUD (personas, users, llm, rag, iot, automations…) + * adminChannels.ts /admin/channels/* (adapters, config, platform views, login) + * adminSystem.ts /admin/observability|notifications|backthink|system/* + * + * Everything below is a verbatim re-export — behaviour is unchanged. */ -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 { + ApiError, + decodeJwtRole, + getApiKey, + getAuthToken, + getStoredRole, + isNotImplemented, + isTokenExpired, + onAuthExpired, + setApiKey, + setAuthToken, + setStoredRole, +} from "./api/core"; -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); - } -} +export { health, login } from "./api/auth"; -/** 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; - } -} +export { chatStream, chatSync } from "./api/chat"; -/** 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); -} +export { + createSubSession, + deleteSession, + fetchMessages, + fetchSessions, +} from "./api/sessions"; +export type { MessageDto } from "./api/sessions"; -/** 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; - } -} +export { fetchPersonas } from "./api/personas"; -/** 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()); -} +export { + adminAddLlmProvider, + adminAddMemory, + adminCreateAutomation, + adminCreatePersona, + adminCreateUser, + adminDeleteAutomation, + adminDeleteRag, + adminExportSession, + adminGetLlm, + adminIndexRag, + adminIoTControl, + adminIoTRule, + adminIoTScenario, + adminIssueApiKey, + adminListAutomations, + adminListChannels, + adminListInstalledPlugins, + adminListIoT, + adminListMemories, + adminListPersonas, + adminListPlugins, + adminListRag, + adminListSessions, + adminListUsers, + adminMemorySearch, + adminPluginInstall, + adminPluginUninstall, + adminRevokeApiKey, + adminRunAutomation, + adminSearchRag, + adminSetPluginPolicy, + adminTestLlm, + adminUpdatePersona, +} from "./api/admin"; -/** 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); -} +export { + adminChannelClose, + adminChannelFriendHandle, + adminChannelLogOverview, + adminChannelMessageTest, + adminChannelMessages, + adminChannelOpen, + adminChannelPlatformFriends, + adminChannelPlatformView, + adminChannelTest, + adminDeleteChannelAccount, + adminSaveChannelConfig, + adminWeixinLoginQR, + adminWeixinLoginStatus, +} from "./api/adminChannels"; +export type { ChannelConfigSaveResult } from "./api/adminChannels"; -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). `mode` is the behavior mode this turn is - * sent in (design/01 §3); omitted when empty so the backend falls back to chat. */ -export function chatStream( - sessionId: string, - text: string, - image?: string, - mode?: Mode -): Promise<{ stream: true }> { - return request<{ stream: true }>("/chat", { - method: "POST", - body: JSON.stringify({ - sessionId, - text, - image, - stream: true, - deviceId: getClientId(), - ...(mode ? { mode } : {}), - }), - }); -} - -/** 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"); -} -/** GET /api/v1/admin/plugins/installed — runtime-installed plugins (v0.28), with - * the backend masking apiKey/sensitive env and reporting live/toolCount. */ -export function adminListInstalledPlugins(): Promise { - return request("/admin/plugins/installed"); -} -/** 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 — really deletes a document (project entry + - * cascaded chapters). 404 when the id does not exist. */ -export function adminDeleteRag(id: string): Promise<{ id: string; deleted: boolean }> { - return request<{ id: string; deleted: boolean }>("/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 }), - }); -} -/** GET /api/v1/admin/channels/{platform}/login-qr — trigger/mint a weixin-iLink - * scan-login QR code (owner only, audited). */ -export function adminWeixinLoginQR( - platform: string, - accountId?: string, -): Promise { - const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; - return request( - `/admin/channels/${encodeURIComponent(platform)}/login-qr${q}`, - ); -} -/** GET /api/v1/admin/channels/{platform}/login-status — poll a weixin-iLink scan - * login. On confirmed the backend persists the token and hot-connects. */ -export function adminWeixinLoginStatus( - platform: string, - accountId?: string, -): Promise { - const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; - return request( - `/admin/channels/${encodeURIComponent(platform)}/login-status${q}`, - ); -} -/** POST /api/v1/admin/plugins/install — installs (or refreshes) an MCP server - * plugin (v0.28): connects, discovers its tools and hot-registers them. The - * backend answers 502 and persists nothing when connect/discover fails. */ -export function adminPluginInstall(req: PluginInstallRequest): Promise { - return request("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) }); -} -/** POST /api/v1/admin/plugins/uninstall — removes an installed MCP server (tools - * unregistered + config deleted) or a plain capability from the registry. */ -export function adminPluginUninstall(name: string): Promise<{ name: string; uninstalled: boolean; kind?: string }> { - return request<{ name: string; uninstalled: boolean; kind?: string }>("/admin/plugins/uninstall", { - method: "POST", - body: JSON.stringify({ name }), - }); -} -/** 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)}`); -} - -/** GET /api/v1/admin/notifications?type=&before=&limit= — the unified notification - * feed (design/10 §3 #16: audit + channel + backthink + system aggregated). - * `type` omitted = all types, `before` = createdAt(ms) cursor for older pages, - * `limit` defaults to 50 (backend caps at 200). Returned newest first. */ -export function adminListNotifications( - params: AdminNotificationQuery = {} -): Promise { - const qs = new URLSearchParams(); - if (params.type) qs.set("type", params.type); - if (params.before !== undefined) qs.set("before", String(params.before)); - if (params.limit !== undefined) qs.set("limit", String(params.limit)); - const q = qs.toString(); - return request(`/admin/notifications${q ? `?${q}` : ""}`); -} - -/* ---------------------------------------------------------------------------- - * System management admin writes (owner-only, audited). Reference: - * internal/services/gateway/admin_system.go + Docs/api-client.md §系统设置契约. - * All four are implemented (not 501) — they return real data. - * -------------------------------------------------------------------------- */ - -/** POST /api/v1/admin/system/revoke-keys — revokes every registered API key. */ -export function adminSystemRevokeKeys(): Promise { - return request("/admin/system/revoke-keys", { method: "POST" }); -} -/** POST /api/v1/admin/system/rotate-jwt — swaps the JWT signing secret (≥32 chars). - * The secret is provided by the owner and never echoed back; all existing tokens - * become invalid, so the client must re-login afterward. */ -export function adminSystemRotateJWT(newSecret: string): Promise { - return request("/admin/system/rotate-jwt", { - method: "POST", - body: JSON.stringify({ newSecret }), - }); -} -/** POST /api/v1/admin/system/backup — snapshots the SQLite DB (VACUUM INTO, incl. WAL). */ -export function adminSystemBackup(): Promise { - return request("/admin/system/backup", { method: "POST" }); -} -/** POST /api/v1/admin/system/restore — restores a data-dir backup; path must live - * inside the data directory (path-traversal guard). The backend pre-backups the - * current DB and reports that the swap needs a restart. */ -export function adminSystemRestore(path: string): Promise { - return request("/admin/system/restore", { - method: "POST", - body: JSON.stringify({ path }), - }); -} +export { + adminBackthink, + adminListNotifications, + adminObservabilityAudit, + adminObservabilityMetrics, + adminObservabilityTrace, + adminSystemBackup, + adminSystemRestore, + adminSystemRevokeKeys, + adminSystemRotateJWT, +} from "./api/adminSystem"; diff --git a/frontend/web/src/lib/api/admin.ts b/frontend/web/src/lib/api/admin.ts new file mode 100644 index 0000000..516b242 --- /dev/null +++ b/frontend/web/src/lib/api/admin.ts @@ -0,0 +1,243 @@ +import type { + AdminAutomationRecord, + AdminChannelRecord, + AdminIoTDevice, + AdminLlmConfig, + AdminLlmProvider, + AdminPluginRecord, + AdminRagHit, + AdminRagResponse, + AdminUserRecord, + ApiKeyIssueResult, + AutomationCreateResult, + AutomationRunResult, + InstalledPlugin, + LlmProviderRequest, + LlmTestRequest, + LlmTestResult, + MemoryAddRequest, + MemoryAddResult, + MemoryRecord, + MemorySearchRequest, + MemorySearchResult, + PersonaRecord, + PersonaUpsertRequest, + PluginInstallRequest, + PluginInstallResult, + RagIndexRequest, + SessionMeta, + SessionRecord, +} from "../types"; +import { request } from "./core"; + +/* ---------------------------------------------------------------------------- + * 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"); +} +/** GET /api/v1/admin/plugins/installed — runtime-installed plugins (v0.28), with + * the backend masking apiKey/sensitive env and reporting live/toolCount. */ +export function adminListInstalledPlugins(): Promise { + return request("/admin/plugins/installed"); +} +/** 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 — really deletes a document (project entry + + * cascaded chapters). 404 when the id does not exist. */ +export function adminDeleteRag(id: string): Promise<{ id: string; deleted: boolean }> { + return request<{ id: string; deleted: boolean }>("/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/plugins/install — installs (or refreshes) an MCP server + * plugin (v0.28): connects, discovers its tools and hot-registers them. The + * backend answers 502 and persists nothing when connect/discover fails. */ +export function adminPluginInstall(req: PluginInstallRequest): Promise { + return request("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) }); +} +/** POST /api/v1/admin/plugins/uninstall — removes an installed MCP server (tools + * unregistered + config deleted) or a plain capability from the registry. */ +export function adminPluginUninstall(name: string): Promise<{ name: string; uninstalled: boolean; kind?: string }> { + return request<{ name: string; uninstalled: boolean; kind?: string }>("/admin/plugins/uninstall", { + method: "POST", + body: JSON.stringify({ name }), + }); +} +/** 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`); +} diff --git a/frontend/web/src/lib/api/adminChannels.ts b/frontend/web/src/lib/api/adminChannels.ts new file mode 100644 index 0000000..c3e325b --- /dev/null +++ b/frontend/web/src/lib/api/adminChannels.ts @@ -0,0 +1,152 @@ +import type { + AdminChannelMessage, + ChannelTestResult, + KookFriendList, + KookMessageTestResult, + KookViewResponse, + WeixinLoginQRResponse, + WeixinLoginStatusResponse, +} from "../types"; +import { request } from "./core"; + +/** Result of saving/deleting a channel account config (persisted to channels.json). */ +export interface ChannelConfigSaveResult { + persisted: boolean; + path: string; + platform: string; + accountId: string; +} + +/** 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 }), + }); +} +/** GET /api/v1/admin/channels/{platform}/login-qr — trigger/mint a weixin-iLink + * scan-login QR code (owner only, audited). */ +export function adminWeixinLoginQR( + platform: string, + accountId?: string, +): Promise { + const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; + return request( + `/admin/channels/${encodeURIComponent(platform)}/login-qr${q}`, + ); +} +/** GET /api/v1/admin/channels/{platform}/login-status — poll a weixin-iLink scan + * login. On confirmed the backend persists the token and hot-connects. */ +export function adminWeixinLoginStatus( + platform: string, + accountId?: string, +): Promise { + const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""; + return request( + `/admin/channels/${encodeURIComponent(platform)}/login-status${q}`, + ); +} diff --git a/frontend/web/src/lib/api/adminSystem.ts b/frontend/web/src/lib/api/adminSystem.ts new file mode 100644 index 0000000..61ec5d2 --- /dev/null +++ b/frontend/web/src/lib/api/adminSystem.ts @@ -0,0 +1,79 @@ +import type { + AdminNotification, + AdminNotificationQuery, + AdminObservabilityMetrics, + AdminTraceSpan, + AuditRecord, + BackthinkItem, + SystemBackupResp, + SystemRestoreResp, + SystemRevokeKeysResp, + SystemRotateJwtResp, +} from "../types"; +import { limitQuery, request } from "./core"; + +/** 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)}`); +} + +/** GET /api/v1/admin/backthink — persisted background-think records (newest first). */ +export function adminBackthink(limit?: number): Promise { + return request(`/admin/backthink${limitQuery(limit)}`); +} + +/** GET /api/v1/admin/notifications?type=&before=&limit= — the unified notification + * feed (design/10 §3 #16: audit + channel + backthink + system aggregated). + * `type` omitted = all types, `before` = createdAt(ms) cursor for older pages, + * `limit` defaults to 50 (backend caps at 200). Returned newest first. */ +export function adminListNotifications( + params: AdminNotificationQuery = {} +): Promise { + const qs = new URLSearchParams(); + if (params.type) qs.set("type", params.type); + if (params.before !== undefined) qs.set("before", String(params.before)); + if (params.limit !== undefined) qs.set("limit", String(params.limit)); + const q = qs.toString(); + return request(`/admin/notifications${q ? `?${q}` : ""}`); +} + +/* ---------------------------------------------------------------------------- + * System management admin writes (owner-only, audited). Reference: + * internal/services/gateway/admin_system.go + Docs/api-client.md §系统设置契约. + * All four are implemented (not 501) — they return real data. + * -------------------------------------------------------------------------- */ + +/** POST /api/v1/admin/system/revoke-keys — revokes every registered API key. */ +export function adminSystemRevokeKeys(): Promise { + return request("/admin/system/revoke-keys", { method: "POST" }); +} +/** POST /api/v1/admin/system/rotate-jwt — swaps the JWT signing secret (≥32 chars). + * The secret is provided by the owner and never echoed back; all existing tokens + * become invalid, so the client must re-login afterward. */ +export function adminSystemRotateJWT(newSecret: string): Promise { + return request("/admin/system/rotate-jwt", { + method: "POST", + body: JSON.stringify({ newSecret }), + }); +} +/** POST /api/v1/admin/system/backup — snapshots the SQLite DB (VACUUM INTO, incl. WAL). */ +export function adminSystemBackup(): Promise { + return request("/admin/system/backup", { method: "POST" }); +} +/** POST /api/v1/admin/system/restore — restores a data-dir backup; path must live + * inside the data directory (path-traversal guard). The backend pre-backups the + * current DB and reports that the swap needs a restart. */ +export function adminSystemRestore(path: string): Promise { + return request("/admin/system/restore", { + method: "POST", + body: JSON.stringify({ path }), + }); +} diff --git a/frontend/web/src/lib/api/auth.ts b/frontend/web/src/lib/api/auth.ts new file mode 100644 index 0000000..dafa910 --- /dev/null +++ b/frontend/web/src/lib/api/auth.ts @@ -0,0 +1,19 @@ +import type { LoginResult } from "../types"; +import { request } from "./core"; + +/** 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"); +} diff --git a/frontend/web/src/lib/api/chat.ts b/frontend/web/src/lib/api/chat.ts new file mode 100644 index 0000000..7f088c4 --- /dev/null +++ b/frontend/web/src/lib/api/chat.ts @@ -0,0 +1,41 @@ +import { getClientId } from "../client"; +import type { ChatSyncResult, Mode } from "../types"; +import { request } from "./core"; + +/** 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). `mode` is the behavior mode this turn is + * sent in (design/01 §3); omitted when empty so the backend falls back to chat. */ +export function chatStream( + sessionId: string, + text: string, + image?: string, + mode?: Mode +): Promise<{ stream: true }> { + return request<{ stream: true }>("/chat", { + method: "POST", + body: JSON.stringify({ + sessionId, + text, + image, + stream: true, + deviceId: getClientId(), + ...(mode ? { mode } : {}), + }), + }); +} diff --git a/frontend/web/src/lib/api/core.ts b/frontend/web/src/lib/api/core.ts new file mode 100644 index 0000000..b61387b --- /dev/null +++ b/frontend/web/src/lib/api/core.ts @@ -0,0 +1,177 @@ +import type { ApiErrorShape, Role } 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. + * + * This module holds the transport + auth-state plumbing shared by every domain + * module under `lib/api/`; `lib/api.ts` re-exports all of them, so call-site + * import paths are unchanged. + */ +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; +} + +export 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; +} + +/** Builds an optional ?limit=N query suffix (empty when no positive limit). */ +export function limitQuery(limit?: number): string { + return limit && limit > 0 ? `?limit=${limit}` : ""; +} + +/** 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; +} diff --git a/frontend/web/src/lib/api/personas.ts b/frontend/web/src/lib/api/personas.ts new file mode 100644 index 0000000..567d70e --- /dev/null +++ b/frontend/web/src/lib/api/personas.ts @@ -0,0 +1,7 @@ +import type { PersonaRecord } from "../types"; +import { request } from "./core"; + +/** GET /api/v1/personas — persona summaries. */ +export function fetchPersonas(): Promise { + return request("/personas"); +} diff --git a/frontend/web/src/lib/api/sessions.ts b/frontend/web/src/lib/api/sessions.ts new file mode 100644 index 0000000..a2b0220 --- /dev/null +++ b/frontend/web/src/lib/api/sessions.ts @@ -0,0 +1,86 @@ +import type { ChatMessage, Mode, SessionMeta } from "../types"; +import { request } from "./core"; + +/** 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" } + ); +}