后台思考页此前只读 /ws/chat 的 backthink/thinking WS 实时事件(后台任务完成才有),DB里 270 条 backthink_records 看不到。新增 adminBackthink() 拉 GET /api/v1/admin/backthink + useAutoReload(10s),与 WS 事件按 id 去重、按 at 倒序展示;空态/数据源说明更新。 验收: npm run build 99模块全绿
692 lines
26 KiB
TypeScript
692 lines
26 KiB
TypeScript
import type {
|
||
AdminAutomationRecord,
|
||
AdminChannelMessage,
|
||
AdminChannelRecord,
|
||
AdminIoTDevice,
|
||
AdminLlmConfig,
|
||
AdminLlmProvider,
|
||
AdminObservabilityMetrics,
|
||
AdminPluginRecord,
|
||
AdminRagHit,
|
||
AdminRagResponse,
|
||
AdminTraceSpan,
|
||
AdminUserRecord,
|
||
ApiErrorShape,
|
||
ApiKeyIssueResult,
|
||
AuditRecord,
|
||
BackthinkItem,
|
||
AutomationCreateResult,
|
||
AutomationRunResult,
|
||
ChannelTestResult,
|
||
ChatMessage,
|
||
ChatSyncResult,
|
||
LlmProviderRequest,
|
||
LlmTestRequest,
|
||
LlmTestResult,
|
||
LoginResult,
|
||
MemoryAddRequest,
|
||
MemoryAddResult,
|
||
MemoryRecord,
|
||
MemorySearchRequest,
|
||
MemorySearchResult,
|
||
Mode,
|
||
PersonaRecord,
|
||
PersonaUpsertRequest,
|
||
KookFriendList,
|
||
KookMessageTestResult,
|
||
KookViewResponse,
|
||
RagIndexRequest,
|
||
Role,
|
||
SessionMeta,
|
||
SessionRecord,
|
||
} from "./types";
|
||
import { getClientId } from "./client";
|
||
|
||
/**
|
||
* REST client for the Cyrene v2 gateway. The gateway wraps every success in
|
||
* `{data}` and every failure in `{error:{code,message}}` (Docs/api-client.md).
|
||
* We use same-origin relative paths so the same code works behind the Vite dev
|
||
* proxy and inside the single-binary gateway on :8080.
|
||
*/
|
||
export class ApiError extends Error {
|
||
readonly status: number;
|
||
readonly code?: string;
|
||
constructor(status: number, code: string | undefined, message: string) {
|
||
super(message);
|
||
this.name = "ApiError";
|
||
this.status = status;
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
const API_BASE = "/api/v1";
|
||
const TOKEN_KEY = "cyrene.jwt";
|
||
const ROLE_KEY = "cyrene.role";
|
||
const API_KEY_KEY = "cyrene.apikey";
|
||
|
||
export function getAuthToken(): string | null {
|
||
return localStorage.getItem(TOKEN_KEY);
|
||
}
|
||
export function setAuthToken(token: string | null): void {
|
||
if (token) {
|
||
localStorage.setItem(TOKEN_KEY, token);
|
||
setStoredRole(decodeJwtRole(token));
|
||
} else {
|
||
localStorage.removeItem(TOKEN_KEY);
|
||
setStoredRole(null);
|
||
}
|
||
}
|
||
|
||
/** Decode the RBAC role from a JWT payload (client-side only; never verified).
|
||
* base64url is converted to base64 before decoding to survive `-`/`_`. */
|
||
export function decodeJwtRole(token: string): Role | null {
|
||
const parts = token.split(".");
|
||
if (parts.length < 2) return null;
|
||
try {
|
||
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
||
const payload = JSON.parse(atob(padded)) as Record<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;
|
||
}
|
||
|
||
/** 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). */
|
||
export function chatStream(
|
||
sessionId: string,
|
||
text: string,
|
||
image?: string
|
||
): Promise<{ stream: true }> {
|
||
return request<{ stream: true }>("/chat", {
|
||
method: "POST",
|
||
body: JSON.stringify({ sessionId, text, image, stream: true, deviceId: getClientId() }),
|
||
});
|
||
}
|
||
|
||
/** One chat message as returned by GET /api/v1/sessions/{id}/messages. Mirrors
|
||
* the `chat_messages` row; `mode`/`mood`/`image`/`thinking` are optional. */
|
||
export interface MessageDto {
|
||
id: string;
|
||
sessionId: string;
|
||
role: "user" | "assistant" | "system";
|
||
content: string;
|
||
mode?: Mode;
|
||
mood?: string;
|
||
image?: string;
|
||
thinking?: string;
|
||
/** Source-platform tag (design/16 §4.4): "web" or an IM channel platform. */
|
||
platform?: string;
|
||
/** Concrete client/device that produced the message (design/16 §4.5). */
|
||
deviceId?: string;
|
||
/** Sender display name of a third-party message (design/16 §4.6). Blank for the owner. */
|
||
senderName?: string;
|
||
createdAt: number;
|
||
}
|
||
|
||
/** Map a wire message into a display ChatMessage (carrying its sessionId). */
|
||
function toChatMessage(m: MessageDto): ChatMessage {
|
||
return {
|
||
id: m.id,
|
||
sessionId: m.sessionId,
|
||
role: m.role,
|
||
content: m.content,
|
||
mode: m.mode,
|
||
mood: m.mood,
|
||
image: m.image,
|
||
thinking: m.thinking,
|
||
platform: m.platform,
|
||
deviceId: m.deviceId,
|
||
senderName: m.senderName,
|
||
createdAt: m.createdAt,
|
||
};
|
||
}
|
||
|
||
/** GET /api/v1/sessions — the main + secondary sessions for this web account. */
|
||
export function fetchSessions(): Promise<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");
|
||
}
|
||
/** 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 — deletes a document. The backend returns a
|
||
* clear 501 until M10 lands the write path (upload/chunk/embed/delete). */
|
||
export function adminDeleteRag(id: string): Promise<{ id: string }> {
|
||
return request<{ id: string }>("/admin/rag/delete", {
|
||
method: "POST",
|
||
body: JSON.stringify({ id }),
|
||
});
|
||
}
|
||
/** POST /api/v1/admin/rag — imports one knowledge document (chunk + index), so it
|
||
* becomes keyword/searchable in the project index. */
|
||
export function adminIndexRag(req: RagIndexRequest): Promise<{ name: string }> {
|
||
return request<{ name: string }>("/admin/rag", {
|
||
method: "POST",
|
||
body: JSON.stringify(req),
|
||
});
|
||
}
|
||
|
||
/* --- Admin write endpoints (owner-only, audited; real store or honest 501). --- */
|
||
|
||
/** POST /api/v1/admin/personas — registers a persona (persisted to YAML + registry). */
|
||
export function adminCreatePersona(req: PersonaUpsertRequest): Promise<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 }),
|
||
});
|
||
}
|
||
/** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */
|
||
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
|
||
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
|
||
}
|
||
/** POST /api/v1/admin/plugins/uninstall — removes a capability from the registry. */
|
||
export function adminPluginUninstall(req: { name: string }): Promise<{ name: string; uninstalled: boolean }> {
|
||
return request<{ name: string; uninstalled: boolean }>("/admin/plugins/uninstall", {
|
||
method: "POST",
|
||
body: JSON.stringify(req),
|
||
});
|
||
}
|
||
/** POST /api/v1/admin/memories/search — real retrieval over the memory store. */
|
||
export function adminMemorySearch(req: MemorySearchRequest): Promise<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)}`);
|
||
}
|