refactor(web): 拆 lib/api.ts 789→109 barrel + lib/api/ 8域文件(纯搬移零逻辑)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- lib/api/{core,auth,chat,sessions,personas,admin,adminChannels,adminSystem}.ts
- api.ts 改纯 barrel 显式 re-export(export type for types, isolatedModules); 导出名 77→77, 36处导入点零改动
- 代码行多重集比对一致(仅 request/limitQuery 各加一 export 供兄弟模块); 每文件≤500/函数≤80
- 前端 build 绿(165 modules)
This commit is contained in:
AskaEth committed 2026-09-30 11:52:35 +08:00
1 parent 153a9d6211
commit 4c3c98d5d4
9 files changed
+903 -779

No files matched your search

+99 -779
View File
@@ -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<string, unknown>;
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<AuthExpiredHandler>();
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<T>(
path: string,
init: RequestInit = {},
opts: { skipAuthExpired?: boolean } = {}
): Promise<T> {
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<LoginResult> {
return request<LoginResult>(
"/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<ChatSyncResult> {
return request<ChatSyncResult>("/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<SessionMeta[]> {
return request<SessionMeta[]>("/sessions");
}
/** POST /api/v1/sessions — creates a new secondary session. */
export function createSubSession(title?: string): Promise<SessionMeta> {
return request<SessionMeta>("/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<ChatMessage[]> {
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<MessageDto[]>(
`/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<PersonaRecord[]> {
return request<PersonaRecord[]>("/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<PersonaRecord[]> {
return request<PersonaRecord[]>("/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<SessionMeta[]> {
return request<SessionMeta[]>("/admin/sessions");
}
/** GET /api/v1/admin/memories */
export function adminListMemories(): Promise<MemoryRecord[]> {
return request<MemoryRecord[]>("/admin/memories");
}
/** GET /api/v1/admin/plugins */
export function adminListPlugins(): Promise<AdminPluginRecord[]> {
return request<AdminPluginRecord[]>("/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<InstalledPlugin[]> {
return request<InstalledPlugin[]>("/admin/plugins/installed");
}
/** POST /api/v1/admin/plugins/policy — owner-only, audited. */
export function adminSetPluginPolicy(
name: string,
allowInGroup: boolean,
allowInStranger: boolean
): Promise<AdminPluginRecord> {
return request<AdminPluginRecord>("/admin/plugins/policy", {
method: "POST",
body: JSON.stringify({ name, allowInGroup, allowInStranger }),
});
}
/** GET /api/v1/admin/channels */
export function adminListChannels(): Promise<AdminChannelRecord[]> {
return request<AdminChannelRecord[]>("/admin/channels");
}
/** GET /api/v1/admin/iot */
export function adminListIoT(): Promise<AdminIoTDevice[]> {
return request<AdminIoTDevice[]>("/admin/iot");
}
// --- Planned M10 endpoints (contract-shaped, backend may 404). ---
/** GET /api/v1/admin/users */
export function adminListUsers(): Promise<AdminUserRecord[]> {
return request<AdminUserRecord[]>("/admin/users");
}
/** GET /api/v1/admin/llm */
export function adminGetLlm(): Promise<AdminLlmConfig> {
return request<AdminLlmConfig>("/admin/llm");
}
/** GET /api/v1/admin/automations */
export function adminListAutomations(): Promise<AdminAutomationRecord[]> {
return request<AdminAutomationRecord[]>("/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<AdminRagResponse> {
const qs = query ? `?query=${encodeURIComponent(query)}` : "";
return request<AdminRagResponse>(`/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<PersonaRecord> {
return request<PersonaRecord>("/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<PersonaRecord> {
return request<PersonaRecord>(`/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<AdminUserRecord> {
return request<AdminUserRecord>("/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<ApiKeyIssueResult> {
return request<ApiKeyIssueResult>("/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<string, unknown>): Promise<AdminIoTDevice> {
return request<AdminIoTDevice>(`/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<AutomationCreateResult> {
return request<AutomationCreateResult>("/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<AutomationRunResult> {
return request<AutomationRunResult>(`/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<LlmTestResult> {
return request<LlmTestResult>("/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<ChannelTestResult> {
return request<ChannelTestResult>("/admin/channels/test", { method: "POST", body: JSON.stringify(req) });
}
/** GET /api/v1/admin/channels/messages?adapter=<composite> — one adapter config's
* (platform:account) send/receive message log, newest first. */
export function adminChannelMessages(adapter: string, limit?: number): Promise<AdminChannelMessage[]> {
const q = new URLSearchParams({ adapter });
if (limit && limit > 0) q.set("limit", String(limit));
return request<AdminChannelMessage[]>(`/admin/channels/messages?${q.toString()}`);
}
/** GET /api/v1/admin/channels/messages — adapter overview log across all adapters. */
export function adminChannelLogOverview(limit?: number): Promise<AdminChannelMessage[]> {
const q = limit && limit > 0 ? `?limit=${limit}` : "";
return request<AdminChannelMessage[]>(`/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<string, unknown>;
allowGroups?: string[];
denyGroups?: string[];
allowUsers?: string[];
denyUsers?: string[];
groupMsgPerMin?: number;
msgPerMin?: number;
maxMessages?: number;
},
): Promise<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/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<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/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<KookViewResponse> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<KookViewResponse>(`/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<KookFriendList> {
const q = new URLSearchParams();
if (type) q.set("type", type);
if (accountId) q.set("accountId", accountId);
const qs = q.toString();
return request<KookFriendList>(`/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<KookMessageTestResult> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<KookMessageTestResult>(`/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<WeixinLoginQRResponse> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<WeixinLoginQRResponse>(
`/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<WeixinLoginStatusResponse> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<WeixinLoginStatusResponse>(
`/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<PluginInstallResult> {
return request<PluginInstallResult>("/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<MemorySearchResult> {
return request<MemorySearchResult>("/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<MemoryAddResult> {
return request<MemoryAddResult>("/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<AdminObservabilityMetrics> {
return request<AdminObservabilityMetrics>("/admin/observability/metrics");
}
/** GET /api/v1/admin/observability/trace?limit=N — most recent traced spans. */
export function adminObservabilityTrace(limit?: number): Promise<AdminTraceSpan[]> {
return request<AdminTraceSpan[]>(`/admin/observability/trace${limitQuery(limit)}`);
}
/** GET /api/v1/admin/observability/audit?limit=N — most recent audit entries. */
export function adminObservabilityAudit(limit?: number): Promise<AuditRecord[]> {
return request<AuditRecord[]>(`/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<BackthinkItem[]> {
return request<BackthinkItem[]>(`/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<AdminNotification[]> {
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<AdminNotification[]>(`/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<SystemRevokeKeysResp> {
return request<SystemRevokeKeysResp>("/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<SystemRotateJwtResp> {
return request<SystemRotateJwtResp>("/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<SystemBackupResp> {
return request<SystemBackupResp>("/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<SystemRestoreResp> {
return request<SystemRestoreResp>("/admin/system/restore", {
method: "POST",
body: JSON.stringify({ path }),
});
}
export {
adminBackthink,
adminListNotifications,
adminObservabilityAudit,
adminObservabilityMetrics,
adminObservabilityTrace,
adminSystemBackup,
adminSystemRestore,
adminSystemRevokeKeys,
adminSystemRotateJWT,
} from "./api/adminSystem";
+243
View File
@@ -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<PersonaRecord[]> {
return request<PersonaRecord[]>("/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<SessionMeta[]> {
return request<SessionMeta[]>("/admin/sessions");
}
/** GET /api/v1/admin/memories */
export function adminListMemories(): Promise<MemoryRecord[]> {
return request<MemoryRecord[]>("/admin/memories");
}
/** GET /api/v1/admin/plugins */
export function adminListPlugins(): Promise<AdminPluginRecord[]> {
return request<AdminPluginRecord[]>("/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<InstalledPlugin[]> {
return request<InstalledPlugin[]>("/admin/plugins/installed");
}
/** POST /api/v1/admin/plugins/policy — owner-only, audited. */
export function adminSetPluginPolicy(
name: string,
allowInGroup: boolean,
allowInStranger: boolean
): Promise<AdminPluginRecord> {
return request<AdminPluginRecord>("/admin/plugins/policy", {
method: "POST",
body: JSON.stringify({ name, allowInGroup, allowInStranger }),
});
}
/** GET /api/v1/admin/channels */
export function adminListChannels(): Promise<AdminChannelRecord[]> {
return request<AdminChannelRecord[]>("/admin/channels");
}
/** GET /api/v1/admin/iot */
export function adminListIoT(): Promise<AdminIoTDevice[]> {
return request<AdminIoTDevice[]>("/admin/iot");
}
// --- Planned M10 endpoints (contract-shaped, backend may 404). ---
/** GET /api/v1/admin/users */
export function adminListUsers(): Promise<AdminUserRecord[]> {
return request<AdminUserRecord[]>("/admin/users");
}
/** GET /api/v1/admin/llm */
export function adminGetLlm(): Promise<AdminLlmConfig> {
return request<AdminLlmConfig>("/admin/llm");
}
/** GET /api/v1/admin/automations */
export function adminListAutomations(): Promise<AdminAutomationRecord[]> {
return request<AdminAutomationRecord[]>("/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<AdminRagResponse> {
const qs = query ? `?query=${encodeURIComponent(query)}` : "";
return request<AdminRagResponse>(`/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<PersonaRecord> {
return request<PersonaRecord>("/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<PersonaRecord> {
return request<PersonaRecord>(`/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<AdminUserRecord> {
return request<AdminUserRecord>("/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<ApiKeyIssueResult> {
return request<ApiKeyIssueResult>("/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<string, unknown>): Promise<AdminIoTDevice> {
return request<AdminIoTDevice>(`/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<AutomationCreateResult> {
return request<AutomationCreateResult>("/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<AutomationRunResult> {
return request<AutomationRunResult>(`/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<LlmTestResult> {
return request<LlmTestResult>("/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<PluginInstallResult> {
return request<PluginInstallResult>("/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<MemorySearchResult> {
return request<MemorySearchResult>("/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<MemoryAddResult> {
return request<MemoryAddResult>("/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`);
}
+152
View File
@@ -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<ChannelTestResult> {
return request<ChannelTestResult>("/admin/channels/test", { method: "POST", body: JSON.stringify(req) });
}
/** GET /api/v1/admin/channels/messages?adapter=<composite> — one adapter config's
* (platform:account) send/receive message log, newest first. */
export function adminChannelMessages(adapter: string, limit?: number): Promise<AdminChannelMessage[]> {
const q = new URLSearchParams({ adapter });
if (limit && limit > 0) q.set("limit", String(limit));
return request<AdminChannelMessage[]>(`/admin/channels/messages?${q.toString()}`);
}
/** GET /api/v1/admin/channels/messages — adapter overview log across all adapters. */
export function adminChannelLogOverview(limit?: number): Promise<AdminChannelMessage[]> {
const q = limit && limit > 0 ? `?limit=${limit}` : "";
return request<AdminChannelMessage[]>(`/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<string, unknown>;
allowGroups?: string[];
denyGroups?: string[];
allowUsers?: string[];
denyUsers?: string[];
groupMsgPerMin?: number;
msgPerMin?: number;
maxMessages?: number;
},
): Promise<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/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<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/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<KookViewResponse> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<KookViewResponse>(`/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<KookFriendList> {
const q = new URLSearchParams();
if (type) q.set("type", type);
if (accountId) q.set("accountId", accountId);
const qs = q.toString();
return request<KookFriendList>(`/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<KookMessageTestResult> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<KookMessageTestResult>(`/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<WeixinLoginQRResponse> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<WeixinLoginQRResponse>(
`/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<WeixinLoginStatusResponse> {
const q = accountId ? `?accountId=${encodeURIComponent(accountId)}` : "";
return request<WeixinLoginStatusResponse>(
`/admin/channels/${encodeURIComponent(platform)}/login-status${q}`,
);
}
+79
View File
@@ -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<AdminObservabilityMetrics> {
return request<AdminObservabilityMetrics>("/admin/observability/metrics");
}
/** GET /api/v1/admin/observability/trace?limit=N — most recent traced spans. */
export function adminObservabilityTrace(limit?: number): Promise<AdminTraceSpan[]> {
return request<AdminTraceSpan[]>(`/admin/observability/trace${limitQuery(limit)}`);
}
/** GET /api/v1/admin/observability/audit?limit=N — most recent audit entries. */
export function adminObservabilityAudit(limit?: number): Promise<AuditRecord[]> {
return request<AuditRecord[]>(`/admin/observability/audit${limitQuery(limit)}`);
}
/** GET /api/v1/admin/backthink — persisted background-think records (newest first). */
export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
return request<BackthinkItem[]>(`/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<AdminNotification[]> {
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<AdminNotification[]>(`/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<SystemRevokeKeysResp> {
return request<SystemRevokeKeysResp>("/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<SystemRotateJwtResp> {
return request<SystemRotateJwtResp>("/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<SystemBackupResp> {
return request<SystemBackupResp>("/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<SystemRestoreResp> {
return request<SystemRestoreResp>("/admin/system/restore", {
method: "POST",
body: JSON.stringify({ path }),
});
}
+19
View File
@@ -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<LoginResult> {
return request<LoginResult>(
"/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");
}
+41
View File
@@ -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<ChatSyncResult> {
return request<ChatSyncResult>("/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 } : {}),
}),
});
}
+177
View File
@@ -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<string, unknown>;
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<AuthExpiredHandler>();
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<T>(
path: string,
init: RequestInit = {},
opts: { skipAuthExpired?: boolean } = {}
): Promise<T> {
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;
}
+7
View File
@@ -0,0 +1,7 @@
import type { PersonaRecord } from "../types";
import { request } from "./core";
/** GET /api/v1/personas — persona summaries. */
export function fetchPersonas(): Promise<PersonaRecord[]> {
return request<PersonaRecord[]>("/personas");
}
+86
View File
@@ -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<SessionMeta[]> {
return request<SessionMeta[]>("/sessions");
}
/** POST /api/v1/sessions — creates a new secondary session. */
export function createSubSession(title?: string): Promise<SessionMeta> {
return request<SessionMeta>("/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<ChatMessage[]> {
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<MessageDto[]>(
`/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" }
);
}