Files
mini-openclaw-v2/src/components/xingbang/ExperienceCenter.tsx
T
qiaoqichaoandClaude Code c880f40e4c fix(v2): 修掉预审面板不刷新与候选行同 id 连开多行的缺陷
真实 Chrome 端到端验证第二篇(/prescreen-workbench、/experience-center)发现两个
源码守卫与生产构建都看不见的缺陷:

- 发起 AI 预审、运行结束后 PrescreenPanel 不重载:新运行进不了"查看哪一次运行",
  面板继续显示上一次运行,而徽标写着"结果会自动刷新"。根因是 load() 只依赖
  project.id;补修时又发现 worker 先写 run.status、再另写 prescreen_run 视图
  (assistant_worker.process_next),两者不在同一事务,只读一次会读到视图还没
  落库的中间态,因此改为按运行集合重载并在最新运行进入视图前短时重试。
- 候选区 key={card.id} 撞 React key,且「自然语言修改适用范围」「补充独立测试」
  按 card.id 开合,点一行连开 4 行(同一 card.id 的草稿行与各版本行共享 id)。
  改为行级 rowKey(组件id@版本@状态)。

同时新增可选脚本 scripts/browser-e2e-modules.cjs(42/42 通过),并给
test_prescreen_ui.cjs / test_experience_center_ui.cjs 补上对应源码断言。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 09:35:29 +08:00

263 lines
16 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.
"use client";
import {useEffect, useState} from "react";
import {AlertTriangle, CheckCircle2, FileText, HelpCircle, History, Pencil, RefreshCw, Send, ShieldCheck, Wrench} from "lucide-react";
import {PRESCREEN} from "@/lib/prescreen";
import {originBadge} from "@/lib/prescreen-run";
/** 历史经验加工中心(纠偏第二批 · BR-48 / BR-49 / BR-50、AC-50/51/52)。
*
* 挂在独立路由 /experience-center 上(BR-50:三个模块各有独立入口,不得只在旧页下叠加面板)。
* 复用 workspace.css 既有 class,**不新增样式表**;来源徽标用 prescreen-run.originBadge,
* 并配 .origin-* 样式与图标,满足"文字+图标+样式三者齐全、去色仍可区分"(AC-42/BR-48)。
*
* 三条界面纪律:
* 1. **失败就是失败**:提炼记录里的 E-28 原样显示,不把失败渲染成"已生成草稿"。
* 2. **来源不可洗**:历史提炼与人工创建用不同文字+图标+底色,修订后仍显示历史来源。
* 3. **不自批**:同一个身份既编辑又发布时不显示发布按钮(后端另有强制,界面不诱导违规)。
*/
const ORIGIN_ICONS: Record<string, any> = {
history: History, pencil: Pencil, "file-text": FileText, "help-circle": HelpCircle,
};
const CARD_STATUS: Record<string, string> = {
draft: "草稿", published: "已发布", rejected: "已拒绝", withdrawn: "已撤回",
};
/** 候选行的稳定标识。列表里**同一个 card.id 会出现多行**(草稿行与各版本行共享 id,
* 见 knowledge_cards.list_cards),所以 React key 与"展开哪一行的面板"都不能只用 card.id:
* 只用 id 会撞 key,点一行的「自然语言修改适用范围」还会连开同 id 的所有行——
* 这两点都是真实浏览器里量到的(见 17 四批交付记录「浏览器验证发现的问题(第二篇)」)。
* (id, version, status) 在注册表里唯一:同一 (id, version) 的草稿行与版本行 status 不同。 */
const rowKey = (card: any) => `${card.id}@${card.version}@${card.status}`;
// 组方案时的基座核查项:与种子方案一致,保证新方案能独立跑通(不是"只放卡片"的半成品方案)。
const BASE_SKILLS = ["corporate.consistency", "corporate.cashflow", "corporate.assets", "corporate.coverage"];
function OriginBadge({origin}: {origin: string}) {
const badge = originBadge(origin);
const Icon = ORIGIN_ICONS[badge.icon] || HelpCircle;
return <span className={`xb-origin ${badge.tone}`}><Icon size={13} aria-hidden="true"/>{badge.text}</span>;
}
export default function ExperienceCenter({project, identity, send}: any) {
const [timeline, setTimeline] = useState<any>(null);
const [timelineMissing, setTimelineMissing] = useState(false);
const [extractions, setExtractions] = useState<any[]>([]);
const [candidates, setCandidates] = useState<any[]>([]);
const [plans, setPlans] = useState<any[]>([]);
const [requirement, setRequirement] = useState("");
const [picked, setPicked] = useState<string[]>([]);
const [reviseFor, setReviseFor] = useState("");
const [reviseText, setReviseText] = useState("");
const [reviseNote, setReviseNote] = useState("");
const [casesFor, setCasesFor] = useState("");
const [casesText, setCasesText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [loaded, setLoaded] = useState(false);
const path = `/projects/${encodeURIComponent(project.id)}`;
const canReview = Boolean(identity?.preview || identity?.role === "reviewer" || identity?.role === "admin");
const canPublish = Boolean(identity?.preview || identity?.role === "reviewer");
// 不自批:提议/编辑的人不能看到自己的发布按钮
const mayPublish = (card: any) => canPublish && card.edited_by !== identity?.id;
async function refresh() {
try {
const [found, cards, planList] = await Promise.all([
send("GET", `${PRESCREEN}${path}/knowledge-extractions`),
send("GET", `${PRESCREEN}${path}/knowledge-candidates`),
send("GET", `${PRESCREEN}${path}/review-plans`),
]);
setExtractions(found.items || []);
setCandidates(cards.items || []);
setPlans(planList.items || []);
} catch (e: any) {setError(e.message);}
try {
setTimeline(await send("GET", `${PRESCREEN}${path}/case-timeline`));
setTimelineMissing(false);
} catch {setTimeline(null); setTimelineMissing(true);}
setLoaded(true);
}
useEffect(() => {refresh();}, [project.id]);
async function act(work: () => Promise<any>, message: string) {
setBusy(true); setError(""); setNotice("");
try {const result = await work(); setNotice(message); await refresh(); return result;}
catch (e: any) {setError(e.message); return null;} finally {setBusy(false);}
}
const events = timeline?.events || [];
const buildTimeline = () => act(
() => send("POST", `${PRESCREEN}${path}/case-timeline`),
"已从项目材料重建时间线;缺发生时间的事件按缺证显示,不推断日期。");
const requestExtraction = () => act(
() => send("POST", `${PRESCREEN}${path}/knowledge-extractions`, {requirement, event_ids: picked}),
"提炼作业已结束;请查看下方记录——失败会给出未完成原因。");
const revise = (card: any) => act(
() => send("PATCH", `${PRESCREEN}/knowledge-candidates/${encodeURIComponent(card.id)}`,
{requirement: reviseText, note: reviseNote}),
"已形成草稿修订;不会自动发布,也不改变已发布版本。");
const saveCases = (card: any) => {
let parsed: any;
try {parsed = JSON.parse(casesText);}
catch {setError("测试样本必须是合法 JSON 数组。"); return Promise.resolve(null);}
return act(
() => send("PATCH", `${PRESCREEN}/knowledge-candidates/${encodeURIComponent(card.id)}`,
{changes: {test_cases: parsed}, note: "补充独立测试样本"}),
"测试样本已保存到草稿;发布前必须能通过隔离评测。");
};
const transition = (card: any, action: string) => act(
() => send("POST", `${PRESCREEN}/knowledge-candidates/${encodeURIComponent(card.id)}/transitions`, {action}),
action === "evaluate" ? "已完成隔离评测。" : "已发布;后续运行可使用该版本。");
const assemble = (card: any) => act(
() => send("POST", `${PRESCREEN}${path}/review-plans`, {
name: `含「${card.title}」的预审方案`,
skills: [...BASE_SKILLS, card.id], rules: ["demo.coverage"],
template: "template.ai-pre-review", audience: ["manager"], run_kind: "pre_review",
budget: {timeout_seconds: 180, max_tool_calls: 18},
}),
"方案草稿已装配;需评测与独立发布后,新运行才会使用该卡片。");
const planTransition = (plan: any, action: string) => act(
() => send("POST", `${PRESCREEN}/review-plans/${encodeURIComponent(plan.id)}/transitions`, {action}),
action === "evaluate" ? "方案评测已完成。" : "方案已发布;新运行将使用其中的卡片版本。");
return <>
{error && <div role="alert" className="ra-alert error">{error}</div>}
{notice && <div role="status" className="ra-alert">{notice}</div>}
{!loaded && <p>正在读取历史过程…</p>}
<section className="xb-panel xb-wide">
<h2>① 来源:历史过程时间线</h2>
<p>时间线由项目材料<b>确定性</b>恢复,不调用模型、不编造经过。只有最终报告时会标"过程材料不完整",不生成虚构的问询顺序。</p>
<div className="xb-actions">
<button className="ra-button" disabled={busy} onClick={buildTimeline}><RefreshCw size={15}/>从项目材料重建时间线</button>
{timeline && <span className="ra-badge">{timeline.status === "partial" ? "过程材料不完整" : "已恢复"} · {events.length} 个事件</span>}
</div>
{timelineMissing && <div className="ra-empty"><h2>尚未重建时间线</h2><p>先重建时间线,提炼才有可追溯的来源。</p></div>}
{timeline?.warnings?.map((item: string, index: number) =>
<p className="xb-warning" key={index}><AlertTriangle size={13}/> {item}</p>)}
{events.map((event: any) => <div className="xb-finding" key={event.id}>
<div>
<label className="ra-field"><span>
<input type="checkbox" aria-label={`选择来源事件 ${event.id}`} disabled={busy}
checked={picked.includes(event.id)}
onChange={(e) => setPicked(e.target.checked ? [...picked, event.id] : picked.filter((id) => id !== event.id))}/>
{" "}{event.occurred_at ? new Date(event.occurred_at).toLocaleDateString() : "未标注发生时间(缺证)"} · {event.event_type}
</span></label>
<p>{event.question || "(无问询文本)"}</p>
{(event.judgment_before || event.judgment_after) &&
<p><small>{event.judgment_before || "—"} → {event.judgment_after || "—"}</small></p>}
</div>
</div>)}
</section>
<section className="xb-panel xb-wide">
<h2>② 提炼:用真实模型生成候选</h2>
<p>提炼会<b>实际调用</b>已配置的模型。模型不可用时明确记为未完成(E-28),并且<b>不产生任何候选草稿</b>——这里不会用一个固定返回值冒充提炼成功。</p>
<label className="ra-field"><span>提炼要求(可选,只约束本次加工,不成为正式制度)</span>
<input value={requirement} disabled={busy} placeholder="例如:重点整理现金流与利润不一致的核查方法"
onChange={(e) => setRequirement(e.target.value)}/>
</label>
<div className="xb-actions">
<button className="ra-button primary" disabled={busy || !timeline} onClick={requestExtraction}><Send size={15}/>请求提炼</button>
<span className="ra-badge">已选 {picked.length} 个来源事件{timeline ? "" : "(需先重建时间线)"}</span>
</div>
{!extractions.length && <p className="ra-note">还没有提炼记录。</p>}
{extractions.map((record: any) => <div className="xb-finding" key={record.id}>
<div>
<b>{record.status === "completed" ? "提炼完成" : "提炼未完成"} · {record.model || "未调用模型"}</b>
<p>提示版本 {record.prompt_version} · 来源事件 {record.source_event_ids.length} 个 · 时间 {new Date(record.created_at).toLocaleString()}</p>
{record.candidate_id && <p>候选草稿:<code>{record.candidate_id}</code></p>}
{record.degradations?.map((item: any, index: number) =>
<p className="xb-warning" key={index}><AlertTriangle size={13}/> {item.message}({item.code})</p>)}
</div>
</div>)}
</section>
<section className="xb-panel xb-wide">
<h2>③ 候选:专家修订、评测与发布</h2>
<p>修订只产生草稿,不直接生效;发布需要先通过隔离评测,并由<b>另一位</b>有发布资格的人操作。已发布版本不可覆盖,修订会升版本并保留父版本。</p>
{!candidates.length && <div className="ra-empty"><h2>尚无知识候选</h2><p>先提炼或人工新增一张候选卡片。</p></div>}
{candidates.map((card: any) => <div className="xb-finding" key={rowKey(card)}>
<div>
<b>{card.title}</b>
<p>
<OriginBadge origin={card.origin}/>
{" "}<span className="ra-badge">{CARD_STATUS[card.status] || card.status}</span>
{" "}<span className="ra-badge">v{card.version} · revision {card.revision}</span>
{" "}<span className="ra-badge">{card.data_domain === "demo" ? "演示数据域" : "正式数据域"}</span>
</p>
<p>{card.kind === "skill" ? "Skill(核查方法)" : card.kind} · 适用条件 {card.conditions.length} 条 · 例外 {card.exceptions.length} 条
{card.parent_version ? ` · 父版本 ${card.parent_version.version}` : ""}</p>
<p><small>编辑人 {card.edited_by}{card.reviewer_id ? ` · 审核人 ${card.reviewer_id}` : ""}</small></p>
<div className="xb-actions">
<button className="ra-button" disabled={busy} onClick={() => {setReviseFor(reviseFor === rowKey(card) ? "" : rowKey(card)); setReviseText(""); setReviseNote("");}}>
自然语言修改适用范围
</button>
<button className="ra-button" disabled={busy} onClick={() => {setCasesFor(casesFor === rowKey(card) ? "" : rowKey(card)); setCasesText("");}}>
补充独立测试样本
</button>
{canReview && <button className="ra-button" disabled={busy} onClick={() => transition(card, "evaluate")}>隔离评测</button>}
{mayPublish(card) && card.status !== "published" &&
<button className="ra-button primary" disabled={busy} onClick={() => transition(card, "publish")}>
<ShieldCheck size={15}/>审核发布
</button>}
{card.status === "published" && <button className="ra-button" disabled={busy} onClick={() => assemble(card)}>
<Wrench size={15}/>选入新方案(基座核查项 + 本卡片)
</button>}
</div>
{canPublish && card.edited_by === identity?.id &&
<p className="ra-note">本卡由你本人提议或编辑,因此不显示发布按钮:发布需要独立审核人(BR-42 / AC-43)。</p>}
{reviseFor === rowKey(card) && <div className="xb-task">
<label className="ra-field"><span>用自然语言说明要改什么(必须真实调用模型解析)</span>
<input value={reviseText} disabled={busy} placeholder="例如:只适用于设备类租赁,并增加关联交易例外"
onChange={(e) => setReviseText(e.target.value)}/>
</label>
<label className="ra-field"><span>变更说明(必填,会随修订留痕)</span>
<input value={reviseNote} disabled={busy} onChange={(e) => setReviseNote(e.target.value)}/>
</label>
<button className="ra-button" disabled={busy || !reviseText.trim() || !reviseNote.trim()} onClick={() => revise(card)}>形成草稿修订</button>
</div>}
{casesFor === rowKey(card) && <div className="xb-task">
<label className="ra-field"><span>独立测试样本(JSON 数组;自定义 Skill 的发布门禁要求)</span>
<textarea className="ra-code" value={casesText} disabled={busy}
placeholder='[{"name":"缺证样本","values":{"financial.operating_cashflow":null},"expected_outputs":{},"expected_checks":{"history.repayment.dependency":"unknown"}}]'
onChange={(e) => setCasesText(e.target.value)}/>
</label>
<button className="ra-button" disabled={busy || !casesText.trim()} onClick={() => saveCases(card)}>保存样本到草稿</button>
</div>}
</div>
</div>)}
</section>
<section className="xb-panel xb-wide">
<h2>④ 方案:让卡片真正影响后续核查</h2>
<p>卡片只有在被选入<b>已发布方案</b>后才会影响运行;运行结果会冻结并记录卡片版本,核查项可追溯到 <code>卡片id@版本</code>。</p>
{!plans.length && <p className="ra-note">还没有项目自建方案。在上方已发布卡片上点"选入新方案"即可装配。</p>}
{plans.map((plan: any) => <div className="xb-finding" key={`${plan.id}@${plan.version}`}>
<div>
<b>{plan.name || plan.id}</b>
<p><span className="ra-badge">{CARD_STATUS[plan.status] || plan.status}</span> v{plan.version} · 核查项 {plan.skills.length} 个</p>
<p><small>选入的卡片:{plan.skills.filter((key: string) => !BASE_SKILLS.includes(key)).join("、") || "(无)"}</small></p>
</div>
{canReview && plan.status !== "published" &&
<button className="ra-button" disabled={busy} onClick={() => planTransition(plan, "evaluate")}>评测方案</button>}
{canPublish && plan.status !== "published" &&
<button className="ra-button primary" disabled={busy} onClick={() => planTransition(plan, "publish")}>发布方案</button>}
{plan.status === "published" && <span className="ra-badge"><CheckCircle2 size={13}/> 可用于新运行</span>}
</div>)}
</section>
</>;
}