From c512609c63246ddf442899c6d5fcf55b3e262dd3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 09:58:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E8=AE=B0=E5=BF=86=E6=95=B4?= =?UTF-8?q?=E7=90=86=E5=80=99=E9=80=89=E7=9A=84=E5=BA=94=E7=94=A8/?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E6=93=8D=E4=BD=9C=E9=9D=A2=EF=BC=88=E5=88=87?= =?UTF-8?q?=E7=89=87=20TIDY-FE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人报障「面板上点不到『应用』」→ 后端 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 凭据 + 真后端),证据止于渲染层与传输层。 --- frontend/web/src/admin/modules/Memories.tsx | 3 + .../MemoryTidyApplyCard.render.test.ts | 239 ++++++++++ .../src/admin/modules/MemoryTidyApplyCard.tsx | 448 ++++++++++++++++++ .../src/admin/modules/memoryTidyApplyState.ts | 240 ++++++++++ frontend/web/src/lib/api.ts | 14 + .../src/lib/api/adminMemoryTidyApply.test.ts | 210 ++++++++ .../web/src/lib/api/adminMemoryTidyApply.ts | 108 +++++ frontend/web/src/lib/types.ts | 1 + frontend/web/src/lib/types/memoryTidyApply.ts | 107 +++++ 9 files changed, 1370 insertions(+) create mode 100644 frontend/web/src/admin/modules/MemoryTidyApplyCard.render.test.ts create mode 100644 frontend/web/src/admin/modules/MemoryTidyApplyCard.tsx create mode 100644 frontend/web/src/admin/modules/memoryTidyApplyState.ts create mode 100644 frontend/web/src/lib/api/adminMemoryTidyApply.test.ts create mode 100644 frontend/web/src/lib/api/adminMemoryTidyApply.ts create mode 100644 frontend/web/src/lib/types/memoryTidyApply.ts diff --git a/frontend/web/src/admin/modules/Memories.tsx b/frontend/web/src/admin/modules/Memories.tsx index 7d1983d..06f95a4 100644 --- a/frontend/web/src/admin/modules/Memories.tsx +++ b/frontend/web/src/admin/modules/Memories.tsx @@ -8,6 +8,7 @@ import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components" import { Button, Surface, Tag } from "../../components/primitives"; import { MemoryTidyFactsCard } from "./ConfigFactsCards"; import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard"; +import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard"; const scopeTone: Record = { user: "accent", @@ -188,6 +189,8 @@ export function MemoriesModule(): React.ReactNode { + + diff --git a/frontend/web/src/admin/modules/MemoryTidyApplyCard.render.test.ts b/frontend/web/src/admin/modules/MemoryTidyApplyCard.render.test.ts new file mode 100644 index 0000000..b790008 --- /dev/null +++ b/frontend/web/src/admin/modules/MemoryTidyApplyCard.render.test.ts @@ -0,0 +1,239 @@ +// 记忆整理**候选应用卡**的真实渲染测试(切片 TIDY-1)。 +// +// 为什么需要它:这张卡是「整理建议的执行面」,而修好之后最容易走样的三条口径 +// 都是**呈现**: +// ① 批量回执被上限截断时,必须如实说出「命中多少 / 本轮处理多少 / 还剩多少未 +// 处理」—— 绝不能画成「全部完成」(否则主人会以为 342 条都处理完了); +// ② `skipped` 是**幂等命中/动作不可应用**,不是错误 —— 界面**不许报红**; +// ③ 行的两个按钮按**后端 status** 决定可用性(pending 行的回滚必须禁用), +// 未知状态原样显示、不吞、不猜。 +// +// 跑法(与 ConfigFactsCards.render.test.ts / MemoryTidyDryRunCard.render.test.ts 同): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyApplyCard.render.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-memory-tidy-apply.render.test.cjs \ +// && node /tmp/cyrene-memory-tidy-apply.render.test.cjs +// +// 🔴 渲染类测试必须 `--format=cjs`:`--format=esm` 会整批崩在 bundle 深处。 +// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { TIDY_STATUS_APPLIED, TIDY_STATUS_PENDING } from "../../lib/api"; +import type { + AdminTidyApplyBatch, + AdminTidyCandidateRecord, + AdminTidyRunResult, +} from "../../lib/types"; +import { + MemoryTidyApplyBody, + TIDY_APPLY_ALL_DONE_TAG, + TIDY_APPLY_SKIPPED_HINT, + TIDY_APPLY_TRUNCATED_HINT, + TIDY_CANDIDATE_LIST_ERROR_HINT, +} from "./MemoryTidyApplyCard"; +import type { TidyApplyActions, TidyApplyViewState } from "./memoryTidyApplyState"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function has(haystack: string, needle: string): boolean { + return haystack.includes(needle); +} + +function countOf(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +/** 按钮的禁用属性在静态标记里的形态(`disabled:opacity-50` 这个**类名**里也有 + * "disabled" 字样,因此只能按属性形态 `disabled=""` 计数)。 */ +const DISABLED_ATTR = 'disabled=""'; + +/** 动作替身:展示体只调这些出口,测试不关心它们做什么。 */ +const NOOP_ACTIONS: TidyApplyActions = { + onStatusChange: () => {}, + onApply: () => {}, + onRevert: () => {}, + onRequest: () => {}, + onConfirm: () => {}, + onCancelConfirm: () => {}, +}; + +const PENDING: AdminTidyCandidateRecord = { + id: "cand-pending-1", + action: "merge", + status: "pending", + sourceIds: ["mem-short"], + targetId: "mem-long", + personaId: "cyrene", + scope: "persona", + kind: "relationship", + proposedContent: "主人常在深夜与助手聊天,有熬夜习惯", + mergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚", + reason: "containment_a_subset_b", + ruleVersion: "t0.1", + runId: "run-1", + scoreMin: 0.95, + scoreMax: 0.95, + createdAt: "2026-10-05T10:18:10Z", +}; + +const APPLIED: AdminTidyCandidateRecord = { ...PENDING, id: "cand-applied-1", status: "applied" }; + +/** 后端候选状态闭集只有 pending / applied;这个 fixture 用的是**闭集外**的值, + * 用来钉住「未知值原样显示、不猜」。 */ +const UNKNOWN: AdminTidyCandidateRecord = { ...PENDING, id: "cand-weird-1", status: "reverted" }; + +function viewState(patch: Partial = {}): TidyApplyViewState { + return { + status: TIDY_STATUS_PENDING, + list: { kind: "ready", data: { candidates: [PENDING], count: 1 } }, + busy: null, + confirming: null, + batch: null, + run: null, + last: null, + fail: null, + ...patch, + }; +} + +function withCandidates(candidate: AdminTidyCandidateRecord, status = TIDY_STATUS_PENDING): TidyApplyViewState { + return viewState({ status, list: { kind: "ready", data: { candidates: [candidate], count: 1 } } }); +} + +function render(state: TidyApplyViewState): string { + return renderToStaticMarkup(createElement(MemoryTidyApplyBody, { state, actions: NOOP_ACTIONS })); +} + +// ── ① 候选表:动作 / 状态 / 摘要 / 理由 / 时间 + 每行两个动作 ──────────────── +const pendingHtml = render(withCandidates(PENDING)); +ok(has(pendingHtml, "并入"), "merge 要显示中文标签「并入」"); +ok(has(pendingHtml, "待定"), "状态要按后端值显示「待定」"); +ok(has(pendingHtml, "作息偏晚"), "摘要要显示存活条目原文(diff 预览)"); +ok(has(pendingHtml, "containment_a_subset_b"), "必须显示机器可读理由"); +ok(has(pendingHtml, "2026-10-05 10:18"), "必须显示候选产生时间(RFC3339 裁到分)"); +ok(has(pendingHtml, "应用") && has(pendingHtml, "回滚"), "每行必须有「应用」与「回滚」两个按钮"); + +// ── ② 回滚只在 status=applied 时可用(按后端 status,不按「我记得点过」)─────── +const pendingDisabled = countOf(pendingHtml, DISABLED_ATTR); +ok(pendingDisabled === 1, `待定行只有回滚该禁用(实际 disabled 属性出现 ${pendingDisabled} 次)`); +const appliedHtml = render(withCandidates(APPLIED, TIDY_STATUS_APPLIED)); +ok(has(appliedHtml, "已应用"), "已应用行要显示「已应用」"); +const appliedDisabled = countOf(appliedHtml, DISABLED_ATTR); +ok(appliedDisabled === 0, `已应用行的回滚必须可点(实际 disabled 属性出现 ${appliedDisabled} 次)`); +const unknownHtml = render(withCandidates(UNKNOWN)); +ok(has(unknownHtml, "reverted"), "闭集外的状态值必须原样显示(绝不吞掉)"); +ok(countOf(unknownHtml, DISABLED_ATTR) === 1, "未知状态按「非已应用」处理:回滚禁用"); + +// ── ③ 批量回执:truncated 时必须如实显示「还剩多少未处理」 ────────────────── +const BATCH_TRUNCATED: AdminTidyApplyBatch = { + limit: 100, + total: 342, + truncated: true, + applied: 90, + skipped: 10, + failed: 0, + merged: 80, + archived: 10, + reclassified: 0, + results: [], +}; +const truncatedHtml = render(viewState({ batch: BATCH_TRUNCATED })); +ok(has(truncatedHtml, TIDY_APPLY_TRUNCATED_HINT), "截断时必须显式点明「命中数大于本轮处理数」"); +ok(has(truncatedHtml, "命中待定 342 条"), "必须显示命中的**真**总数(不是 len(results))"); +ok(has(truncatedHtml, "本轮处理 100 条"), "必须显示本轮真正处理了多少条"); +ok(has(truncatedHtml, "还有 242 条待定未处理"), "必须算出并显示未处理量(342 - 100)"); +ok(!has(truncatedHtml, TIDY_APPLY_ALL_DONE_TAG), "截断时绝不得渲染「已全部处理」标记"); +ok(!has(truncatedHtml, "待实现"), "截断提示不得带「待实现」前缀(那会被读成这功能还没做)"); + +const BATCH_DONE: AdminTidyApplyBatch = { ...BATCH_TRUNCATED, limit: 100, total: 5, applied: 3, skipped: 2, failed: 0, truncated: false }; +const doneHtml = render(viewState({ batch: BATCH_DONE })); +ok(has(doneHtml, TIDY_APPLY_ALL_DONE_TAG), "未截断时才可以说「待定候选已全部处理」"); +ok(!has(doneHtml, "未处理"), "未截断时不得出现「未处理」字样(否则那句话是假的)"); + +// ── ④ skipped 不显示为错误 ──────────────────────────────────────────────── +const skippedHtml = render( + viewState({ + batch: BATCH_DONE, + last: { + candidateId: "cand-link-9", + action: "link", + status: "skipped", + reason: "action_not_applyable", + affectedIds: [], + }, + }) +); +ok(has(skippedHtml, TIDY_APPLY_SKIPPED_HINT), "skipped 必须显式说明「不是错误」"); +ok(has(skippedHtml, "已跳过"), "单条回执要按后端 status 显示「已跳过」"); +ok(has(skippedHtml, "reason=action_not_applyable"), "必须带上机器可读的跳过原因"); +ok(!has(skippedHtml, "text-[var(--mu-err)]"), "skipped 绝不得用错误色渲染(界面不许报红)"); +ok(!has(skippedHtml, "应用失败"), "skipped 不得被呈现成失败"); + +// 反向哨兵:确有 failed 时错误色**必须**出现 —— 否则上面那条否定断言是空的。 +const failedHtml = render( + viewState({ batch: { ...BATCH_DONE, applied: 2, skipped: 0, failed: 1, total: 3 } }) +); +ok(has(failedHtml, "text-[var(--mu-err)]"), "失败计数非 0 时必须出现错误色(否定断言的对照)"); +ok(countOf(failedHtml, TIDY_APPLY_ALL_DONE_TAG) === 1, "有失败但未截断:完成标记仍按 truncated=false 显示"); + +// ── ⑤ 列表读失败 ≠ 没有候选 ────────────────────────────────────────────── +const listErrHtml = render( + viewState({ list: { kind: "error", message: "tidy_unavailable:记忆整理候选未接线" } }) +); +ok(has(listErrHtml, "tidy_unavailable"), "必须显示后端给的原因"); +ok(has(listErrHtml, TIDY_CANDIDATE_LIST_ERROR_HINT), "要说清「没读到」不是「没有候选」"); +ok(!has(listErrHtml, "没有待定候选"), "读失败时绝不得渲染空态文案(把失败当结论)"); +ok(has(render(viewState({ list: { kind: "loading" } })), "加载候选…"), "取数中要显示加载态"); + +// ── ⑥ 两个写动作必须先二次确认 ──────────────────────────────────────────── +const idleHtml = render(viewState()); +ok(has(idleHtml, "批量应用全部待定") && has(idleHtml, "手动真跑一轮"), "顶部必须给出两个写动作"); +ok(!has(idleHtml, "确认批量应用"), "没点按钮时不得出现确认块"); +const confirmBatchHtml = render(viewState({ confirming: "batch" })); +ok(has(confirmBatchHtml, "确认批量应用"), "批量应用要先展开二次确认"); +ok(has(confirmBatchHtml, "行永不物理删除"), "确认文案要说清软动作与可回滚"); +const confirmRunHtml = render(viewState({ confirming: "run" })); +ok(has(confirmRunHtml, "确认真跑一轮"), "真跑也要先二次确认"); +ok(has(confirmRunHtml, "不自动应用"), "真跑确认文案要说清「不自动应用」"); + +// ── ⑦ 手动真跑回执:建议侧计数 + 只产候选 ────────────────────────────────── +const RUN: AdminTidyRunResult = { + runId: "run-20261005-1018", + trigger: "manual", + ruleVersion: "t0.1", + durationMs: 1234, + partitions: 3, + scanned: 736, + compared: 400, + pairs: 12, + vetoed: 4, + errors: 0, + truncated: [], + vetoReasons: { scope: 2 }, + candidatesCreated: 42, + candidatesDuplicate: 7, + candidatesByAction: { merge: 30, link: 12 }, +}; +const runHtml = render(viewState({ run: RUN })); +ok(has(runHtml, "只产候选,未自动应用"), "真跑回执必须点明不自动应用"); +ok(has(runHtml, "run-20261005-1018"), "必须显示 runId(批量应用按轮次要用它)"); +ok(has(runHtml, "新增候选 42 条"), "必须显示新建候选数"); +ok(has(runHtml, "幂等去重 7 条"), "必须显示被幂等键挡下的数量"); +ok(has(runHtml, "并入=30") && has(runHtml, "只建链接=12"), "按动作分布要用人话标签"); +const runErrHtml = render(viewState({ run: { ...RUN, errors: 2 } })); +ok(has(runErrHtml, "本轮有 2 处失败"), "真跑里有失败必须如实显示"); + +// ── ⑧ 写动作失败要显示后端文案(不自己编)────────────────────────────────── +const failHtml = render(viewState({ fail: "tidy_disabled:记忆整理已被配置关闭" })); +ok(has(failHtml, "tidy_disabled"), "写动作失败要显示后端 message"); + +// eslint-disable-next-line no-console +console.log(`MemoryTidyApplyCard.render: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/MemoryTidyApplyCard.tsx b/frontend/web/src/admin/modules/MemoryTidyApplyCard.tsx new file mode 100644 index 0000000..44cb108 --- /dev/null +++ b/frontend/web/src/admin/modules/MemoryTidyApplyCard.tsx @@ -0,0 +1,448 @@ +import { + TIDY_APPLY_STATUS_APPLIED, + TIDY_APPLY_STATUS_FAILED, + TIDY_APPLY_STATUS_SKIPPED, + TIDY_STATUS_APPLIED, + TIDY_STATUS_PENDING, +} from "../../lib/api"; +import type { + AdminTidyApplyBatch, + AdminTidyApplyResult, + AdminTidyCandidateRecord, + AdminTidyRunResult, +} from "../../lib/types"; +import { Button, Surface, Tag } from "../../components/primitives"; +import { AdminTable, Notice, type AdminColumn } from "../components"; +import { + useTidyApplyState, + type TidyApplyActions, + type TidyApplyViewState, + type TidyConfirmKind, +} from "./memoryTidyApplyState"; + +/** + * 记忆整理「候选」卡的**展示**半边(切片 TIDY-1;取数与写动作在 + * `memoryTidyApplyState.ts`,拆开只为守 AGENTS §10 的文件行数上限)。 + * + * 动因是实测缺口:后端已经有候选列表 / 单条应用 / 批量应用 / 单条回滚 / 手动真跑, + * 面板却只有一张只读试算卡 —— 于是候选全 pending、一条都应用不了。本卡把「点得到」 + * 这件事补上,四条不可走样的口径: + * + * 1. **权威键只读**:一切状态与计数只从响应里取。界面**不**推断「应该成功了」—— + * 单条回执按 `status` 如实显示 applied / skipped / failed;批量回执按 + * `applied/skipped/failed/total/truncated` 如实显示。 + * 2. **skipped 不是错误**:幂等命中(已决定过)与 link/keep(本就没有执行语义) + * 都是 200,面板用中性标签显示,**绝不报红**。 + * 3. **超限绝不静默**:`truncated=true` 时必须说出「命中多少 / 本轮处理多少 / + * 还剩多少未处理」,不许画出「全部完成」的样子。 + * 4. **回滚只在已应用时可用**:按候选 `status` 决定按钮可用性(不是按「我记得刚 + * 点过应用」)。未知值一律原样显示,不猜、不吞。 + * + * 展示与取数分层:`MemoryTidyApplyBody` 只吃状态(真实渲染测试直接喂 fixture), + * `MemoryTidyApplyCard` 接取数钩子。 + */ + +/** 诚实文案:这张卡会真的改记忆(与试算卡的「一行都不写」相对)。 */ +export const TIDY_APPLY_DISCLAIMER = "会真的改动记忆(软合并 / 归档 / 改分类,可回滚)"; + +/** 列表读失败时的说明:区分「没读到」与「没有候选」。 */ +export const TIDY_CANDIDATE_LIST_ERROR_HINT = + "这是「没读到」,不是「没有候选」—— 整理写面未接线时后端回 500 tidy_unavailable,绝不回空列表"; + +/** 批量回执被上限截断时的定性(测试钉住它必须出现)。 */ +export const TIDY_APPLY_TRUNCATED_HINT = "命中数大于本轮处理数"; + +/** 批量回执「处理完了」的标记 —— 只有 `truncated=false` 时才可以出现。 */ +export const TIDY_APPLY_ALL_DONE_TAG = "待定候选已全部处理"; + +/** skipped 的定性(幂等 / 不可应用都不是失败)。 */ +export const TIDY_APPLY_SKIPPED_HINT = + "跳过不是错误:已决定过的候选重复应用会幂等跳过,link/keep 本就没有执行语义"; + +/** 批量应用的二次确认文案(写动作走既有确认惯例)。 */ +const CONFIRM_BATCH_HINT = + "会把全部待定候选真正落地:merge = 软合并(被吸收行标记 merged,正文一个字都不改)、archive = 软删(行保留)、reclassify = 改 kind。行永不物理删除,因此每一步都能回滚。确定继续?"; + +/** 手动真跑的二次确认文案。 */ +const CONFIRM_RUN_HINT = + "立刻按与低峰轮同一套规则与配置跑一轮:写候选 + 写运行留痕,不自动应用任何候选。跑完在下面的待定列表里逐条决定。确定继续?"; + +const actionLabel: Record = { + merge: "并入", + link: "只建链接", + archive: "归档", + reclassify: "改分类", +}; + +const actionTone: Record = { + merge: "warn", + link: "accent", + archive: "neutral", + reclassify: "neutral", +}; + +/** 候选状态的中文标签(真源只有 pending / applied;未知值原样显示)。 */ +const statusLabel: Record = { + pending: "待定", + applied: "已应用", +}; + +const statusTone: Record = { + pending: "accent", + applied: "ok", +}; + +/** 未知值原样显示(后端加值不能丢信息)。 */ +function label(table: Record, value: string): string { + return table[value] ?? value; +} + +/** shortTime 把 RFC3339 裁成「日期 + 时分」(秒与时区对判读候选新旧没有信息量)。 */ +function shortTime(iso: string): string { + return iso.length >= 16 ? `${iso.slice(0, 10)} ${iso.slice(11, 16)}` : iso; +} + +/** WarnNote 是**黄牌提示**:截断 / 未处理完这类「真实但不致命」的话用它。 + * + * ⚠️ 刻意**不用**共用的 `Notice tone="warn"` —— 那个组件会把 tone 渲染成固定前缀 + * 「待实现」,贴在「本轮还有 N 条未处理」上会被读成「这功能还没做」(措辞失实)。 + * 真失败仍走 `Notice tone="err"`(它的「错误」前缀是对的)。 */ +function WarnNote({ children }: { children: React.ReactNode }): React.ReactNode { + return ( +
+ {children} +
+ ); +} + +/** 候选动作标签。 */ +function ActionTag({ action }: { action: string }): React.ReactNode { + return {label(actionLabel, action)}; +} + +/** 候选状态标签(只按后端给的值渲染,不回推)。 */ +function StatusTag({ status }: { status: string }): React.ReactNode { + return {label(statusLabel, status)}; +} + +/** 摘要单元格:优先存活条目原文,其次被吸收条目原文,截断显示(title 给全文)。 */ +function SummaryCell({ candidate }: { candidate: AdminTidyCandidateRecord }): React.ReactNode { + const text = candidate.mergedContent || candidate.proposedContent || ""; + if (!text) { + return (该动作无正文预览); + } + return ( + + {text} + + ); +} + +/** 一行的两个动作按钮:**回滚只在 status=applied 时可用**。 */ +function CandidateActions({ + candidate, + busy, + onApply, + onRevert, +}: { + candidate: AdminTidyCandidateRecord; + busy: string | null; + onApply: (id: string) => void; + onRevert: (id: string) => void; +}): React.ReactNode { + const applied = candidate.status === TIDY_STATUS_APPLIED; + const working = busy !== null; + return ( +
+ + +
+ ); +} + +/** 单条应用/回滚的回执(按后端 `status` 如实分色;未知状态原样显示)。 */ +function SingleReceipt({ result }: { result: AdminTidyApplyResult }): React.ReactNode { + const applied = result.status === TIDY_APPLY_STATUS_APPLIED; + const skipped = result.status === TIDY_APPLY_STATUS_SKIPPED; + const failed = result.status === TIDY_APPLY_STATUS_FAILED; + const tone = applied ? "ok" : failed ? "err" : "neutral"; + const text = applied + ? `已应用(改动 ${result.affectedIds.length} 行)` + : skipped + ? "已跳过(幂等命中或不可应用,不是错误)" + : failed + ? "应用失败" + : `未知状态 ${result.status}`; + return ( +
+ {text} + {result.candidateId} + {result.reason ? ( + + reason={result.reason} + + ) : null} + + 受影响行:{result.affectedIds.length ? result.affectedIds.join("、") : "无"} + +
+ ); +} + +/** 批量应用回执:计数全部取自响应,`truncated` 时如实说「还剩多少未处理」。 */ +function BatchReceipt({ batch }: { batch: AdminTidyApplyBatch }): React.ReactNode { + const processed = batch.applied + batch.skipped + batch.failed; + const remaining = batch.total - processed; + return ( +
+
+ 批量应用回执 + + 命中待定 {batch.total} 条 · 本轮处理 {processed} 条 · 单轮上限 {batch.limit} + +
+
+ + 应用 {batch.applied} + + + 跳过 {batch.skipped} + + + 失败 {batch.failed} + + + 其中合并 {batch.merged} / 归档 {batch.archived} / 改分类 {batch.reclassified} + +
+ {batch.truncated ? ( + + {TIDY_APPLY_TRUNCATED_HINT}:命中 {batch.total} 条,本轮只处理 {processed} 条(单轮上限{" "} + {batch.limit}),还有 {remaining} 条待定未处理 —— 再点一次继续。 + + ) : ( + {TIDY_APPLY_ALL_DONE_TAG} + )} + {batch.skipped > 0 ? ( + + {TIDY_APPLY_SKIPPED_HINT}(本轮 {batch.skipped} 条)。 + + ) : null} + {batch.failed > 0 ? ( + + 失败 {batch.failed} 条(失败隔离:其余候选照常处理,逐条原因见后端审计 memory.tidy.apply)。 + + ) : null} +
+ ); +} + +/** 手动真跑回执:它是**建议侧**计数 —— 只产候选,未自动应用。 */ +function RunReceipt({ run }: { run: AdminTidyRunResult }): React.ReactNode { + const byAction = Object.entries(run.candidatesByAction ?? {}).sort((a, b) => + a[0] < b[0] ? -1 : 1 + ); + const byActionText = byAction.length + ? byAction.map(([action, count]) => `${label(actionLabel, action)}=${count}`).join(" / ") + : "无"; + return ( +
+
+ 真跑完成(只产候选,未自动应用) + runId={run.runId} +
+
+ 新增候选 {run.candidatesCreated} 条({byActionText})· 幂等去重 {run.candidatesDuplicate} 条 · + 扫描 {run.scanned} / 比对 {run.compared} / 召回 {run.pairs} 对 / 否决 {run.vetoed} · 用时{" "} + {run.durationMs} ms · 规则 {run.ruleVersion} · 分区 {run.partitions} +
+ {run.errors > 0 ? ( + + 本轮有 {run.errors} 处失败(分区或候选写入):已写下的候选仍然有效。 + + ) : null} + {run.truncated && run.truncated.length > 0 ? ( + + 本轮被护栏截断的分区:{run.truncated.join("、")} —— 这一轮看到的比库里少。 + + ) : null} +
+ ); +} + +/** 二次确认块(批量应用 / 真跑共用;写动作先确认再发请求)。 */ +function ConfirmBlock({ + kind, + busy, + onConfirm, + onCancel, +}: { + kind: TidyConfirmKind; + busy: boolean; + onConfirm: () => void; + onCancel: () => void; +}): React.ReactNode { + const isBatch = kind === "batch"; + return ( +
+

+ {isBatch ? CONFIRM_BATCH_HINT : CONFIRM_RUN_HINT} +

+
+ + +
+
+ ); +} + +/** 顶部条:状态筛选 + 两个写动作(含二次确认)。 */ +function Toolbar({ + state, + actions, +}: { + state: TidyApplyViewState; + actions: TidyApplyActions; +}): React.ReactNode { + const working = state.busy !== null; + return ( +
+
+ + + 筛选就是后端查询条件本身:只有 pending / applied 两个真值(后端把空筛选按 pending 处理,因此这里不提供「全部」) + + + + + +
+ {state.confirming ? ( + + ) : null} +
+ ); +} + +/** 候选表(取数三态:加载 / 失败 / 就绪;空态按当前筛选给不同文案)。 */ +function CandidatesSection({ + state, + actions, +}: { + state: TidyApplyViewState; + actions: TidyApplyActions; +}): React.ReactNode { + const { list } = state; + if (list.kind === "loading") { + return
加载候选…
; + } + if (list.kind === "error") { + return ( +
+ 读取候选失败:{list.message} + {TIDY_CANDIDATE_LIST_ERROR_HINT} +
+ ); + } + const columns: AdminColumn[] = [ + { key: "action", title: "动作", render: (c) => }, + { key: "status", title: "状态", render: (c) => }, + { key: "summary", title: "摘要", render: (c) => }, + { + key: "reason", + title: "理由", + render: (c) => {c.reason}, + }, + { + key: "createdAt", + title: "时间", + render: (c) => ( + {shortTime(c.createdAt)} + ), + }, + { key: "ops", title: "操作", render: (c) => }, + ]; + return ( +
+ c.id} + /> + + 本页返回 {list.data.count} 条(后端列表默认上限 100、钳到 500;「到底有多少待定」的权威数字在批量回执的命中数里,本页不把它冒充成全部)。 + +
+ ); +} + +/** MemoryTidyApplyBody 是候选卡的纯展示体(测试直接喂状态与动作替身)。 */ +export function MemoryTidyApplyBody({ + state, + actions, +}: { + state: TidyApplyViewState; + actions: TidyApplyActions; +}): React.ReactNode { + return ( + +
+

记忆整理候选(应用 / 回滚)

+ {TIDY_APPLY_DISCLAIMER} + 写操作 · 已审计 + + GET/POST /api/v1/admin/memories/candidates… + +
+ + {state.fail ? 操作失败:{state.fail} : null} + {state.batch ? : null} + {state.run ? : null} + {state.last ? : null} + +
+ ); +} + +/** MemoryTidyApplyCard 是记忆管理页挂的候选卡(取数 + 写动作走 useTidyApplyState)。 */ +export function MemoryTidyApplyCard(): React.ReactNode { + const { state, actions } = useTidyApplyState(); + return ; +} diff --git a/frontend/web/src/admin/modules/memoryTidyApplyState.ts b/frontend/web/src/admin/modules/memoryTidyApplyState.ts new file mode 100644 index 0000000..4cc0e3c --- /dev/null +++ b/frontend/web/src/admin/modules/memoryTidyApplyState.ts @@ -0,0 +1,240 @@ +import { useState } from "react"; +import { + TIDY_STATUS_PENDING, + adminTidyCandidateApply, + adminTidyCandidateRevert, + adminTidyCandidates, + adminTidyCandidatesApplyBatch, + adminTidyRun, +} from "../../lib/api"; +import type { + AdminTidyApplyBatch, + AdminTidyApplyResult, + AdminTidyCandidatesResponse, + AdminTidyRunResult, +} from "../../lib/types"; +import { useAsyncData } from "../../lib/useAsyncData"; +import { useAuditStore } from "../audit"; + +/** + * 记忆整理「候选」卡的**取数与写动作**半边(切片 TIDY-1),与展示半边 + * (`MemoryTidyApplyCard.tsx`)分开,只为守 AGENTS §10 的文件/函数长度上限。 + * + * 三条口径落在这里,而不是散在组件里: + * 1. **权威键只读**:本模块只把响应原样交给状态(`batch` / `run` / `last` / 列表), + * 从不合成计数、从不把「我点了按钮」当成「它成功了」—— 界面显示的一切都来自后端。 + * 2. **筛选即查询条件**:`status` 一变就重取,且**旧数据不冒充新筛选的结果**。 + * 3. **写动作四件套**(单条应用 / 单条回滚 / 批量应用 / 真跑)共用一份 busy + 失败 + + * 客户端审计收尾:失败文案取后端 `error.message`,不自己编。 + * + * 后端另有权威审计(`memory.tidy.apply` / `memory.tidy.revert` / `memory.tidy.run`), + * 这里记的是面板侧可见的那一份(与既有 `memory.add` / `llm.resetFromEnv` 同惯例)。 + */ + +/** 二次确认展开的是哪个写动作。 */ +export type TidyConfirmKind = "batch" | "run"; + +/** 候选列表的取数态。 */ +export type TidyCandidateListState = + | { kind: "loading" } + | { kind: "ready"; data: AdminTidyCandidatesResponse } + | { kind: "error"; message: string }; + +/** 应用卡的完整视图态(纯数据;真实渲染测试直接喂)。 */ +export interface TidyApplyViewState { + status: string; + list: TidyCandidateListState; + /** 正在进行的动作(`apply:` / `revert:` / `batch` / `run`),null = 空闲。 */ + busy: string | null; + confirming: TidyConfirmKind | null; + /** 最近一次批量应用回执(计数的**唯一**来源)。 */ + batch: AdminTidyApplyBatch | null; + /** 最近一次手动真跑回执。 */ + run: AdminTidyRunResult | null; + /** 最近一次单条应用/回滚回执。 */ + last: AdminTidyApplyResult | null; + /** 写动作的失败文案(后端 message)。 */ + fail: string | null; +} + +/** 视图态的六个交互出口(展示半边只调它们,不碰 API)。 */ +export interface TidyApplyActions { + onStatusChange: (status: string) => void; + onApply: (id: string) => void; + onRevert: (id: string) => void; + /** 展开某个写动作的二次确认(此时还不发请求)。 */ + onRequest: (kind: TidyConfirmKind) => void; + /** 确认落刀(批量应用 / 真跑由 `state.confirming` 决定是哪一个)。 */ + onConfirm: () => void; + onCancelConfirm: () => void; +} + +/** 写动作那一半的出口(`onStatusChange` 属于筛选的持有者,不在这里)。 */ +type TidyWriteActions = Omit; + +/** 一次写动作的收尾器:busy 标记 + 失败文案 + 客户端审计 + 成功收起确认。 */ +type TidyAct = (label: string, audit: string, task: () => Promise) => void; + +/** useTidyCandidateList 取候选列表,并把「屏幕上这批数据属于哪次请求」记在 + * `dataStatus` 里:切筛选时旧数据**不许**冒充新筛选的结果(在途期间只显示加载态)。 + * + * `dataStatus` 在**成功与失败**两条路上都记 —— 否则新筛选请求失败时它会停在旧值, + * 界面会永远卡在「加载中」,把失败也一起藏掉。 */ +function useTidyCandidateList(status: string): { + list: TidyCandidateListState; + reload: () => void; +} { + const [dataStatus, setDataStatus] = useState(status); + const fetchList = (): Promise => { + const requested = status; + return adminTidyCandidates({ status: requested }).then( + (res) => { + setDataStatus(requested); + return res; + }, + (e: unknown) => { + setDataStatus(requested); + throw e; + } + ); + }; + const { data, loading, error, reload } = useAsyncData(fetchList, [status]); + const inFlight = dataStatus !== status || (loading && data === null); + const list: TidyCandidateListState = inFlight + ? { kind: "loading" } + : error + ? { kind: "error", message: error } + : data + ? { kind: "ready", data } + : { kind: "loading" }; + return { list, reload }; +} + +/** runConfirmed 落刀二次确认里的那个写动作(批量应用 / 真跑),回执存进状态。 */ +function runConfirmed( + kind: TidyConfirmKind, + act: TidyAct, + store: { + setBatch: (batch: AdminTidyApplyBatch) => void; + setRun: (run: AdminTidyRunResult) => void; + reload: () => void; + } +): void { + if (kind === "run") { + act("run", "memory.tidy.run", () => + adminTidyRun().then((res) => { + store.setRun(res); + store.reload(); + }) + ); + return; + } + act("batch", "memory.tidy.applyBatch", () => + adminTidyCandidatesApplyBatch().then((res) => { + store.setBatch(res); + store.reload(); + }) + ); +} + +/** useTidyWrites 持有四个写动作的状态(busy / 二次确认 / 三种回执 / 失败)并把它们 + * 装配成交互出口。`act` 是四者共用的收尾,四个动作一字排开只差调哪个端点。 */ +function useTidyWrites(reload: () => void): { + busy: string | null; + confirming: TidyConfirmKind | null; + batch: AdminTidyApplyBatch | null; + run: AdminTidyRunResult | null; + last: AdminTidyApplyResult | null; + fail: string | null; + actions: TidyWriteActions; + reset: () => void; +} { + const [busy, setBusy] = useState(null); + const [confirming, setConfirming] = useState(null); + const [batch, setBatch] = useState(null); + const [run, setRun] = useState(null); + const [last, setLast] = useState(null); + const [fail, setFail] = useState(null); + const log = useAuditStore((s) => s.log); + + const act: TidyAct = (action, audit, task) => { + setBusy(action); + setFail(null); + task() + .then(() => { + log(audit, "ok"); + setConfirming(null); + }) + .catch((e: unknown) => { + log(audit, "error"); + setFail(e instanceof Error ? e.message : "操作失败"); + }) + .finally(() => setBusy(null)); + }; + + const confirmKind = confirming; + const actions: TidyWriteActions = { + onApply: (id) => + act(`apply:${id}`, "memory.tidy.apply", () => + adminTidyCandidateApply(id).then((res) => { + setLast(res); + reload(); + }) + ), + onRevert: (id) => + act(`revert:${id}`, "memory.tidy.revert", () => + adminTidyCandidateRevert(id).then((res) => { + setLast(res); + reload(); + }) + ), + onRequest: (kind) => setConfirming(kind), + onCancelConfirm: () => setConfirming(null), + onConfirm: () => { + if (confirmKind) runConfirmed(confirmKind, act, { setBatch, setRun, reload }); + }, + }; + + return { + busy, + confirming, + batch, + run, + last, + fail, + actions, + reset: () => { + setLast(null); + setFail(null); + }, + }; +} + +/** useTidyApplyState 把两半拼成候选卡的状态与出口。 */ +export function useTidyApplyState(): { + state: TidyApplyViewState; + actions: TidyApplyActions; +} { + const [status, setStatus] = useState(TIDY_STATUS_PENDING); + const { list, reload } = useTidyCandidateList(status); + const writes = useTidyWrites(reload); + return { + state: { + status, + list, + busy: writes.busy, + confirming: writes.confirming, + batch: writes.batch, + run: writes.run, + last: writes.last, + fail: writes.fail, + }, + actions: { + ...writes.actions, + onStatusChange: (next) => { + setStatus(next); + writes.reset(); + }, + }, + }; +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index ea27586..4389556 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -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"; diff --git a/frontend/web/src/lib/api/adminMemoryTidyApply.test.ts b/frontend/web/src/lib/api/adminMemoryTidyApply.test.ts new file mode 100644 index 0000000..2411f0c --- /dev/null +++ b/frontend/web/src/lib/api/adminMemoryTidyApply.test.ts @@ -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(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): Promise { + 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`); diff --git a/frontend/web/src/lib/api/adminMemoryTidyApply.ts b/frontend/web/src/lib/api/adminMemoryTidyApply.ts new file mode 100644 index 0000000..3aa5608 --- /dev/null +++ b/frontend/web/src/lib/api/adminMemoryTidyApply.ts @@ -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 { + 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(`${CANDIDATES_PATH}${q ? `?${q}` : ""}`); +} + +/** POST …/candidates/{id}/apply —— 应用**一条**候选。 + * + * 幂等:已决定过的候选回 **200 + `status:"skipped"`**(不是错误,界面不许报红); + * 候选不存在回 404 not_found。 */ +export function adminTidyCandidateApply(id: string): Promise { + return request(`${CANDIDATES_PATH}/${encodeURIComponent(id)}/apply`, { + method: "POST", + }); +} + +/** POST …/candidates/{id}/revert —— 回滚一条**已应用**候选(记忆行复原 + 候选退回 + * pending;行永不物理删除,因此无损可逆)。候选不在已应用状态时回 + * `status:"skipped"` + `reason:"not_applied"`。 */ +export function adminTidyCandidateRevert(id: string): Promise { + return request(`${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 { + 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(`${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 { + return request("/admin/memories/tidy/run", { method: "POST" }); +} diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index 82ea6e8..4c0f9e7 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -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"; diff --git a/frontend/web/src/lib/types/memoryTidyApply.ts b/frontend/web/src/lib/types/memoryTidyApply.ts new file mode 100644 index 0000000..1611128 --- /dev/null +++ b/frontend/web/src/lib/types/memoryTidyApply.ts @@ -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; + /** 本次**新建**的候选数;`candidatesDuplicate` 是被幂等键挡下的。 */ + candidatesCreated: number; + candidatesDuplicate: number; + /** 新建候选按动作的分布(merge / link / archive / reclassify)。 */ + candidatesByAction?: Record; +}