refactor(web): 拆 lib/api.ts 789→109 barrel + lib/api/ 8域文件(纯搬移零逻辑)
- 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:
1 parent
153a9d6211
commit
4c3c98d5d4
9 files changed
+903
-779
No files matched your search
+99
-779
@@ -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";
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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" }
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user