import type { AdminLogLine, AdminLogQuery, AdminNotification, AdminNotificationQuery, AdminObservabilityMetrics, AdminStorageStatus, AdminTraceSpan, AuditRecord, AdminConfigFacts, AdminTidyDryRunResult, BackthinkItem, FlowGraph, SystemBackupResp, SystemRestoreResp, SystemRevokeKeysResp, SystemRotateJwtResp, } from "../types"; import { limitQuery, request } from "./core"; /** GET /api/v1/admin/observability/metrics — gateway request metrics. */ export function adminObservabilityMetrics(): Promise { return request("/admin/observability/metrics"); } /** GET /api/v1/admin/observability/trace?limit=N — most recent traced spans. */ export function adminObservabilityTrace(limit?: number): Promise { return request(`/admin/observability/trace${limitQuery(limit)}`); } /** GET /api/v1/admin/observability/audit?limit=N — most recent audit entries. */ export function adminObservabilityAudit(limit?: number): Promise { return request(`/admin/observability/audit${limitQuery(limit)}`); } /** GET /api/v1/admin/observability/logs?level=&limit= — the gateway's structured * log tail, newest first (owner/admin only). An omitted `level` means “all”; the * backend defaults `limit` to 200 and caps it at 1000. */ export function adminObservabilityLogs( params: AdminLogQuery = {} ): Promise { const qs = new URLSearchParams(); if (params.level) qs.set("level", params.level); if (params.limit !== undefined) qs.set("limit", String(params.limit)); const q = qs.toString(); return request(`/admin/observability/logs${q ? `?${q}` : ""}`); } /** GET /api/v1/admin/backthink?limit=&trigger= — persisted background-think records * (newest first). * * `trigger` 是**服务端**筛选(契约 v0.82,切片 C2;`design/12 §C.6`):取值域 = * 既有触发源 + `self_wake`(`heartbeat` / `startup` / `self_wake` / 事件主题 * `iot.device_update` / `reminder.trigger` / `system.event`)。⚠️ 后端对白名单外的 * 取值**如实 400**(`error.code = "invalid_trigger"`),**不静默忽略** —— 因此调用方 * 只能传自己确实认识的值;省略 = 不过滤(返回全部,老行为逐字不变)。 * * ⚠️ 历史拉取的筛选结果**不含**实时帧:WS 的 `backthink` 事件始终是全量的, * 面板必须**保留客户端筛选**兜住那一部分(见 Backthink.tsx)。 */ export function adminBackthink(limit?: number, trigger?: string): Promise { const qs = new URLSearchParams(); if (limit && limit > 0) qs.set("limit", String(limit)); if (trigger) qs.set("trigger", trigger); const q = qs.toString(); return request(`/admin/backthink${q ? `?${q}` : ""}`); } /** 自排闸的**进程生效**口径与代码默认值(契约 v0.82 §4.10(e))。 * * ⚠️ `limitIsDefault` / `windowIsDefault` 只表示「生效值**恰好等于**代码默认值」, * **不是**「`backthink.json` 里没写这个键」的断言(网关不读那个文件)。 */ export interface AdminSelfWakeGate { /** 容量 1 的待跑槽是否被占用(有一轮自排排着队还没跑)。 */ pending: boolean; /** 本窗口已用额度(与工具收据的 `used` 同一口径)。 */ used: number; limit: number; windowSec: number; defaultLimit: number; defaultWindowSec: number; limitIsDefault: boolean; windowIsDefault: boolean; } /** GET /api/v1/admin/backthink/self-wake 的响应(契约 v0.82 新增;只读、不审计, * owner/admin 档)。 * * 三态**必须分开显示**(把「看不到」画成「已用 0 / 上限 0」就是把猜当事实): * - `available:false` ⇒ 本进程给不出快照(服务未接线 / 实现不提供读口 / 闸不在): * `gate` **缺席**、`reason` 说明原因; * - `available:true, running:false` ⇒ 已接线但不在运行(未启动或已停止):`gate` * 仍带配置面数字(上限/窗口/默认值),但此刻排不出新的一轮 —— `reason` 说明; * - `available:true, running:true` ⇒ 正常态。 * * ⚠️ 它**不是写口**:调这两个旋钮要改 `$DATA_DIR/config/backthink.json` + 重启 * (启动期读一次);`configFile` 只是相对落点(`config/backthink.json`)。 */ export interface AdminSelfWakeStatus { available: boolean; running: boolean; reason?: string; configFile?: string; gate?: AdminSelfWakeGate; } /** GET /api/v1/admin/backthink/self-wake — 自排闸的只读快照(本窗口已用 N / 上限 M)。 * * 为什么有它:这个数字此前只出现在被拒那一轮的审计文案里(`backthink.self_wake.capped` * 的 result),也就是「已经被拒过一次」才第一次看得见。它是纯读:不写库、不落审计、 * 读它不改变闸的状态。 */ export function adminSelfWakeStatus(): Promise { return request("/admin/backthink/self-wake"); } /** 一条**已落盘轮次**的摘要(契约 v0.73 §4.11 的 `turns[]`)。面板用它渲染历史轮 * 选择器:把 `turnId` 回带给 `adminFlowGraph` 即可精确取回那一轮。 */ export interface AdminFlowTurn { turnId: string; createdAt: string; } /** 链路图端点的后端响应(契约 v0.71 起;**v0.73 新增 `turnId`/`source`/`turns`**)。 * * `graph` **恒在**(`null` 而不是缺席):`null` = 该轮**确实**没有图,客户端必须据此 * 渲染空态,绝不允许补画任何节点或假图。 * * `source` 告诉客户端这张图是哪来的:`live` = 内存里最近一轮(实时图);`persisted` = * 从本地库读回的历史轮;`none` = 没有图。`turns` 是该会话已落盘的轮次摘要(最新在前), * 没有时为 `[]`(后端保证非 null 数组)。 */ export interface AdminFlowGraphResponse { sessionId: string; turnId: string; source: "live" | "persisted" | "none"; graph: FlowGraph | null; /** 最近一轮**每一路**的图(契约 v0.77,纯增量;真并行 `chat.parallel` 时不止一条)。 * `graph` 只是其中最新的一条(向后兼容),`lanes` 才是全貌 —— 面板据此把两路并列 * 画出来,而不是让后起的那一路把前一路顶掉。没有图时是 `[]`(后端保证非 null)。 */ lanes: AdminFlowLane[]; turns: AdminFlowTurn[]; } /** `lanes[]` 里的一条路:`turnId` 是该路的权威回合标识(design/15 §2.5.9), * `graph` 与响应的 `graph` / 帧的 data 逐字同形。 */ export interface AdminFlowLane { turnId: string; graph: FlowGraph | null; } /** GET /api/v1/admin/flow/graph?sessionId=&turnId= — **链路图快照 + 多轮历史回放**(契约 * v0.71 起,v0.73 增量;只读、不审计,owner/admin 档)。 * * 为什么需要它:`graph_update` 是**纯实时**帧、从不重放。v0.71 补了「打开页面拉最近一轮 * 快照」;v0.73 把图**每轮落盘**,于是可以: * - **省略 `turnId`** ⇒ 最近一轮(后端**先查内存、miss 才落盘** ⇒ 最新一轮永远最新); * - **给了 `turnId`** ⇒ 精确取该会话已落盘的某一轮(历史回放)。 * * `turns`(同一响应里)就是给选择器用的历史轮列表。⚠️ 白名单之外的会话(群聊/陌生人) * 从不建图、也从不落盘 ⇒ 它们**永远**是空态,这不是缺陷。 */ export function adminFlowGraph(sessionId: string, turnId?: string): Promise { const qs = new URLSearchParams(); if (sessionId) qs.set("sessionId", sessionId); if (turnId) qs.set("turnId", turnId); const q = qs.toString(); return request(`/admin/flow/graph${q ? `?${q}` : ""}`); } /** 时间轴里的**一个回合**(`design/19 §7`;形状取自设计规格 —— W1 的出口)。 */ export interface AdminFlowTimelineRound { /** 该回合的权威标识。🔴 **一律用后端给的值**:面板既不自己拼 `{会话}-{序号}`, * 也不从 `graph.id` 反解(契约 v0.79 起 `graph.id` 含 `#` 且单路也有后缀)。 */ turnId: string; /** 会话轮次号。**可能缺席 / `null`**(旧行、或后端尚未落这一列)⇒ 面板显示 * 「轮次未知」,**绝不用 0 冒充**。 */ round?: number | null; /** 该回合的开始时间(RFC3339)。它是**时间轴排序与 `before` 游标的唯一依据**。 */ startedAt: string; /** 收尾时间。缺席 / `null` = 该回合**未收尾**(不许读成「已完成」)。 */ endedAt?: string | null; /** 该回合**每一路**的图(真并行时不止一条;`graph` 为 `null` = 那一路没有留下图)。 */ lanes: AdminFlowLane[]; } /** 链路**时间轴**端点的响应(契约 v0.80 §4.11.1;v0.81 增量)。 * * 🔴 **`truncated` 不等于「已过保留期」**(契约 v0.81 §4.11.1(a) 的定稿口径):它的含义 * 是 **「本次不是全部」** = `hasMore`(分页未到底)**或** 落盘源未接线(只剩内存最近一轮)。 * 「更早的回合已过保留期」这句话的判据**只能是** `retentionBoundaryReached === true` * ——🔴 **不得**再拿 `truncated` + `retentionEnabled` + 「翻到底了没」三者自己拼(那正是 * v0.81 修掉的缺陷:一页还没拉完时面板会对着**仍然可翻**的历史说「已过保留期」)。 */ export interface AdminFlowTimelineResponse { /** 解析后的会话(回显)。取数只认调用时给的 `sessionId`,不依赖这一键。 */ sessionId?: string; /** 本页**至少一轮**来自落盘 `persisted` / 本页**全部**来自内存 `live`(落盘源未接线, * 或该会话还没有落盘轮)/ `none`(没有任何回合)。 */ source?: "persisted" | "live" | "none"; /** 保留期内的回合。后端按**时间正序**(最旧在前)返回;面板渲染前自己归一排序。 */ rounds: AdminFlowTimelineRound[]; /** 本页**最早一轮**的开始时刻;`hasMore` 为真时**原样回带**成下一次请求的 `before` * 即可连续向更早翻页(游标精度到毫秒)。没有回合时是空串。 */ nextBefore?: string; /** 库里**还有更早的回合**没被本次返回(= 还能继续翻页)。**这是唯一权威的翻页判据**; * 面板不要用「本页是否满页」去猜(满页的最后一页会被猜成「还有更早」,白打一次请求)。 */ hasMore?: boolean; /** 本次**不是全部**:`hasMore`(分页未到底)或落盘源未接线。**不是**「已过保留期」 * —— 面板据此标注「更早的轮次**未显示**」,说「已过保留期」请看下一行。 */ truncated: boolean; /** 🆕 **v0.81**(契约 §4.11.1(a)):**该会话确有更早的轮次已被保留期清扫删掉**, * 且本次已取到它现存最早的一端。**只有它为 `true` 时**才可以说「更早的轮次已过保留期」, * 其余情形(含它 `false`)一律说「更早的轮次未显示」(`truncated`)或「只拿到最近一轮」 * (`source:"live"`)。 * * ⚠️ 缺席(老后端)= **不知道** —— 一律按最保守话术处理(不说过期),**不**拿别的键回推。 * ⚠️ 它**少说也不说谎**:`false` **不等于**「没被清扫」(整段历史都被清扫 / 有轮次号未知 * 的老行 / 最早那一轮落盘失败,三种盲区见契约 §4.11.1(a) 表)。 */ retentionBoundaryReached?: boolean; /** `flow_graphs` 的保留期(小时,**下取整**)。`≤0` = 后端配置为不回收。 */ retentionHours: number; /** 保留期清扫**是否启用**。`false` ⇒ 「更早的回合」**不是**被清扫掉的,别说成过期。 * ⚠️ 它只说明「清扫开着」,**不说明「发生过清扫」**(判据见 `retentionBoundaryReached`)。 */ retentionEnabled?: boolean; } /** GET /api/v1/admin/flow/timeline?sessionId=&limit=&before= — **多轮时间轴**(`design/19 §7`; * 只读、不审计,owner/admin 档)。它是老出口 `adminFlowGraph`(最近一轮 + 历史轮列表)的 * **增量兄弟**:一次给**多轮**,每轮连同它**每一路**的图,供面板画「所有消息按时间顺序串 * 起来」的时间轴。 * * 分页(契约 §4.11.1(a)):`limit` 缺省 50(后端硬上限 200);`before` 是**开始时间游标** * —— 「开始时刻**严格早于**它」的回合才返回(省略 = 最新那一页)。游标取响应里的 * `nextBefore`(= 本页最早一轮的开始时刻)**原样回带**即可连续翻页,**不重不漏**。 * ⚠️ 本函数**不解释** `before`(不做字符串/毫秒换算),只原样转交:面板的游标就是后端 * 给的时间字符串。⚠️ **也不要**用「本页是否满页」代替 `hasMore`:满页的最后一页会被误 * 判成「还有更早的」,白打一次请求(`hasMore` 才是权威判据)。 * * 老后端(该出口未接线)⇒ `404`;调用方按「当前只支持最近一轮」诚实降级,别把 404 读成 * 「这段时间没有回合」。 */ export function adminFlowTimeline( sessionId: string, opts: { limit?: number; before?: string } = {} ): Promise { const qs = new URLSearchParams(); if (sessionId) qs.set("sessionId", sessionId); if (opts.limit && opts.limit > 0) qs.set("limit", String(opts.limit)); if (opts.before) qs.set("before", opts.before); const q = qs.toString(); return request(`/admin/flow/timeline${q ? `?${q}` : ""}`); } /** GET /api/v1/admin/config-facts — **只读**配置事实面(契约 v0.68 §4.10(a)): * 记忆整理 7 键 + 安静时段两键的**进程生效值** + 声明默认值,外加安静时段窗口的 * 解释(跨午夜 / 已关闭)。owner/admin 档;**不含任何凭据值**(后端有哨兵测试钉死), * 网关也**不读 `.env`** —— 值就是运行实例真正在用的数。 */ export function adminConfigFacts(): Promise { return request("/admin/config-facts"); } /** POST /api/v1/admin/memories/tidy/dry-run — 记忆整理「试算」(契约 v0.75 §4.12)。 * * 它跑**同一套**整理规则(与低峰定时轮共用代码路径)并把候选直接回在响应里, * 但**一行都不写**(不写候选表/运行表、不改任何记忆)—— 因此可以在任何时刻、 * 在整理器第一次开跑之前,先看清「它会建议合并哪些」。 * * ⚠️ 它不是看板数据源:试算是一次真实的规则计算(扫描 + 两两召回 + 包含判定), * **只在用户点击时调用,不要轮询**。 * ⚠️ 失败走 `error.code`(tidy_unavailable / tidy_disabled / tidy_recall_unavailable / * tidy_timeout),**不会**回 `candidates: []` —— 空数组只表示「算过了,没有建议」。 */ export function adminMemoryTidyDryRun(): Promise { return request("/admin/memories/tidy/dry-run", { method: "POST" }); } /** GET /api/v1/admin/notifications?type=&before=&limit= — the unified notification * feed (design/10 §3 #16: audit + channel + backthink + system aggregated). * `type` omitted = all types, `before` = createdAt(ms) cursor for older pages, * `limit` defaults to 50 (backend caps at 200). Returned newest first. */ export function adminListNotifications( params: AdminNotificationQuery = {} ): Promise { const qs = new URLSearchParams(); if (params.type) qs.set("type", params.type); if (params.before !== undefined) qs.set("before", String(params.before)); if (params.limit !== undefined) qs.set("limit", String(params.limit)); const q = qs.toString(); return request(`/admin/notifications${q ? `?${q}` : ""}`); } /* ---------------------------------------------------------------------------- * System management admin writes (owner-only, audited). Reference: * internal/services/gateway/admin_system.go + Docs/api-client.md §系统设置契约. * All four are implemented (not 501) — they return real data. * -------------------------------------------------------------------------- */ /** GET /api/v1/admin/system/storage — the **read-only** storage snapshot behind the * dashboard's 存储 card (v0.60): SQLite file/WAL/page stats, the documents table's * count+bytes, and whether the optional pgvector enhancement is configured and * actually live. The `vector` block never carries credentials (no DSN, no password). * owner-only; the backend answers `500 storage_unavailable` rather than a fake * all-zero snapshot when the live DB handle is not wired. */ export function adminSystemStorage(): Promise { return request("/admin/system/storage"); } /** POST /api/v1/admin/system/revoke-keys — revokes every registered API key. */ export function adminSystemRevokeKeys(): Promise { return request("/admin/system/revoke-keys", { method: "POST" }); } /** POST /api/v1/admin/system/rotate-jwt — swaps the JWT signing secret (≥32 chars). * The secret is provided by the owner and never echoed back; all existing tokens * become invalid, so the client must re-login afterward. */ export function adminSystemRotateJWT(newSecret: string): Promise { return request("/admin/system/rotate-jwt", { method: "POST", body: JSON.stringify({ newSecret }), }); } /** POST /api/v1/admin/system/backup — snapshots the SQLite DB (VACUUM INTO, incl. WAL). */ export function adminSystemBackup(): Promise { return request("/admin/system/backup", { method: "POST" }); } /** POST /api/v1/admin/system/restore — restores a data-dir backup; path must live * inside the data directory (path-traversal guard). The backend pre-backups the * current DB and reports that the swap needs a restart. */ export function adminSystemRestore(path: string): Promise { return request("/admin/system/restore", { method: "POST", body: JSON.stringify({ path }), }); }