feat(admin): 记忆整理候选的应用/回滚操作面(切片 TIDY-FE)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人报障「面板上点不到『应用』」→ 后端 TIDY-1 的五条端点(契约 v0.83 B20–B24)
此前只能 curl 驱动。本片补齐界面:候选表(动作/状态/摘要/理由/时间 + 行内
应用/回滚,回滚仅在 status=applied 时可点)、status 筛选、批量应用全部待定、
手动真跑一轮(两个写动作都走二次确认)。

三条契约硬口径落到界面:
- 幂等不是错误:skipped 用中性色,绝不渲染成失败;
- 超限必须如实:truncated=true 时黄牌「还有 N 条待定未处理」,且该分支绝不
  渲染「已全部处理」;未处理量只由后端权威字段算得(total - 已处理三项);
- 权威键只读:状态与计数全部取自响应,不做「应该成功了」的推断。

一个刻意的偏离(有测试钉住):截断提示不用共用 Notice tone=warn —— 那个组件
的固定前缀是「待实现」,贴在「还有 N 条未处理」上会被读成「功能还没做」。

验收(主会话复跑 + 亲自变异):tsc --noEmit 0 错;vite build 通过;
渲染测试 43 checks、传输层测试 28 checks 全绿;我把 truncated 判断取反 ⇒
渲染测试红在「截断时必须显式点明命中数大于本轮处理数」,还原后 md5 一致且绿。

⚠️ 未跑真机浏览器交互(需 owner 凭据 + 真后端),证据止于渲染层与传输层。
This commit is contained in:
AskaEth committed 2026-10-07 09:58:21 +08:00
1 parent aa0bf8b5a0
commit c512609c63
9 files changed
+1370

No files matched your search

+14
View File
@@ -12,6 +12,7 @@
* 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/*
* adminMemoryTidyApply.ts /admin/memories/candidates|tidy/run (整理应用/回滚;TIDY-1)
*
* Everything below is a verbatim re-export — behaviour is unchanged.
*/
@@ -141,3 +142,16 @@ export type {
AdminSelfWakeGate,
AdminSelfWakeStatus,
} from "./api/adminSystem";
export {
TIDY_APPLY_STATUS_APPLIED,
TIDY_APPLY_STATUS_FAILED,
TIDY_APPLY_STATUS_SKIPPED,
TIDY_STATUS_APPLIED,
TIDY_STATUS_PENDING,
adminTidyCandidateApply,
adminTidyCandidateRevert,
adminTidyCandidates,
adminTidyCandidatesApplyBatch,
adminTidyRun,
} from "./api/adminMemoryTidyApply";
@@ -0,0 +1,210 @@
// 记忆整理**应用路径** api 客户端的测试(切片 TIDY-1)。
//
// 为什么需要它:这张卡的价值全在「点得到且说真话」,而最容易走样的是**传输层**的三件事 ——
// ① 五个端点的 URL / 方法形状(批量是 `…/candidates/apply`,不是 `…/candidates/{id}/apply`);
// ② `{data}` 信封解包(回到调用方的必须是 data 本身,不能是信封);
// ③ **`status:"skipped"` 是 200、不是错误** —— 传输层不许把它抛成异常(界面才不会报红),
// 而真正的失败(tidy_unavailable / not_found / tidy_run_not_found)必须带着 `code` 抛出,
// **绝不**回一份空结果假装「没有候选」。
//
// 跑法(与 flowTimeline.test.ts / configFacts.test.ts 同手法;项目无前端测试运行器):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/lib/api/adminMemoryTidyApply.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-memory-tidy-apply-api.test.mjs \
// && node /tmp/cyrene-memory-tidy-apply-api.test.mjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import `node:*`(用 globalThis 取 fetch)。
import { ApiError } from "./core";
import {
adminTidyCandidateApply,
adminTidyCandidateRevert,
adminTidyCandidates,
adminTidyCandidatesApplyBatch,
adminTidyRun,
} from "./adminMemoryTidyApply";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
ok(got === want, `${what}(实际 ${JSON.stringify(got)},期望 ${JSON.stringify(want)})`);
}
type Call = { url: string; method: string };
const calls: Call[] = [];
/** api 客户端的 `request` 会读 `localStorage` 取 token / API key;node 下没有它。 */
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
} as unknown as Storage;
/** 2xx 桩:回 `{data: body}` 信封(真后端 writeJSON 就是这个形状)。 */
function stubOK(body: unknown): void {
globalThis.fetch = ((url: string, init?: { method?: string }) => {
calls.push({ url: String(url), method: init?.method ?? "GET" });
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: body }),
} as unknown as Response);
}) as typeof globalThis.fetch;
}
/** 非 2xx 桩:回 `{error:{code,message}}` 信封(真后端 writeError 的形状)。 */
function stubErr(status: number, code: string, message: string): void {
globalThis.fetch = ((url: string, init?: { method?: string }) => {
calls.push({ url: String(url), method: init?.method ?? "GET" });
return Promise.resolve({
ok: false,
status,
json: () => Promise.resolve({ error: { code, message } }),
} as unknown as Response);
}) as typeof globalThis.fetch;
}
/** 跑一次调用并把它抛出来的错误码取回来(没抛则回 "no-error")。 */
async function codeOf(task: () => Promise<unknown>): Promise<string> {
try {
await task();
return "no-error";
} catch (err: unknown) {
return err instanceof ApiError ? (err.code ?? "no-code") : "not-apierror";
}
}
// ── ① 候选列表:URL 形状 + `{data}` 解包 ────────────────────────────────────
const CANDIDATES_BODY = { candidates: [], count: 0 };
stubOK(CANDIDATES_BODY);
const list = await adminTidyCandidates({ status: "pending" });
same(calls[0].url, "/api/v1/admin/memories/candidates?status=pending", "候选列表 URL");
same(calls[0].method, "GET", "候选列表是只读 GET");
ok(list.candidates.length === 0 && list.count === 0, "解包 `{data}` 信封(回的是 data 本身)");
stubOK(CANDIDATES_BODY);
await adminTidyCandidates();
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates", "不给 status 时不带查询串");
// ── ② 单条应用 / 回滚:路径 + POST,且 **skipped 是 200 不是错误** ─────────
const SKIPPED = {
candidateId: "cand-1",
action: "link",
status: "skipped",
reason: "action_not_applyable",
affectedIds: [],
};
stubOK(SKIPPED);
const skipped = await adminTidyCandidateApply("cand-1");
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/cand-1/apply", "单条应用 URL");
same(calls[calls.length - 1].method, "POST", "应用是写动作 POST");
same(skipped.status, "skipped", "200 + skipped 正常解包(客户端不许把它抛成异常)");
same(skipped.reason, "action_not_applyable", "skipped 的机器可读原因要透传给调用方");
stubOK({ ...SKIPPED, status: "applied", affectedIds: ["mem-short"] });
const applied = await adminTidyCandidateApply("cand-1");
same(applied.status, "applied", "applied 回执按后端值透传");
same(applied.affectedIds.length, 1, "受影响行 id 要透传(回滚的抓手)");
stubOK({ candidateId: "cand-1", action: "merge", status: "applied", affectedIds: ["mem-short"] });
await adminTidyCandidateRevert("cand-1");
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/cand-1/revert", "回滚 URL");
same(calls[calls.length - 1].method, "POST", "回滚是写动作 POST");
// 候选 id 必须 URL 编码(不拼裸串)。
stubOK(SKIPPED);
await adminTidyCandidateApply("cand a/b");
same(
calls[calls.length - 1].url,
"/api/v1/admin/memories/candidates/cand%20a%2Fb/apply",
"候选 id 要 URL 编码"
);
// ── ③ 批量应用:`…/candidates/apply` + `truncated`/`total` 原样透传 ──────────
const BATCH = {
limit: 100,
total: 342,
truncated: true,
applied: 90,
skipped: 10,
failed: 0,
merged: 80,
archived: 10,
reclassified: 0,
results: [SKIPPED],
};
stubOK(BATCH);
const batch = await adminTidyCandidatesApplyBatch();
same(calls[calls.length - 1].url, "/api/v1/admin/memories/candidates/apply", "批量应用 URL(不带 id)");
same(calls[calls.length - 1].method, "POST", "批量应用是写动作 POST");
same(batch.total, 342, "total 是真待定总数,原样透传");
same(batch.truncated, true, "truncated 原样透传(界面据此说「还剩多少未处理」)");
stubOK({ ...BATCH, runId: "run-1", total: 5, truncated: false });
await adminTidyCandidatesApplyBatch({ runId: "run-1", limit: 50 });
same(
calls[calls.length - 1].url,
"/api/v1/admin/memories/candidates/apply?runId=run-1&limit=50",
"按轮次批量应用要带 runId 与 limit"
);
// ── ④ 手动真跑 ────────────────────────────────────────────────────────────
stubOK({
runId: "run-9",
trigger: "manual",
ruleVersion: "t0.1",
durationMs: 12,
partitions: 1,
scanned: 3,
compared: 1,
pairs: 1,
vetoed: 0,
errors: 0,
vetoReasons: {},
candidatesCreated: 2,
candidatesDuplicate: 0,
candidatesByAction: { merge: 2 },
});
const run = await adminTidyRun();
same(calls[calls.length - 1].url, "/api/v1/admin/memories/tidy/run", "手动真跑 URL");
same(calls[calls.length - 1].method, "POST", "手动真跑是写动作 POST");
same(run.candidatesCreated, 2, "真跑回执按后端值透传");
// ── ⑤ 诚实失败:错误码必须原样上抛(绝不回空结果) ──────────────────────────
stubErr(500, "tidy_unavailable", "记忆整理候选未接线");
same(
await codeOf(() => adminTidyCandidates({ status: "pending" })),
"tidy_unavailable",
"写面未接线 ⇒ 抛 tidy_unavailable(不是空候选列表)"
);
stubErr(404, "not_found", "整理候选不存在");
same(await codeOf(() => adminTidyCandidateApply("nope")), "not_found", "候选不存在 ⇒ not_found");
stubErr(404, "tidy_run_not_found", "整理运行不存在");
same(
await codeOf(() => adminTidyCandidatesApplyBatch({ runId: "nope" })),
"tidy_run_not_found",
"runId 查不到 ⇒ tidy_run_not_found(与「这一轮没有待定候选」是两件事)"
);
stubErr(409, "tidy_disabled", "记忆整理已被配置关闭");
same(await codeOf(() => adminTidyRun()), "tidy_disabled", "配置关闭 ⇒ 409 tidy_disabled");
stubErr(500, "tidy_run_unavailable", "记忆整理真跑入口未接线");
same(await codeOf(() => adminTidyRun()), "tidy_run_unavailable", "只接了试算 ⇒ 真跑回 unavailable");
stubErr(503, "tidy_timeout", "记忆整理真跑超时");
same(await codeOf(() => adminTidyRun()), "tidy_timeout", "超时 ⇒ 503 tidy_timeout");
stubErr(400, "bad_request", "invalid limit");
same(
await codeOf(() => adminTidyCandidates({ status: "pending", limit: -1 })),
"bad_request",
"非法 limit 由后端回 400(客户端只转发合法值)"
);
// eslint-disable-next-line no-console
console.log(`adminMemoryTidyApply: ${checks} checks passed`);
@@ -0,0 +1,108 @@
import type {
AdminTidyApplyBatch,
AdminTidyApplyResult,
AdminTidyCandidatesResponse,
AdminTidyRunResult,
} from "../types";
import { request } from "./core";
/* ----------------------------------------------------------------------------
* 记忆整理**应用路径**(切片 TIDY-1)。真源:
* internal/services/gateway/admin_memory_tidy_apply.go
* internal/services/gateway/admin_memory_tidy_run.go
*
* GET /admin/memories/candidates?status=&limit= 候选列表(含 id/runId)
* POST /admin/memories/candidates/{id}/apply 应用一条(真的改记忆)
* POST /admin/memories/candidates/apply?runId=&limit= 批量应用(省略 runId = 全部待定)
* POST /admin/memories/candidates/{id}/revert 回滚一条(行永不物理删除)
* POST /admin/memories/tidy/run 手动**真跑**一轮(写候选,不自动应用)
*
* 五个都是 `writeAuth`(owner|admin):候选正文含隐私,读侧也不给 viewer。
* ⚠️ 失败走 `error.code`(tidy_unavailable / tidy_disabled / tidy_run_unavailable /
* tidy_timeout / tidy_run_failed / not_found / tidy_run_not_found),**绝不**回一份
* 空结果假装「没有候选」。`status:"skipped"` 是 200 + 幂等命中,不是错误。
* -------------------------------------------------------------------------- */
/** 候选列表的路径(五条里有三条挂在它下面)。 */
const CANDIDATES_PATH = "/admin/memories/candidates";
/** 候选列表只认这两个状态(真源:tidy_store.go / tidy_apply_store.go 的字面量)。
* 因此面板只提供这两个筛选项 —— 加一个「全部」会撒谎,因为空 `status` 在**后端**
* 是「按 pending 筛」,不是「不过滤」(见 listTidyCandidates 的默认化分支)。 */
export const TIDY_STATUS_PENDING = "pending";
export const TIDY_STATUS_APPLIED = "applied";
/** 应用/回滚**回执**的状态闭集(真源 internal/runtime/memory/tidy_apply.go 的
* `TidyApplyStatus`:applied = 真的改了/撤销了;skipped = 没改,且**不是错误**;
* failed = 尝试过并失败)。 */
export const TIDY_APPLY_STATUS_APPLIED = "applied";
export const TIDY_APPLY_STATUS_SKIPPED = "skipped";
export const TIDY_APPLY_STATUS_FAILED = "failed";
/** GET /api/v1/admin/memories/candidates?status=&limit=
*
* `limit` 缺省 100、>500 钳到 500、非整数回 400 bad_request —— **钳位与默认值都在
* 后端**,本函数只转发调用方显式给出的值(否则 URL 里不出现 `limit`)。
*
* ⚠️ 响应里的 `count` 是**返回条数**,后端没有给「待定总数」;列表被 `limit` 截断时
* 没有 `truncated` 标记。因此界面不许把这一页读成「全部待定」——「到底有多少待定」
* 的权威数字在**批量应用回执**的 `total` 里。 */
export function adminTidyCandidates(
params: { status?: string; limit?: number } = {}
): Promise<AdminTidyCandidatesResponse> {
const qs = new URLSearchParams();
if (params.status) qs.set("status", params.status);
if (params.limit && params.limit > 0) qs.set("limit", String(params.limit));
const q = qs.toString();
return request<AdminTidyCandidatesResponse>(`${CANDIDATES_PATH}${q ? `?${q}` : ""}`);
}
/** POST …/candidates/{id}/apply —— 应用**一条**候选。
*
* 幂等:已决定过的候选回 **200 + `status:"skipped"`**(不是错误,界面不许报红);
* 候选不存在回 404 not_found。 */
export function adminTidyCandidateApply(id: string): Promise<AdminTidyApplyResult> {
return request<AdminTidyApplyResult>(`${CANDIDATES_PATH}/${encodeURIComponent(id)}/apply`, {
method: "POST",
});
}
/** POST …/candidates/{id}/revert —— 回滚一条**已应用**候选(记忆行复原 + 候选退回
* pending;行永不物理删除,因此无损可逆)。候选不在已应用状态时回
* `status:"skipped"` + `reason:"not_applied"`。 */
export function adminTidyCandidateRevert(id: string): Promise<AdminTidyApplyResult> {
return request<AdminTidyApplyResult>(`${CANDIDATES_PATH}/${encodeURIComponent(id)}/revert`, {
method: "POST",
});
}
/** POST …/candidates/apply?runId=&limit= —— **批量**应用。
*
* `runId` 省略 = 应用**全部待定**(面板那一档);给出时只应用该轮产出的候选
* (查不到该轮回 404 tidy_run_not_found)。`limit` 省略 ⇒ 由后端领域侧取默认值
* (100,钳到 500)。
*
* ⚠️ 回执里的 `total` 与 `applied+skipped+failed` **可以不等**:后者只覆盖 `limit`
* 之内,前者是命中的真总数 —— 不等时 `truncated=true`,界面必须如实显示「本轮未
* 处理完 + 还剩多少」,不许画成「全部完成」。 */
export function adminTidyCandidatesApplyBatch(
opts: { runId?: string; limit?: number } = {}
): Promise<AdminTidyApplyBatch> {
const qs = new URLSearchParams();
if (opts.runId) qs.set("runId", opts.runId);
if (opts.limit && opts.limit > 0) qs.set("limit", String(opts.limit));
const q = qs.toString();
return request<AdminTidyApplyBatch>(`${CANDIDATES_PATH}/apply${q ? `?${q}` : ""}`, {
method: "POST",
});
}
/** POST /api/v1/admin/memories/tidy/run —— 手动**真跑**一轮(写候选 + 写运行留痕,
* **不自动应用**)。它是「不必等到低峰时段」的那个按钮。
*
* 失败是**诚实错误**,不是空回执:配置关闭 ⇒ 409 tidy_disabled;超时 ⇒ 503
* tidy_timeout;未接线(只接了试算)⇒ 500 tidy_run_unavailable;其余 ⇒ 500
* tidy_run_failed。审计由领域侧记 `memory.tidy.run`,本函数不重复。 */
export function adminTidyRun(): Promise<AdminTidyRunResult> {
return request<AdminTidyRunResult>("/admin/memories/tidy/run", { method: "POST" });
}
+1
View File
@@ -14,4 +14,5 @@ export * from "./types/kook";
export * from "./types/notification";
export * from "./types/configFacts";
export * from "./types/memoryTidy";
export * from "./types/memoryTidyApply";
export * from "./types/channelEvents";
@@ -0,0 +1,107 @@
// 记忆整理**应用/回滚**的线上类型(切片 TIDY-1)。
// 唯一真源:internal/services/gateway/admin_memory_tidy_apply_dto.go
// + internal/runtime/memory/tidy_apply.go(状态闭集与批量回执口径)。
//
// ⚠️ 三个「不许自己推断」的点:
// ① 候选状态闭集来自**后端字段**(`pending` / `applied`,见 tidy_store.go 与
// tidy_apply_store.go 的常量),面板不回推、不猜测第三个值;未知值原样显示;
// ② 批量回执的 `total` 是**命中筛选的真总数**,而 `applied+skipped+failed` 只覆盖
// `limit` 之内的那些 —— 两者不等时 `truncated=true`,**不是**「全部完成」;
// ③ `status:"skipped"` 是**幂等命中/动作不可应用**,不是错误(后端回 200)。
import type { AdminTidyEvidence } from "./memoryTidy";
/** 一条**落库**候选(与试算候选的区别:这里有 id / status / runId / createdAt)。 */
export interface AdminTidyCandidateRecord {
id: string;
/** merge(并入)/ link(只建链接)/ archive(归档)/ reclassify(改分类)。 */
action: string;
/** 候选状态:`pending` / `applied`(真源见上)。 */
status: string;
sourceIds: string[];
/** merge 时的存活条目(超集);其余动作可能为空。 */
targetId?: string;
personaId: string;
scope: string;
projectId?: string;
kind?: string;
/** 被吸收条目的原文(diff 预览用)。 */
proposedContent?: string;
/** 存活条目的原文。 */
mergedContent?: string;
/** 机器可读理由(如 containment_a_subset_b)。 */
reason: string;
ruleVersion: string;
runId?: string;
scoreMin: number;
scoreMax: number;
/** UTC RFC3339(后端投影时统一格式化)。 */
createdAt: string;
evidence?: AdminTidyEvidence;
}
/** GET /api/v1/admin/memories/candidates 的 data(`candidates` 恒为数组)。 */
export interface AdminTidyCandidatesResponse {
candidates: AdminTidyCandidateRecord[];
/** = `candidates.length`(后端返回条数,**不是**库里的待定总数)。 */
count: number;
}
/** 一条候选的应用/回滚回执。 */
export interface AdminTidyApplyResult {
candidateId: string;
action: string;
/** `applied`(真的改了/撤销了)/ `skipped`(没改,且**不是错误**)/ `failed`。 */
status: string;
/** skipped 的机器可读原因(already_decided / not_applied / action_not_applyable …)。 */
reason?: string;
targetId?: string;
/** 这次真的被改动的 memory_items id(回滚的抓手)。 */
affectedIds: string[];
}
/** POST /api/v1/admin/memories/candidates/apply 的 data(批量应用回执)。 */
export interface AdminTidyApplyBatch {
runId?: string;
/** 本轮的单批上限(后端领域侧默认 100 / 钳到 500)。 */
limit: number;
/** 命中筛选的真待定候选数(**不是** len(results))。 */
total: number;
/** `total > 本轮处理的条数` 时为 true —— 界面必须如实显示「未处理完」。 */
truncated: boolean;
applied: number;
skipped: number;
/** 尝试过并失败的条数(失败隔离:一条失败不阻塞其余)。 */
failed: number;
merged: number;
archived: number;
reclassified: number;
results: AdminTidyApplyResult[];
}
/** POST /api/v1/admin/memories/tidy/run 的 data(手动**真跑**一轮回执)。
*
* ⚠️ 它是**建议侧**计数:真跑只写候选 + 留痕、**不自动应用**。要落地改动,
* 接着调候选的 apply / 批量 apply。 */
export interface AdminTidyRunResult {
runId: string;
/** 触发源(手动真跑恒为 `manual`)。 */
trigger: string;
ruleVersion: string;
durationMs: number;
partitions: number;
scanned: number;
compared: number;
pairs: number;
vetoed: number;
/** 本轮失败计数(分区失败 + 候选写入失败):非 0 必须如实显示。 */
errors: number;
/** 被护栏截断的分区标签。 */
truncated?: string[];
vetoReasons?: Record<string, number>;
/** 本次**新建**的候选数;`candidatesDuplicate` 是被幂等键挡下的。 */
candidatesCreated: number;
candidatesDuplicate: number;
/** 新建候选按动作的分布(merge / link / archive / reclassify)。 */
candidatesByAction?: Record<string, number>;
}