Files
Cyrene_v2/frontend/web/src/admin/modules/memoryTidyApplyState.ts
T
AskaEth c512609c63
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 凭据 + 真后端),证据止于渲染层与传输层。
2026-10-07 09:58:21 +08:00

241 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
},
},
};
}