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
|
* Public REST surface for the Cyrene v2 gateway, kept as a single import path
|
||||||
* `{data}` and every failure in `{error:{code,message}}` (Docs/api-client.md).
|
* (`lib/api`) so call sites never change. The implementation is split by domain
|
||||||
* We use same-origin relative paths so the same code works behind the Vite dev
|
* under `lib/api/`:
|
||||||
* proxy and inside the single-binary gateway on :8080.
|
*
|
||||||
|
* 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";
|
export {
|
||||||
const TOKEN_KEY = "cyrene.jwt";
|
ApiError,
|
||||||
const ROLE_KEY = "cyrene.role";
|
decodeJwtRole,
|
||||||
const API_KEY_KEY = "cyrene.apikey";
|
getApiKey,
|
||||||
|
getAuthToken,
|
||||||
|
getStoredRole,
|
||||||
|
isNotImplemented,
|
||||||
|
isTokenExpired,
|
||||||
|
onAuthExpired,
|
||||||
|
setApiKey,
|
||||||
|
setAuthToken,
|
||||||
|
setStoredRole,
|
||||||
|
} from "./api/core";
|
||||||
|
|
||||||
export function getAuthToken(): string | null {
|
export { health, login } from "./api/auth";
|
||||||
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).
|
export { chatStream, chatSync } from "./api/chat";
|
||||||
* 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
|
export {
|
||||||
* decoding the token so a session created before role persistence still works. */
|
createSubSession,
|
||||||
export function getStoredRole(): Role | null {
|
deleteSession,
|
||||||
const r = localStorage.getItem(ROLE_KEY);
|
fetchMessages,
|
||||||
if (r === "owner" || r === "admin" || r === "viewer") return r;
|
fetchSessions,
|
||||||
const token = getAuthToken();
|
} from "./api/sessions";
|
||||||
return token ? decodeJwtRole(token) : null;
|
export type { MessageDto } from "./api/sessions";
|
||||||
}
|
|
||||||
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
|
export { fetchPersonas } from "./api/personas";
|
||||||
* 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
|
export {
|
||||||
* when a non-login request comes back 401/unauthorized, after already clearing
|
adminAddLlmProvider,
|
||||||
* the stored token; the WS layer subscribes to stop reconnecting + flag the UI. */
|
adminAddMemory,
|
||||||
type AuthExpiredHandler = () => void;
|
adminCreateAutomation,
|
||||||
const authExpiredHandlers = new Set<AuthExpiredHandler>();
|
adminCreatePersona,
|
||||||
export function onAuthExpired(handler: AuthExpiredHandler): () => void {
|
adminCreateUser,
|
||||||
authExpiredHandlers.add(handler);
|
adminDeleteAutomation,
|
||||||
return () => {
|
adminDeleteRag,
|
||||||
authExpiredHandlers.delete(handler);
|
adminExportSession,
|
||||||
};
|
adminGetLlm,
|
||||||
}
|
adminIndexRag,
|
||||||
function fireAuthExpired(): void {
|
adminIoTControl,
|
||||||
authExpiredHandlers.forEach((h) => h());
|
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 {
|
||||||
export function getApiKey(): string | null {
|
adminChannelClose,
|
||||||
return localStorage.getItem(API_KEY_KEY);
|
adminChannelFriendHandle,
|
||||||
}
|
adminChannelLogOverview,
|
||||||
export function setApiKey(key: string | null): void {
|
adminChannelMessageTest,
|
||||||
if (key) localStorage.setItem(API_KEY_KEY, key);
|
adminChannelMessages,
|
||||||
else localStorage.removeItem(API_KEY_KEY);
|
adminChannelOpen,
|
||||||
}
|
adminChannelPlatformFriends,
|
||||||
|
adminChannelPlatformView,
|
||||||
|
adminChannelTest,
|
||||||
|
adminDeleteChannelAccount,
|
||||||
|
adminSaveChannelConfig,
|
||||||
|
adminWeixinLoginQR,
|
||||||
|
adminWeixinLoginStatus,
|
||||||
|
} from "./api/adminChannels";
|
||||||
|
export type { ChannelConfigSaveResult } from "./api/adminChannels";
|
||||||
|
|
||||||
interface Envelope {
|
export {
|
||||||
data?: unknown;
|
adminBackthink,
|
||||||
error?: ApiErrorShape;
|
adminListNotifications,
|
||||||
}
|
adminObservabilityAudit,
|
||||||
|
adminObservabilityMetrics,
|
||||||
/** Result of saving/deleting a channel account config (persisted to channels.json). */
|
adminObservabilityTrace,
|
||||||
export interface ChannelConfigSaveResult {
|
adminSystemBackup,
|
||||||
persisted: boolean;
|
adminSystemRestore,
|
||||||
path: string;
|
adminSystemRevokeKeys,
|
||||||
platform: string;
|
adminSystemRotateJWT,
|
||||||
accountId: string;
|
} from "./api/adminSystem";
|
||||||
}
|
|
||||||
|
|
||||||
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 }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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