feat(memory): 记忆整理「试算(dry-run)」入口 + 面板卡(契约 v0.75)
主人要「现在就想看到在 736 条上它会建议合并哪些」,而不是等 23:00 的低峰首跑。
- 后端:Tidier.DryRun + TidyService.DryRun,**复用同一条扫描→召回→判定→构造候选路径**(抽出 tidyRoundPolicy,生产轮与试算轮只差「候选出口」与「召回降级策略」两处;禁止第二套实现)
- 🔴 零副作用:不写 memory_tidy_candidates / memory_tidy_runs,绝不改 memory_items 任何列;机械证明 = 跑前跑后 cyrene.db 与 -wal 的 SHA256 一致(测试断言「表行数不变」)
- 失败码诚实:500 tidy_unavailable / 409 tidy_disabled / 500 tidy_recall_unavailable(**embedding 不可用不降级成 0 条**)/ 503 tidy_timeout / 500 tidy_dry_run_failed;**空数组唯一含义 = 算过了没建议**
- 端点 POST /api/v1/admin/memories/tidy/dry-run(writeAuth);面板「记忆」页加试算卡(点了才跑、无轮询、三态分开、明写「仅试算,不会改动任何记忆」)
- 真机 e2e(741 条活库副本):76 ms / 15 分区 / 53 候选 / 41 skipped / 1 vetoed(low_confidence);样例 merge reason=fact_subset score=1.0000、link reason=similar_but_not_contained score=0.9248
- 变异:dry-run 顺手写库 ⇒ 两条测试红(行数 0→2);自造阈值 ⇒ 红;失败回 200 空数组 ⇒ 红
- 如实登记:Compared=688 被 400/分区护栏截断、候选上限 20/分区被打满 ⇒ 护栏值需在 741 条量级重定(非 bug)
This commit is contained in:
1 parent
3d8c30dc99
commit
d6ed9cfa2b
18 files changed
+1631
-79
No files matched your search
@@ -7,6 +7,7 @@ import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
|
||||
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
|
||||
|
||||
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
|
||||
user: "accent",
|
||||
@@ -185,6 +186,8 @@ export function MemoriesModule(): React.ReactNode {
|
||||
<MemoryTable rows={loading ? null : filtered} loading={loading} error={error} />
|
||||
</Surface>
|
||||
|
||||
<MemoryTidyDryRunCard />
|
||||
|
||||
<MemoryTidyFactsCard />
|
||||
|
||||
<Notice>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// 记忆整理「试算」卡的**真实渲染**测试(契约 v0.75 §4.12;切片 MT1)。
|
||||
//
|
||||
// 为什么需要它:这张卡要回答的是「它会建议合并哪些」,而修好之后的三条关键断言
|
||||
// 都是**文案与呈现**:
|
||||
// ① 有候选:动作/理由/分数/id 去向/diff 预览都要一眼读得出来;
|
||||
// ② 空:必须说明「算完了但没建议」**不等于**「库里没有重复」(否则空态会被读成
|
||||
// 「库很干净」);
|
||||
// ③ 错误:**显示错误、绝不显示 0**(把失败画成「0 条候选」就是把「没算出来」
|
||||
// 冒充成结论 —— 这正是本项目面板明令禁止的那一类)。
|
||||
//
|
||||
// 跑法(与 ConfigFactsCards.render.test.ts 同):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyDryRunCard.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-memory-tidy-dryrun.render.test.cjs \
|
||||
// && node /tmp/cyrene-memory-tidy-dryrun.render.test.cjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { AdminTidyDryRunResult } from "../../lib/types";
|
||||
import {
|
||||
MemoryTidyDryRunBody,
|
||||
TIDY_DRY_RUN_DISCLAIMER,
|
||||
TIDY_DRY_RUN_ERROR_HINT,
|
||||
type TidyDryRunState,
|
||||
} from "./MemoryTidyDryRunCard";
|
||||
|
||||
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 render(state: TidyDryRunState): string {
|
||||
return renderToStaticMarkup(createElement(MemoryTidyDryRunBody, { state, onRun: () => {} }));
|
||||
}
|
||||
|
||||
const RESULT: AdminTidyDryRunResult = {
|
||||
dryRun: true,
|
||||
recallMode: "embedding",
|
||||
durationMs: 42,
|
||||
ruleVersion: "t0.1",
|
||||
partitions: 2,
|
||||
scanned: 736,
|
||||
compared: 400,
|
||||
pairs: 5,
|
||||
vetoed: 2,
|
||||
vetoReasons: { scope: 1, temporal: 1 },
|
||||
candidates: [
|
||||
{
|
||||
action: "merge",
|
||||
sourceIds: ["mem-short", "mem-long"],
|
||||
targetId: "mem-long",
|
||||
personaId: "cyrene",
|
||||
scope: "persona",
|
||||
kind: "relationship",
|
||||
proposedContent: "主人常在深夜与助手聊天,有熬夜习惯",
|
||||
mergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||
reason: "containment_a_subset_b",
|
||||
ruleVersion: "t0.1",
|
||||
scoreMin: 0.95,
|
||||
scoreMax: 0.95,
|
||||
evidence: { similarity: 0.95, recall_mode: "embedding", containment: "a_subset_b" },
|
||||
},
|
||||
{
|
||||
action: "link",
|
||||
sourceIds: ["mem-redbull", "mem-iced"],
|
||||
personaId: "cyrene",
|
||||
scope: "persona",
|
||||
kind: "relationship",
|
||||
proposedContent: "",
|
||||
mergedContent: "",
|
||||
reason: "similar_but_not_contained",
|
||||
ruleVersion: "t0.1",
|
||||
scoreMin: 0.88,
|
||||
scoreMax: 0.88,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── ① 有候选:动作 / 理由 / 分数 / id 去向 / diff 预览 / n观测口径 ──────────
|
||||
const doneHtml = render({ kind: "done", result: RESULT });
|
||||
ok(has(doneHtml, TIDY_DRY_RUN_DISCLAIMER), "恒显示「仅试算,不会改动任何记忆」");
|
||||
ok(has(doneHtml, "并入"), "merge 动作要显示中文标签「并入」");
|
||||
ok(has(doneHtml, "只建链接"), "link 动作要显示「只建链接」(绝不合并)");
|
||||
ok(has(doneHtml, "containment_a_subset_b"), "必须显示机器可读理由");
|
||||
ok(has(doneHtml, "0.950"), "必须显示分数(区间)");
|
||||
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
|
||||
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
|
||||
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
|
||||
ok(has(doneHtml, "召回来源:embedding"), "必须显示召回来源(lexical 是降级信号)");
|
||||
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
|
||||
|
||||
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
|
||||
const truncatedHtml = render({
|
||||
kind: "done",
|
||||
result: { ...RESULT, truncated: ["cyrene/persona/(compare)"] },
|
||||
});
|
||||
ok(has(truncatedHtml, "被配额截断"), "命中护栏时必须如实说明");
|
||||
ok(has(truncatedHtml, "MEMORY_TIDY_SCAN_LIMIT") && has(truncatedHtml, "MEMORY_TIDY_CANDIDATE_LIMIT"), "要指向真键名");
|
||||
|
||||
// ── ② 空:说清「算完了但没建议」≠「没有重复」 ──────────────────────────────
|
||||
const emptyHtml = render({ kind: "done", result: { ...RESULT, candidates: [], pairs: 0, vetoed: 0, vetoReasons: {} } });
|
||||
ok(has(emptyHtml, "这一轮没有产生任何候选"), "空态必须明说「没有产生候选」");
|
||||
ok(has(emptyHtml, "不等于"), "空态必须说清「不等于库里没有重复」");
|
||||
ok(has(emptyHtml, "召回门"), "空态要解释候选的三道门(召回门 / 包含判定 / 规则否决)");
|
||||
ok(!has(emptyHtml, "建议 0 条"), "空态不得渲染成「建议 0 条」(那不是失败文案也不是空态文案)");
|
||||
|
||||
// ── ③ 错误:显示错误、绝不显示 0 ────────────────────────────────────────────
|
||||
const errorHtml = render({ kind: "error", message: "语义召回(embedding)不可用,试算未产出候选" });
|
||||
ok(has(errorHtml, "试算失败"), "错误态必须显式标出失败");
|
||||
ok(has(errorHtml, "语义召回(embedding)不可用"), "必须显示后端给的原因");
|
||||
ok(has(errorHtml, TIDY_DRY_RUN_ERROR_HINT), "必须说清「没算出来」≠「算过了、没有建议」");
|
||||
ok(!has(errorHtml, "算完了"), "错误态绝不得渲染完成态标记(「算完了,一行都没写」)");
|
||||
ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/候选数)—— 把失败画成 0 条就是把失败当结论");
|
||||
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
|
||||
ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」");
|
||||
|
||||
// ── 未跑 / 在跑 ────────────────────────────────────────────────────────────
|
||||
const idleHtml = render({ kind: "idle" });
|
||||
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
|
||||
ok(has(idleHtml, "23:00"), "未跑时要解释「整理器只在低峰时段真跑」");
|
||||
ok(has(idleHtml, "不会自动"), "未跑时要说明这是按需试算、不自动刷新");
|
||||
|
||||
const runningHtml = render({ kind: "running" });
|
||||
ok(has(runningHtml, "试算中…"), "在跑时按钮显示进行态");
|
||||
ok(has(runningHtml, "disabled"), "在跑时按钮必须禁用(防止重复触发)");
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`MemoryTidyDryRunCard.render: ${checks} checks passed`);
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from "react";
|
||||
import { adminMemoryTidyDryRun } from "../../lib/api";
|
||||
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { Notice } from "../components";
|
||||
import { useAuditStore } from "../audit";
|
||||
|
||||
/**
|
||||
* 记忆整理「试算」卡(契约 v0.75 §4.12;切片 MT1)。
|
||||
*
|
||||
* 它回答的是「**现在就想看到整理会建议合并哪些**」,而不必等到低峰时段那一轮:
|
||||
* 点击才跑(**不轮询** —— 试算是一次真实规则计算,不是看板指标),跑完把候选摊在
|
||||
* 这里。后端保证**零副作用**(候选直接回在响应里,一行都不写),因此卡片的文案把
|
||||
* 这件事明确写出来(`仅试算,不会改动任何记忆`)。
|
||||
*
|
||||
* 三个态的呈现**刻意分开**,因为它们的产品含义不同:
|
||||
* - 有候选:逐条展示「哪些条 → 建议怎么处理 + 理由 + 分数区间 + diff 预览」;
|
||||
* - 空:**算完了但没有建议**(要说明这不是「没有重复」,而是被召回门/包含判定/
|
||||
* 规则否决挡下了);
|
||||
* - 错误:**显示错误,绝不显示 0** —— 把失败画成「0 条候选」等于把「没算出来」
|
||||
* 冒充成「算过了,没有」,正是本项目面板明令禁止的那一类。
|
||||
*
|
||||
* 展示与取数分层:`MemoryTidyDryRunBody` 只吃状态(可用真实渲染测试直接喂
|
||||
* fixture),卡组件负责取数 + 局部状态。
|
||||
*/
|
||||
|
||||
/** 诚实文案:整张卡的核心承诺(测试钉住它必须出现)。 */
|
||||
export const TIDY_DRY_RUN_DISCLAIMER = "仅试算,不会改动任何记忆";
|
||||
|
||||
/** 错误态的说明:区分「没算出来」与「算过了,没有」。 */
|
||||
export const TIDY_DRY_RUN_ERROR_HINT = "这是「没算出来」,不是「算过了、没有建议」";
|
||||
|
||||
/** 尝试按钮文案。 */
|
||||
const RUN_LABEL = "试算整理建议";
|
||||
|
||||
/** 面板状态机(idle / running / done / error)。 */
|
||||
export type TidyDryRunState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "running" }
|
||||
| { kind: "done"; result: AdminTidyDryRunResult }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
/** ActionTag 渲染候选动作标签;未知动作原样显示(不吞信息)。 */
|
||||
function ActionTag({ action }: { action: string }): React.ReactNode {
|
||||
return <Tag tone={actionTone[action] ?? "neutral"}>{actionLabel[action] ?? action}</Tag>;
|
||||
}
|
||||
|
||||
/** VoteReasonLine 渲染规则否决分布(它是「没有建议」时最有用的解释)。 */
|
||||
function VetoReasonLine({ reasons }: { reasons: Record<string, number> }): React.ReactNode {
|
||||
const entries = Object.entries(reasons).sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<span className="text-[11px] text-[var(--mu-text-mid)]">
|
||||
否决分布(规则把哪些相似对挡在候选之外):
|
||||
{entries.map(([reason, count]) => (
|
||||
<span key={reason} className="ml-2 font-mono">
|
||||
{reason}={count}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** scoreLabel 渲染分数区间(当前实现一对一个分数,因此多数时候两端相等)。 */
|
||||
function scoreLabel(candidate: AdminTidyDryRunCandidate): string {
|
||||
if (candidate.scoreMin === candidate.scoreMax) return candidate.scoreMin.toFixed(3);
|
||||
return `${candidate.scoreMin.toFixed(3)}–${candidate.scoreMax.toFixed(3)}`;
|
||||
}
|
||||
|
||||
/** CandidateRow 渲染一条候选:动作 + 理由 + 分数 + id 去向 + diff 预览。 */
|
||||
function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): React.ReactNode {
|
||||
const target = candidate.targetId ? ` → 保留 ${candidate.targetId}` : "";
|
||||
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">
|
||||
<ActionTag action={candidate.action} />
|
||||
<span className="font-mono text-[var(--mu-text-lo)]">{scoreLabel(candidate)}</span>
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{candidate.reason}</span>
|
||||
<Tag tone="neutral">{candidate.scope}</Tag>
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-[var(--mu-text-mid)]">
|
||||
{candidate.sourceIds.join(", ")}
|
||||
{target}
|
||||
</div>
|
||||
{candidate.proposedContent ? (
|
||||
<div className="text-xs text-[var(--mu-text-mid)]">
|
||||
<span className="text-[var(--mu-text-lo)]">被并入:</span>
|
||||
{candidate.proposedContent}
|
||||
</div>
|
||||
) : null}
|
||||
{candidate.mergedContent ? (
|
||||
<div className="text-xs text-[var(--mu-text-mid)]">
|
||||
<span className="text-[var(--mu-text-lo)]">存活条目:</span>
|
||||
{candidate.mergedContent}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */
|
||||
function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode {
|
||||
const vetoReasons = result.vetoReasons ?? {};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Tag tone="ok">算完了,一行都没写</Tag>
|
||||
<Tag tone={result.recallMode === "embedding" ? "accent" : "warn"}>召回来源:{result.recallMode}</Tag>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
|
||||
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
|
||||
</span>
|
||||
</div>
|
||||
{result.truncated && result.truncated.length > 0 ? (
|
||||
<Notice tone="warn">
|
||||
本轮被配额截断:{result.truncated.join("、")} —— 这一轮看到的比库里少,调大
|
||||
<span className="font-mono"> MEMORY_TIDY_SCAN_LIMIT</span> /{" "}
|
||||
<span className="font-mono">MEMORY_TIDY_CANDIDATE_LIMIT</span> 后重试。
|
||||
</Notice>
|
||||
) : null}
|
||||
<VetoReasonLine reasons={vetoReasons} />
|
||||
{result.candidates.length === 0 ? (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
|
||||
<span className="text-xs text-[var(--mu-text-hi)]">这一轮没有产生任何候选。</span>
|
||||
<p className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
这意味着「跑完了但没建议」,不等于「库里没有重复」:候选必须先过召回门(相似度只用来召回),
|
||||
再通过包含判定(A 的事实集合 ⊆ B 才并入),最后还要逐条过规则否决(跨作用域 / 跨角色 / 跨项目 /
|
||||
群聊来源 / 时效污染 / 数值不一致 / 否定冲突 / 低置信)。上面的否决分布就是「哪些相似对被挡下」。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-[var(--mu-text-mid)]">建议 {result.candidates.length} 条(均未应用):</span>
|
||||
{result.candidates.map((candidate, index) => (
|
||||
<CandidateRow
|
||||
key={`${candidate.action}-${candidate.sourceIds.join(",")}-${index}`}
|
||||
candidate={candidate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** StateBody 渲染「未跑 / 在跑 / 失败 / 完成」四态中的非按钮部分。 */
|
||||
function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
|
||||
switch (state.kind) {
|
||||
case "idle":
|
||||
return (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
点一下就跑一轮「同样的整理规则」,把「它会建议合并 / 归档 / 建链接哪些」摊开给你看。整理器只在低峰
|
||||
时段(默认 23:00–07:00)真跑,平时库里的候选队列是空的 —— 这是一次按需试算,不是看板,所以不会自动
|
||||
刷新。
|
||||
</span>
|
||||
);
|
||||
case "running":
|
||||
return <span className="text-xs text-[var(--mu-text-lo)]">试算中…(只读计算,不会改动任何记忆)</span>;
|
||||
case "error":
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Notice tone="err">试算失败:{state.message}</Notice>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case "done":
|
||||
return <ResultBody result={state.result} />;
|
||||
}
|
||||
}
|
||||
|
||||
/** MemoryTidyDryRunBody 是试算卡的纯展示体(测试直接喂状态)。 */
|
||||
export function MemoryTidyDryRunBody({
|
||||
state,
|
||||
onRun,
|
||||
}: {
|
||||
state: TidyDryRunState;
|
||||
onRun: () => void;
|
||||
}): 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_DRY_RUN_DISCLAIMER}</Tag>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">POST /api/v1/admin/memories/tidy/dry-run</span>
|
||||
<span className="ml-auto">
|
||||
<Button variant="primary" disabled={state.kind === "running"} onClick={onRun}>
|
||||
{state.kind === "running" ? "试算中…" : RUN_LABEL}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<StateBody state={state} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/** MemoryTidyDryRunCard 是记忆管理页挂的试算卡(局部状态 + 点击才跑)。 */
|
||||
export function MemoryTidyDryRunCard(): React.ReactNode {
|
||||
const [state, setState] = useState<TidyDryRunState>({ kind: "idle" });
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
const run = (): void => {
|
||||
setState({ kind: "running" });
|
||||
adminMemoryTidyDryRun()
|
||||
.then((result) => {
|
||||
log("memory.tidy.dryRun", "ok");
|
||||
setState({ kind: "done", result });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("memory.tidy.dryRun", "error");
|
||||
setState({ kind: "error", message: e instanceof Error ? e.message : "试算失败" });
|
||||
});
|
||||
};
|
||||
|
||||
return <MemoryTidyDryRunBody state={state} onRun={run} />;
|
||||
}
|
||||
Reference in new issue
Block a user