feat(admin): 记忆整理候选的应用/回滚操作面(切片 TIDY-FE)
主人报障「面板上点不到『应用』」→ 后端 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:
1 parent
aa0bf8b5a0
commit
c512609c63
9 files changed
+1370
No files matched your search
@@ -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<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
user: "accent",
|
||||
@@ -188,6 +189,8 @@ export function MemoriesModule(): React.ReactNode {
|
||||
|
||||
<MemoryTidyDryRunCard />
|
||||
|
||||
<MemoryTidyApplyCard />
|
||||
|
||||
<MemoryTidyFactsCard />
|
||||
|
||||
<Notice>
|
||||
|
||||
@@ -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> = {}): 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`);
|
||||
@@ -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<string, string> = {
|
||||
merge: "并入",
|
||||
link: "只建链接",
|
||||
archive: "归档",
|
||||
reclassify: "改分类",
|
||||
};
|
||||
|
||||
const actionTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
merge: "warn",
|
||||
link: "accent",
|
||||
archive: "neutral",
|
||||
reclassify: "neutral",
|
||||
};
|
||||
|
||||
/** 候选状态的中文标签(真源只有 pending / applied;未知值原样显示)。 */
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: "待定",
|
||||
applied: "已应用",
|
||||
};
|
||||
|
||||
const statusTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
pending: "accent",
|
||||
applied: "ok",
|
||||
};
|
||||
|
||||
/** 未知值原样显示(后端加值不能丢信息)。 */
|
||||
function label(table: Record<string, string>, 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 (
|
||||
<div className="rounded-[var(--mu-radius)] border border-[var(--mu-warn)]/40 bg-[var(--mu-warn)]/10 px-3 py-2 text-xs text-[var(--mu-text-mid)]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 候选动作标签。 */
|
||||
function ActionTag({ action }: { action: string }): React.ReactNode {
|
||||
return <Tag tone={actionTone[action] ?? "neutral"}>{label(actionLabel, action)}</Tag>;
|
||||
}
|
||||
|
||||
/** 候选状态标签(只按后端给的值渲染,不回推)。 */
|
||||
function StatusTag({ status }: { status: string }): React.ReactNode {
|
||||
return <Tag tone={statusTone[status] ?? "neutral"}>{label(statusLabel, status)}</Tag>;
|
||||
}
|
||||
|
||||
/** 摘要单元格:优先存活条目原文,其次被吸收条目原文,截断显示(title 给全文)。 */
|
||||
function SummaryCell({ candidate }: { candidate: AdminTidyCandidateRecord }): React.ReactNode {
|
||||
const text = candidate.mergedContent || candidate.proposedContent || "";
|
||||
if (!text) {
|
||||
return <span className="text-[11px] text-[var(--mu-text-lo)]">(该动作无正文预览)</span>;
|
||||
}
|
||||
return (
|
||||
<span className="block max-w-md truncate text-xs text-[var(--mu-text-mid)]" title={text}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一行的两个动作按钮:**回滚只在 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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="primary" disabled={working} onClick={() => onApply(candidate.id)}>
|
||||
{busy === `apply:${candidate.id}` ? "应用中…" : "应用"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working || !applied}
|
||||
title={applied ? "把这条候选的改动复原(候选退回待定)" : "只有已应用的候选才能回滚"}
|
||||
onClick={() => onRevert(candidate.id)}
|
||||
>
|
||||
{busy === `revert:${candidate.id}` ? "回滚中…" : "回滚"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 单条应用/回滚的回执(按后端 `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 (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Tag tone={tone}>{text}</Tag>
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{result.candidateId}</span>
|
||||
{result.reason ? (
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">
|
||||
reason={result.reason}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
受影响行:{result.affectedIds.length ? result.affectedIds.join("、") : "无"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量应用回执:计数全部取自响应,`truncated` 时如实说「还剩多少未处理」。 */
|
||||
function BatchReceipt({ batch }: { batch: AdminTidyApplyBatch }): React.ReactNode {
|
||||
const processed = batch.applied + batch.skipped + batch.failed;
|
||||
const remaining = batch.total - processed;
|
||||
return (
|
||||
<div className="space-y-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Tag tone="accent">批量应用回执</Tag>
|
||||
<span className="text-[var(--mu-text-mid)]">
|
||||
命中待定 {batch.total} 条 · 本轮处理 {processed} 条 · 单轮上限 {batch.limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--mu-text-mid)]">
|
||||
<span>
|
||||
应用 <b>{batch.applied}</b>
|
||||
</span>
|
||||
<span>
|
||||
跳过 <b>{batch.skipped}</b>
|
||||
</span>
|
||||
<span>
|
||||
失败 <b>{batch.failed}</b>
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
其中合并 {batch.merged} / 归档 {batch.archived} / 改分类 {batch.reclassified}
|
||||
</span>
|
||||
</div>
|
||||
{batch.truncated ? (
|
||||
<WarnNote>
|
||||
{TIDY_APPLY_TRUNCATED_HINT}:命中 {batch.total} 条,本轮只处理 {processed} 条(单轮上限{" "}
|
||||
{batch.limit}),还有 {remaining} 条待定未处理 —— 再点一次继续。
|
||||
</WarnNote>
|
||||
) : (
|
||||
<Tag tone="ok">{TIDY_APPLY_ALL_DONE_TAG}</Tag>
|
||||
)}
|
||||
{batch.skipped > 0 ? (
|
||||
<span className="block text-[11px] text-[var(--mu-text-lo)]">
|
||||
{TIDY_APPLY_SKIPPED_HINT}(本轮 {batch.skipped} 条)。
|
||||
</span>
|
||||
) : null}
|
||||
{batch.failed > 0 ? (
|
||||
<Notice tone="err">
|
||||
失败 {batch.failed} 条(失败隔离:其余候选照常处理,逐条原因见后端审计 memory.tidy.apply)。
|
||||
</Notice>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 手动真跑回执:它是**建议侧**计数 —— 只产候选,未自动应用。 */
|
||||
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 (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Tag tone="ok">真跑完成(只产候选,未自动应用)</Tag>
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">runId={run.runId}</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--mu-text-mid)]">
|
||||
新增候选 {run.candidatesCreated} 条({byActionText})· 幂等去重 {run.candidatesDuplicate} 条 ·
|
||||
扫描 {run.scanned} / 比对 {run.compared} / 召回 {run.pairs} 对 / 否决 {run.vetoed} · 用时{" "}
|
||||
{run.durationMs} ms · 规则 {run.ruleVersion} · 分区 {run.partitions}
|
||||
</div>
|
||||
{run.errors > 0 ? (
|
||||
<Notice tone="err">
|
||||
本轮有 {run.errors} 处失败(分区或候选写入):已写下的候选仍然有效。
|
||||
</Notice>
|
||||
) : null}
|
||||
{run.truncated && run.truncated.length > 0 ? (
|
||||
<WarnNote>
|
||||
本轮被护栏截断的分区:{run.truncated.join("、")} —— 这一轮看到的比库里少。
|
||||
</WarnNote>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 二次确认块(批量应用 / 真跑共用;写动作先确认再发请求)。 */
|
||||
function ConfirmBlock({
|
||||
kind,
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
kind: TidyConfirmKind;
|
||||
busy: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}): React.ReactNode {
|
||||
const isBatch = kind === "batch";
|
||||
return (
|
||||
<div className="space-y-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-3">
|
||||
<p className="text-xs text-[var(--mu-text-mid)]">
|
||||
{isBatch ? CONFIRM_BATCH_HINT : CONFIRM_RUN_HINT}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant={isBatch ? "danger" : "primary"} disabled={busy} onClick={onConfirm}>
|
||||
{busy ? "处理中…" : isBatch ? "确认批量应用" : "确认真跑一轮"}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 顶部条:状态筛选 + 两个写动作(含二次确认)。 */
|
||||
function Toolbar({
|
||||
state,
|
||||
actions,
|
||||
}: {
|
||||
state: TidyApplyViewState;
|
||||
actions: TidyApplyActions;
|
||||
}): React.ReactNode {
|
||||
const working = state.busy !== null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={state.status}
|
||||
onChange={(e) => actions.onStatusChange(e.target.value)}
|
||||
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
>
|
||||
<option value={TIDY_STATUS_PENDING}>待定(可应用)</option>
|
||||
<option value={TIDY_STATUS_APPLIED}>已应用(可回滚)</option>
|
||||
</select>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
筛选就是后端查询条件本身:只有 pending / applied 两个真值(后端把空筛选按 pending 处理,因此这里不提供「全部」)
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
<Button size="sm" variant="soft" disabled={working} onClick={() => actions.onRequest("batch")}>
|
||||
批量应用全部待定
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={working} onClick={() => actions.onRequest("run")}>
|
||||
手动真跑一轮
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
{state.confirming ? (
|
||||
<ConfirmBlock
|
||||
kind={state.confirming}
|
||||
busy={working}
|
||||
onConfirm={actions.onConfirm}
|
||||
onCancel={actions.onCancelConfirm}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 候选表(取数三态:加载 / 失败 / 就绪;空态按当前筛选给不同文案)。 */
|
||||
function CandidatesSection({
|
||||
state,
|
||||
actions,
|
||||
}: {
|
||||
state: TidyApplyViewState;
|
||||
actions: TidyApplyActions;
|
||||
}): React.ReactNode {
|
||||
const { list } = state;
|
||||
if (list.kind === "loading") {
|
||||
return <div className="px-3 py-6 text-center text-xs text-[var(--mu-text-lo)]">加载候选…</div>;
|
||||
}
|
||||
if (list.kind === "error") {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Notice tone="err">读取候选失败:{list.message}</Notice>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">{TIDY_CANDIDATE_LIST_ERROR_HINT}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const columns: AdminColumn<AdminTidyCandidateRecord>[] = [
|
||||
{ key: "action", title: "动作", render: (c) => <ActionTag action={c.action} /> },
|
||||
{ key: "status", title: "状态", render: (c) => <StatusTag status={c.status} /> },
|
||||
{ key: "summary", title: "摘要", render: (c) => <SummaryCell candidate={c} /> },
|
||||
{
|
||||
key: "reason",
|
||||
title: "理由",
|
||||
render: (c) => <span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{c.reason}</span>,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
title: "时间",
|
||||
render: (c) => (
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{shortTime(c.createdAt)}</span>
|
||||
),
|
||||
},
|
||||
{ key: "ops", title: "操作", render: (c) => <CandidateActions candidate={c} busy={state.busy} onApply={actions.onApply} onRevert={actions.onRevert} /> },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={list.data.candidates}
|
||||
loading={false}
|
||||
error={null}
|
||||
emptyTitle={
|
||||
state.status === TIDY_STATUS_APPLIED
|
||||
? "没有已应用的候选:先在「待定」列表里点「应用」"
|
||||
: "没有待定候选:点「手动真跑一轮」产出候选(低峰时段那一轮还没到也能跑)"
|
||||
}
|
||||
rowKey={(c) => c.id}
|
||||
/>
|
||||
<span className="block text-[11px] text-[var(--mu-text-lo)]">
|
||||
本页返回 {list.data.count} 条(后端列表默认上限 100、钳到 500;「到底有多少待定」的权威数字在批量回执的命中数里,本页不把它冒充成全部)。
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** MemoryTidyApplyBody 是候选卡的纯展示体(测试直接喂状态与动作替身)。 */
|
||||
export function MemoryTidyApplyBody({
|
||||
state,
|
||||
actions,
|
||||
}: {
|
||||
state: TidyApplyViewState;
|
||||
actions: TidyApplyActions;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">记忆整理候选(应用 / 回滚)</h3>
|
||||
<Tag tone="accent">{TIDY_APPLY_DISCLAIMER}</Tag>
|
||||
<Tag tone="warn">写操作 · 已审计</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
GET/POST /api/v1/admin/memories/candidates…
|
||||
</span>
|
||||
</div>
|
||||
<Toolbar state={state} actions={actions} />
|
||||
{state.fail ? <Notice tone="err">操作失败:{state.fail}</Notice> : null}
|
||||
{state.batch ? <BatchReceipt batch={state.batch} /> : null}
|
||||
{state.run ? <RunReceipt run={state.run} /> : null}
|
||||
{state.last ? <SingleReceipt result={state.last} /> : null}
|
||||
<CandidatesSection state={state} actions={actions} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/** MemoryTidyApplyCard 是记忆管理页挂的候选卡(取数 + 写动作走 useTidyApplyState)。 */
|
||||
export function MemoryTidyApplyCard(): React.ReactNode {
|
||||
const { state, actions } = useTidyApplyState();
|
||||
return <MemoryTidyApplyBody state={state} actions={actions} />;
|
||||
}
|
||||
@@ -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:<id>` / `revert:<id>` / `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<TidyApplyActions, "onStatusChange">;
|
||||
|
||||
/** 一次写动作的收尾器:busy 标记 + 失败文案 + 客户端审计 + 成功收起确认。 */
|
||||
type TidyAct = (label: string, audit: string, task: () => Promise<void>) => void;
|
||||
|
||||
/** useTidyCandidateList 取候选列表,并把「屏幕上这批数据属于哪次请求」记在
|
||||
* `dataStatus` 里:切筛选时旧数据**不许**冒充新筛选的结果(在途期间只显示加载态)。
|
||||
*
|
||||
* `dataStatus` 在**成功与失败**两条路上都记 —— 否则新筛选请求失败时它会停在旧值,
|
||||
* 界面会永远卡在「加载中」,把失败也一起藏掉。 */
|
||||
function useTidyCandidateList(status: string): {
|
||||
list: TidyCandidateListState;
|
||||
reload: () => void;
|
||||
} {
|
||||
const [dataStatus, setDataStatus] = useState<string>(status);
|
||||
const fetchList = (): Promise<AdminTidyCandidatesResponse> => {
|
||||
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<string | null>(null);
|
||||
const [confirming, setConfirming] = useState<TidyConfirmKind | null>(null);
|
||||
const [batch, setBatch] = useState<AdminTidyApplyBatch | null>(null);
|
||||
const [run, setRun] = useState<AdminTidyRunResult | null>(null);
|
||||
const [last, setLast] = useState<AdminTidyApplyResult | null>(null);
|
||||
const [fail, setFail] = useState<string | null>(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<string>(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();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user