- 时间轴:页首「已过保留期」的**唯一**判据改为 retentionBoundaryReached === true(truncated 只出
「未显示」);缺席(老后端)一句话都不说;删掉旧的 truncated+retentionEnabled+翻到底 启发式
- Backthink:筛选轴接上服务端 ?trigger=(客户端筛选保留作兜底)+ 自排闸用量行(三态文案)
- plugin/nav.ts 加 browser 图标
- 真机(隔离实例,46/46):翻页不重不漏、三种「必须 false」的权威键语义、**重启后 round 不重复**
⚠️ 未验:真机上没构造出权威键 true(需真有更早轮次被清扫)——别读成「真机验过 true」
324 lines
18 KiB
TypeScript
324 lines
18 KiB
TypeScript
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<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/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<AdminLogLine[]> {
|
||
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<AdminLogLine[]>(`/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<BackthinkItem[]> {
|
||
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<BackthinkItem[]>(`/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<AdminSelfWakeStatus> {
|
||
return request<AdminSelfWakeStatus>("/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<AdminFlowGraphResponse> {
|
||
const qs = new URLSearchParams();
|
||
if (sessionId) qs.set("sessionId", sessionId);
|
||
if (turnId) qs.set("turnId", turnId);
|
||
const q = qs.toString();
|
||
return request<AdminFlowGraphResponse>(`/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<AdminFlowTimelineResponse> {
|
||
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<AdminFlowTimelineResponse>(`/admin/flow/timeline${q ? `?${q}` : ""}`);
|
||
}
|
||
|
||
/** GET /api/v1/admin/config-facts — **只读**配置事实面(契约 v0.68 §4.10(a)):
|
||
* 记忆整理 7 键 + 安静时段两键的**进程生效值** + 声明默认值,外加安静时段窗口的
|
||
* 解释(跨午夜 / 已关闭)。owner/admin 档;**不含任何凭据值**(后端有哨兵测试钉死),
|
||
* 网关也**不读 `.env`** —— 值就是运行实例真正在用的数。 */
|
||
export function adminConfigFacts(): Promise<AdminConfigFacts> {
|
||
return request<AdminConfigFacts>("/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<AdminTidyDryRunResult> {
|
||
return request<AdminTidyDryRunResult>("/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<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.
|
||
* -------------------------------------------------------------------------- */
|
||
|
||
/** 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<AdminStorageStatus> {
|
||
return request<AdminStorageStatus>("/admin/system/storage");
|
||
}
|
||
|
||
/** 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 }),
|
||
});
|
||
}
|