feat(maf): Phase 6 - migrate frontend workbench to tdesign-react and extract shared desensitize hook

This commit is contained in:
fengxiangdebaozi committed 2026-08-02 14:33:57 +08:00
1 parent a55992e31c
commit 889a97a04d
10 files changed
+641 -458

No files matched your search

@@ -0,0 +1,70 @@
/**
* 区域级脱敏 Hook —— 把某个容器(DOM 子树)里的**所有文本节点**做脱敏,
* 用于「左/右侧面板 + 顶部标题」这类需要整片脱敏的区域:静态标题、动态内容、
* 嵌套子组件文本一并覆盖,无需逐串包 `desensitize()`(那样易漏、难维护)。
*
* 用法:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
*
* 设计要点:
* - 只读改文本节点(display-only),不碰 `<input>/<textarea>` 的 value、不改 React 状态/回传值;
* - `desensitize` 幂等(替换词不再命中词表),MutationObserver 重入安全;改写前先 disconnect 防自触发循环;
* - 想在区域内**保留原文**(如内嵌沙箱/消息列表),给该子树根加 `data-no-desensitize` 属性即可跳过。
*
* 注意:**不要**用它包「消息列表正文」或「沙箱产物预览」——这两处按需求保持原文,
* 应作为独立容器、不挂本 Hook(智能体名称的脱敏单独在 MessageBubble 的 senderName 处理)。
*
* 该 hook 最初位于 `roundtable-planning/lib/use-desensitize-subtree.ts`,因其属于通用脱敏基建
* (被 MAF 会商、业务链条等多模块共享)现抽到中性 `@/lib` 位置。旧路径保留 re-export 以零改动兼容。
*/
import { useEffect, useRef } from "react";
import { desensitize } from "@/lib/desensitize";
const _SKIP_TAGS = new Set(["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "IFRAME", "CODE", "PRE"]);
export function useDesensitizeSubtree<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
useEffect(() => {
const root = ref.current;
if (!root || typeof MutationObserver === "undefined") return;
let observer: MutationObserver | null = null;
const apply = () => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (_SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
// 显式标记 data-no-desensitize 的子树保留原文(如内嵌沙箱)。
if (parent.closest("[data-no-desensitize]")) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});
const edits: Array<[Text, string]> = [];
let current = walker.nextNode();
while (current) {
const textNode = current as Text;
const original = textNode.nodeValue ?? "";
if (original.trim()) {
const masked = desensitize(original);
if (masked !== original) edits.push([textNode, masked]);
}
current = walker.nextNode();
}
if (edits.length === 0) return;
// 改写期间停掉观察,避免自身改动再次触发,改完恢复。
observer?.disconnect();
for (const [textNode, masked] of edits) textNode.nodeValue = masked;
observer?.observe(root, { subtree: true, childList: true, characterData: true });
};
apply();
observer = new MutationObserver(() => apply());
observer.observe(root, { subtree: true, childList: true, characterData: true });
return () => observer?.disconnect();
});
return ref;
}
@@ -1,7 +1,7 @@
/**
* MAF 写工具审批卡片。
* MAF 写工具审批卡片(MAF-06-06:可视层改用本地 `tdesign-react`)。
*
* P0-4 对齐后端 schemas.py:
* 对齐后端 schemas.py:
* - 标题:action.tool_name,没有 action 时显示 approval.summary
* - 风险:展示 action.risk_level(R0–R4 配色)
* - 参数:展示 action.arguments(已脱敏)
@@ -9,17 +9,14 @@
* - 审批 payload:仅在有值时展示已脱敏 JSON
* - 备注 textarea 已删除(后端 ApprovalDecisionRequest 只接收 decision,不保存备注)
*
* P1:风险等级按 R0–R4 配色(R0/R1 低、R2 中、R3/R4 高)。
* P1:invalidate 使用页面传入的 runId,不依赖 approval.run_id 可选字段。
*
* 批准/拒绝禁用重复提交;invalidate 使用页面传入的 runId,不依赖 approval.run_id 可选字段。
* 仅 pending 审批渲染;approved/rejected 后由父组件过滤掉。
*/
import { Check, Loader2, ShieldCheck, X } from "lucide-react";
import { toast } from "sonner";
import { Button as TButton, Tag as TTag } from "tdesign-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useMafApprovalActions } from "../api/approvals";
import type { MafAction, MafApproval } from "../api/types";
@@ -36,21 +33,21 @@ function shortId(id: string | null): string {
}
/**
* 风险等级配色。后端实际使用 R0–R4(不是 low/medium/high):
* R0/R1 → 低风险(绿)
* R2 → 中风险(琥珀)
* R3/R4 → 高风险(红)
* 风险等级 → TDesign Tag 主题。后端实际使用 R0–R4(不是 low/medium/high):
* R0/R1 → 低风险(success)
* R2 → 中风险(warning)
* R3/R4 → 高风险(danger)
*/
const RISK_TONE: Record<string, string> = {
R0: "text-emerald-600 bg-emerald-50",
R1: "text-emerald-600 bg-emerald-50",
R2: "text-amber-600 bg-amber-50",
R3: "text-red-600 bg-red-50",
R4: "text-red-600 bg-red-50",
const RISK_THEME: Record<string, "success" | "warning" | "danger" | "default"> = {
R0: "success",
R1: "success",
R2: "warning",
R3: "danger",
R4: "danger",
// 兜底:后端可能返回旧值
low: "text-emerald-600 bg-emerald-50",
medium: "text-amber-600 bg-amber-50",
high: "text-red-600 bg-red-50",
low: "success",
medium: "warning",
high: "danger",
};
const RISK_LABEL: Record<string, string> = {
@@ -86,10 +83,10 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp
return (
<div className="rounded-xl border border-red-100 bg-white p-3 shadow-sm">
<header className="mb-2 flex items-center gap-1.5">
<Badge className="bg-red-50 text-red-600 hover:bg-red-50">
<ShieldCheck className="h-3 w-3" />
<TTag size="small" theme="danger" variant="light">
<ShieldCheck className="mr-0.5 h-3 w-3" />
需要你的确认
</Badge>
</TTag>
{action?.id && (
<span className="text-[10px] font-mono text-slate-400">
#{shortId(approval.action_id ?? action.id)}
@@ -106,9 +103,9 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp
{riskLevel && (
<div className="mb-2 flex items-center gap-1.5">
<span className="text-[10px] text-slate-400">风险等级:</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${RISK_TONE[riskLevel] ?? "text-slate-500 bg-slate-50"}`}>
<TTag size="small" theme={RISK_THEME[riskLevel] ?? "default"} variant="light-outline">
{RISK_LABEL[riskLevel] ?? riskLevel}
</span>
</TTag>
</div>
)}
@@ -145,26 +142,35 @@ export function MafApprovalCard({ approval, action, runId }: MafApprovalCardProp
{/* 操作按钮 */}
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
<TButton
theme="danger"
variant="outline"
size="small"
loading={pending}
disabled={pending}
icon={!pending ? <X className="h-3.5 w-3.5" /> : undefined}
onClick={() => onDecide("rejected")}
disabled={pending}
className="border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
>
{pending ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <X className="mr-1 h-3.5 w-3.5" />}
拒绝
</Button>
<Button
size="sm"
onClick={() => onDecide("approved")}
</TButton>
<TButton
theme="success"
size="small"
loading={pending}
disabled={pending}
className="bg-emerald-600 text-white hover:bg-emerald-700"
icon={!pending ? <Check className="h-3.5 w-3.5" /> : undefined}
onClick={() => onDecide("approved")}
>
{pending ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Check className="mr-1 h-3.5 w-3.5" />}
批准
</Button>
</TButton>
</div>
{pending && (
<div className="mt-1 flex items-center justify-center gap-1 text-[10px] text-slate-400">
<Loader2 className="h-3 w-3 animate-spin" />
提交中…
</div>
)}
</div>
);
}
@@ -1,17 +1,18 @@
/**
* MAF 事件时间线 —— 把折叠后的 TimelineEntry[] 渲染成可读的对话流。
*
* 复用 roundtable-planning 的 useAutoScroll + ScrollToBottomButton(滚动跟随语义)。
* 视觉上借鉴 MessageBubble(头像 + header + 正文),但 MAF 事件类型多样(消息/工具/
* 审批/run),这里用统一的「条目卡片」而非消息气泡——每条带类型徽标 + 时间 + 内容。
* MAF-06-05:可视层改用本地 `tdesign-react`(Timeline / Tag)。
* 滚动跟随逻辑自含(useMafAutoScroll),不再依赖 roundtable-planning 的 hook/组件。
* 视觉上每条带类型 Tag + 时间 + 内容;消息体走 Markdown,其余走纯文本。
*/
import { useRef } from "react";
import { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
FileText,
GitBranch,
Hammer,
@@ -23,10 +24,8 @@ import {
Workflow,
XCircle,
} from "lucide-react";
import { Tag as TTag, Timeline as TTimeline, TimelineItem as TTimelineItem } from "tdesign-react";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { shortTime, type TimelineEntry, type TimelineEntryKind } from "../lib/event-timeline";
@@ -37,7 +36,7 @@ interface MafEventTimelineProps {
streaming: boolean;
}
const KIND_ICON: Record<TimelineEntryKind, React.ReactNode> = {
const KIND_ICON: Record<TimelineEntryKind, React.ReactElement> = {
message: <MessageSquare className="h-3.5 w-3.5" />,
tool: <Hammer className="h-3.5 w-3.5" />,
skill: <Sparkles className="h-3.5 w-3.5" />,
@@ -48,15 +47,39 @@ const KIND_ICON: Record<TimelineEntryKind, React.ReactNode> = {
turn: <FileText className="h-3.5 w-3.5" />,
};
const KIND_TONE: Record<TimelineEntryKind, string> = {
message: "border-blue-100 bg-blue-50/40",
tool: "border-cyan-100 bg-cyan-50/40",
skill: "border-purple-100 bg-purple-50/40",
approval: "border-amber-100 bg-amber-50/50",
artifact: "border-emerald-100 bg-emerald-50/40",
run: "border-slate-200 bg-slate-50/60",
checkpoint: "border-slate-200 bg-slate-50/40",
turn: "border-slate-100 bg-white",
/** TimelineEntryKind → TDesign TimelineItem dotColor(支持 primary/warning/error/default 或 hex)。 */
const KIND_DOT_COLOR: Record<TimelineEntryKind, string> = {
message: "primary",
tool: "primary",
skill: "default",
approval: "warning",
artifact: "#00a870",
run: "default",
checkpoint: "default",
turn: "default",
};
/** kind → TDesign Tag 主题(用于卡片内类型标签)。 */
const KIND_TAG_THEME: Record<TimelineEntryKind, "default" | "primary" | "warning" | "success" | "danger"> = {
message: "primary",
tool: "primary",
skill: "default",
approval: "warning",
artifact: "success",
run: "default",
checkpoint: "default",
turn: "default",
};
const KIND_LABEL: Record<TimelineEntryKind, string> = {
message: "消息",
tool: "工具",
skill: "技能",
approval: "审批",
artifact: "产物",
run: "会商",
checkpoint: "检查点",
turn: "轮次",
};
function toolStatusIcon(entry: TimelineEntry): React.ReactNode {
@@ -68,20 +91,54 @@ function toolStatusIcon(entry: TimelineEntry): React.ReactNode {
return null;
}
/**
* 自含滚动跟随 hook —— 替代 roundtable-planning/useAutoScroll。
*
* 语义:用户在底部时新内容自动滚到底;用户上滚查看历史时不强制下拉,
* 只显示「回到底部」按钮。entry 数组变化时若原先在底部则跟随。
*/
function useMafAutoScroll<T extends HTMLElement>(deps: unknown[]) {
const scrollRef = useRef<T | null>(null);
const [atBottom, setAtBottom] = useState(true);
const isNearBottom = () => {
const el = scrollRef.current;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < 48;
};
const onScroll = () => setAtBottom(isNearBottom());
const scrollToBottom = () => {
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
setAtBottom(true);
};
// 内容变化时,若原先在底部则跟随滚到底
useEffect(() => {
if (atBottom) {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { scrollRef, atBottom, onScroll, scrollToBottom };
}
export function MafEventTimeline({ entries, streaming }: MafEventTimelineProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [entries], {
contentRef,
});
const { scrollRef, atBottom, onScroll, scrollToBottom } = useMafAutoScroll<HTMLDivElement>([entries]);
return (
<div className="relative flex h-full min-h-0 flex-1 flex-col">
<div
ref={scrollRef}
onScroll={onScroll}
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-slate-50/30"
>
<div ref={contentRef} className="space-y-2 p-4">
<div className="p-4">
{entries.length === 0 && !streaming && (
<div className="flex h-full items-center justify-center py-12 text-sm text-slate-400">
<AlertTriangle className="mr-1.5 h-4 w-4" />
@@ -89,12 +146,16 @@ export function MafEventTimeline({ entries, streaming }: MafEventTimelineProps)
</div>
)}
{entries.map((entry) => (
<TimelineEntryCard key={entry.key} entry={entry} />
))}
{entries.length > 0 && (
<TTimeline layout="vertical" mode="alternate">
{entries.map((entry) => (
<TimelineEntryItem key={entry.key} entry={entry} />
))}
</TTimeline>
)}
{streaming && (
<div className="flex items-center gap-2 py-2 pl-7 text-xs text-blue-500">
<div className="flex items-center gap-2 py-2 pl-2 text-xs text-blue-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
会商进行中…
</div>
@@ -103,42 +164,56 @@ export function MafEventTimeline({ entries, streaming }: MafEventTimelineProps)
</div>
{!atBottom && (
<ScrollToBottomButton onClick={scrollToBottom} label="回到底部" />
<button
type="button"
onClick={scrollToBottom}
className="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-600 shadow-md transition hover:bg-slate-50"
>
<ChevronDown className="h-3.5 w-3.5" />
回到底部
</button>
)}
</div>
);
}
function TimelineEntryCard({ entry }: { entry: TimelineEntry }) {
function TimelineEntryItem({ entry }: { entry: TimelineEntry }) {
const isMessage = entry.kind === "message";
return (
<div
className={cn(
"rounded-lg border px-3 py-2 transition",
KIND_TONE[entry.kind],
)}
<TTimelineItem
dot={KIND_ICON[entry.kind]}
dotColor={KIND_DOT_COLOR[entry.kind]}
>
<header className="mb-1 flex items-center gap-1.5">
<span className="text-slate-500">{KIND_ICON[entry.kind]}</span>
{entry.kind === "tool" && toolStatusIcon(entry)}
<span className="text-xs font-semibold text-slate-700">{entry.title}</span>
{entry.agentId && (
<Badge variant="outline" className="ml-1 text-[10px] text-slate-500">
{entry.agentId}
</Badge>
<div
className={cn(
"rounded-lg border px-3 py-2 transition",
isMessage ? "border-blue-100 bg-blue-50/40" : "border-slate-100 bg-white",
)}
<span className="ml-auto text-[10px] text-slate-400">{shortTime(entry.createdAt)}</span>
</header>
>
<header className="mb-1 flex items-center gap-1.5">
<TTag size="small" theme={KIND_TAG_THEME[entry.kind]} variant="light-outline">
{KIND_LABEL[entry.kind]}
</TTag>
{entry.kind === "tool" && toolStatusIcon(entry)}
<span className="text-xs font-semibold text-slate-700">{entry.title}</span>
{entry.agentId && (
<TTag size="small" theme="default" variant="outline">
{entry.agentId}
</TTag>
)}
<span className="ml-auto text-[10px] text-slate-400">{shortTime(entry.createdAt)}</span>
</header>
{entry.body && (
isMessage ? (
<div className="prose prose-sm max-w-none text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.body}</ReactMarkdown>
</div>
) : (
<p className="whitespace-pre-wrap break-words text-xs text-slate-600">{entry.body}</p>
)
)}
</div>
{entry.body && (
isMessage ? (
<div className="prose prose-sm max-w-none text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.body}</ReactMarkdown>
</div>
) : (
<p className="whitespace-pre-wrap break-words text-xs text-slate-600">{entry.body}</p>
)
)}
</div>
</TTimelineItem>
);
}
@@ -1,19 +1,17 @@
/**
* MAF 事实/结论流程图面板。
* MAF 事实/结论流程图面板(MAF-06-07:可视层改用本地 `tdesign-react`)。
*
* 包一层 roundtable-planning 的 FlowGraphPanel:数据来自 lib/fact-flow-graph.ts
* (confirmed_facts 证据图 + final_result 的 flow-json 结论图)。run live 时
* streaming=true 让蚂蚁线动;终态转实线。
* 包一层 roundtable-planning 的 FlowGraphPanel(通用图可视化基建,无 TDesign 等价):
* 数据来自 lib/fact-flow-graph.ts(confirmed_facts 证据图 + final_result 的 flow-json 结论图)。
* run live 时 streaming=true 让蚂蚁线动;终态转实线。
*
* FlowGraphPanel 是 portal 全屏弹窗风格(自带 onClose),这里用 Collapsible 折叠面板
* 内嵌一个固定高度的画布预览 + 「展开大图」按钮触发 FlowGraphPanel。
* TDesign Collapse 做折叠壳,内嵌固定高度的节点列表预览 + 「展开大图」按钮触发 FlowGraphPanel。
*/
import { useState } from "react";
import { GitBranch, Maximize2 } from "lucide-react";
import { Button as TButton, Collapse as TCollapse } from "tdesign-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
@@ -32,39 +30,40 @@ export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProp
const graph: FlowGraphData | null = buildMafFlowGraph(checkpoint);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
<TCollapse
value={open ? ["maf-flow"] : []}
onChange={(val) => setOpen(Array.isArray(val) ? val.includes("maf-flow") : val === "maf-flow")}
className="rounded-lg border border-slate-200 bg-white"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2">
<div className="flex items-center gap-1.5">
<GitBranch className="h-4 w-4 text-slate-500" />
<span className="text-sm font-semibold text-slate-700">事实/结论流程图</span>
{graph && (
<span className="text-xs text-slate-400">
({graph.nodes.length} 节点 / {graph.edges.length} 连线)
</span>
)}
</div>
<span className="text-xs text-slate-400">{open ? "收起" : "展开"}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<TCollapse.Panel
value="maf-flow"
header={
<div className="flex items-center gap-1.5">
<GitBranch className="h-4 w-4 text-slate-500" />
<span className="text-sm font-semibold text-slate-700">事实/结论流程图</span>
{graph && (
<span className="text-xs text-slate-400">
({graph.nodes.length} 节点 / {graph.edges.length} 连线)
</span>
)}
</div>
}
>
<div className="relative h-64 border-t border-slate-100">
{graph ? (
<>
{/* 内嵌预览:复用 FlowGraphPanel 的横向布局,占满面板高度 */}
{/* 内嵌预览:节点列表,点击「大图」看完整交互画布 */}
<FlowGraphPreview graph={graph} streaming={streaming} />
<Button
size="sm"
<TButton
theme="default"
variant="outline"
className="absolute right-2 top-2 z-10 h-7 gap-1 bg-white/90 text-xs"
size="small"
className="absolute right-2 top-2 z-10 bg-white/90"
icon={<Maximize2 className="h-3.5 w-3.5" />}
onClick={() => setFullscreen(true)}
>
<Maximize2 className="h-3.5 w-3.5" />
大图
</Button>
</TButton>
</>
) : (
<div className="flex h-full items-center justify-center text-xs text-slate-400">
@@ -72,7 +71,7 @@ export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProp
</div>
)}
</div>
</CollapsibleContent>
</TCollapse.Panel>
{/* 全屏弹窗(portal 到 body,仿 Step3SummaryMode 用法) */}
{fullscreen && graph && (
@@ -84,7 +83,7 @@ export function MafFactFlowPanel({ checkpoint, streaming }: MafFactFlowPanelProp
miniMap
/>
)}
</Collapsible>
</TCollapse>
);
}
@@ -1,15 +1,15 @@
/**
* MAF 功能开关守卫。
* MAF 功能开关守卫(MAF-06-01:可视层改用本地 `tdesign-react`)。
*
* P1:分别处理三种状态:
* 分别处理三种状态:
* - enabled=false → 「功能未启用」
* - enabled=true && store_available=false → 「功能已开启,但持久化存储未就绪」
* - 请求异常 → 「无法连接 MAF 服务」+ 重试按钮
*/
import { DatabaseZap, Loader2, RefreshCw, ShieldAlert, ShieldOff } from "lucide-react";
import { Button as TButton } from "tdesign-react";
import { Button } from "@/components/ui/button";
import { useMafHealth } from "../api/runs";
interface MafHealthGateProps {
@@ -42,10 +42,16 @@ export function MafHealthGate({ children, fallback }: MafHealthGateProps) {
<p className="max-w-md text-sm text-slate-400">
请求 /api/maf-roundtable/health 失败,请检查后端服务是否正常运行。
</p>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
<TButton
theme="default"
variant="outline"
size="small"
loading={isFetching}
icon={!isFetching ? <RefreshCw className="h-3.5 w-3.5" /> : undefined}
onClick={() => refetch()}
>
重试
</Button>
</TButton>
</div>
);
}
@@ -74,10 +80,16 @@ export function MafHealthGate({ children, fallback }: MafHealthGateProps) {
MAF 会商功能已开启,但持久化存储(数据库)尚未配置或连接失败。
写操作将返回 503。请检查数据库配置后重试。
</p>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
<TButton
theme="default"
variant="outline"
size="small"
loading={isFetching}
icon={!isFetching ? <RefreshCw className="h-3.5 w-3.5" /> : undefined}
onClick={() => refetch()}
>
重新检测
</Button>
</TButton>
</div>
);
}
@@ -1,5 +1,7 @@
/**
* MAF run 顶部状态条:状态徽标 + token/延迟 + 生命周期操作按钮。
* MAF run 顶部状态条:状态 Tag + token/延迟 + 生命周期操作按钮。
*
* MAF-06-01:可视层改用本地 `tdesign-react`(Tag / Button)。
*
* 操作:
* - live(pending/running/waiting_approval)→ 取消
@@ -7,14 +9,13 @@
* - cancel_failed → 重试取消(仍可点取消)
* - failed → 重试(recover)
*
* 状态徽标配色按 run.status 区分。token_usage/latency_ms 展示在右侧。
* 状态 Tag 主题按 run.status 区分。token_usage/latency_ms 展示在右侧。
*/
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, Square, XCircle } from "lucide-react";
import { toast } from "sonner";
import { Button as TButton, Tag as TTag } from "tdesign-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useMafRunActions } from "../api/runs";
import { runIsLive, type MafRun, type MafRunStatus } from "../api/types";
@@ -22,32 +23,32 @@ interface MafRunHeaderProps {
run: MafRun;
}
/** run 状态 → 中文 + 徽标 variant + 图标。 */
/** run 状态 → 中文 + Tag 主题 + 图标。 */
function statusMeta(status: MafRunStatus): {
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
theme: "default" | "primary" | "success" | "warning" | "danger";
icon: React.ReactNode;
tone: string;
} {
switch (status) {
case "pending":
return { label: "排队中", variant: "secondary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-slate-500" };
return { label: "排队中", theme: "default", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-slate-500" };
case "running":
return { label: "进行中", variant: "default", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-blue-600" };
return { label: "进行中", theme: "primary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-blue-600" };
case "waiting_approval":
return { label: "等待审批", variant: "default", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-amber-600" };
return { label: "等待审批", theme: "warning", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-amber-600" };
case "completed":
return { label: "已完成", variant: "outline", icon: <CheckCircle2 className="h-3 w-3" />, tone: "text-emerald-600" };
return { label: "已完成", theme: "success", icon: <CheckCircle2 className="h-3 w-3" />, tone: "text-emerald-600" };
case "failed":
return { label: "失败", variant: "destructive", icon: <XCircle className="h-3 w-3" />, tone: "text-red-600" };
return { label: "失败", theme: "danger", icon: <XCircle className="h-3 w-3" />, tone: "text-red-600" };
case "cancelled":
return { label: "已取消", variant: "outline", icon: <Square className="h-3 w-3" />, tone: "text-slate-500" };
return { label: "已取消", theme: "default", icon: <Square className="h-3 w-3" />, tone: "text-slate-500" };
case "cancelling":
return { label: "取消中", variant: "secondary", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-amber-600" };
return { label: "取消中", theme: "warning", icon: <Loader2 className="h-3 w-3 animate-spin" />, tone: "text-amber-600" };
case "cancel_failed":
return { label: "取消失败", variant: "destructive", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-red-600" };
return { label: "取消失败", theme: "danger", icon: <AlertTriangle className="h-3 w-3" />, tone: "text-red-600" };
default:
return { label: status, variant: "outline", icon: null, tone: "text-slate-500" };
return { label: status, theme: "default", icon: null, tone: "text-slate-500" };
}
}
@@ -86,9 +87,9 @@ export function MafRunHeader({ run }: MafRunHeaderProps) {
{meta.icon}
<span className="text-sm font-semibold">{meta.label}</span>
</div>
<Badge variant={meta.variant} className="text-[11px]">
<TTag size="small" theme="default" variant="outline">
{run.workflow_version ?? "maf"}
</Badge>
</TTag>
<div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
{tokenTotal != null && (
@@ -104,27 +105,42 @@ export function MafRunHeader({ run }: MafRunHeaderProps) {
<div className="flex items-center gap-2">
{run.status === "waiting_approval" && (
<Button size="sm" variant="outline" onClick={onResume} disabled={actions.resume.isPending}>
<TButton
theme="default"
variant="outline"
size="small"
loading={actions.resume.isPending}
disabled={actions.resume.isPending}
onClick={onResume}
>
恢复
</Button>
</TButton>
)}
{run.status === "failed" && (
<Button size="sm" variant="outline" onClick={onRecover} disabled={actions.recover.isPending}>
<RotateCcw className="mr-1 h-3.5 w-3.5" />
<TButton
theme="default"
variant="outline"
size="small"
loading={actions.recover.isPending}
disabled={actions.recover.isPending}
icon={<RotateCcw className="h-3.5 w-3.5" />}
onClick={onRecover}
>
重试
</Button>
</TButton>
)}
{(live || run.status === "cancel_failed") && (
<Button
size="sm"
<TButton
theme="danger"
variant="outline"
onClick={onCancel}
size="small"
loading={actions.cancel.isPending}
disabled={actions.cancel.isPending}
className="text-red-600 hover:bg-red-50 hover:text-red-700"
icon={<Square className="h-3.5 w-3.5" />}
onClick={onCancel}
>
<Square className="mr-1 h-3.5 w-3.5" />
取消
</Button>
</TButton>
)}
</div>
@@ -1,15 +1,16 @@
/**
* MAF 席位状态矩阵。
* MAF 席位状态矩阵(MAF-06-07:可视层改用本地 `tdesign-react`)。
*
* 数据来自 `run.maf_checkpoint.seat_outcomes`({agent_id: {status, execution_stage, ...}})。
* 每个席位一行:agent_id + status 徽标 + execution_stage 徽标。
* 每个席位一行:agent_id + status Tag + execution_stage Tag。
*
* execution_stage 取值(Phase-3 闭合枚举):
* legacy / planner / expert / format_repair / cross_review / cross_review_repair /
* finalizer / group_chat
*/
import { Badge } from "@/components/ui/badge";
import { Tag as TTag } from "tdesign-react";
import type { MafCheckpoint, MafSeatOutcome } from "../api/types";
interface MafSeatStatusPanelProps {
@@ -29,22 +30,20 @@ const STAGE_LABELS: Record<string, string> = {
group_chat: "群聊",
};
const SEAT_STATUS_TONE: Record<string, string> = {
completed: "text-emerald-600",
running: "text-blue-600",
pending: "text-slate-500",
failed: "text-red-600",
cancelled: "text-slate-400",
};
function seatStatusBadge(status: string) {
const variant =
status === "completed"
? ("outline" as const)
: status === "failed"
? ("destructive" as const)
: ("secondary" as const);
return <Badge variant={variant} className="text-[10px]">{status}</Badge>;
/** 席位状态 → TDesign Tag 主题。 */
function seatStatusTheme(status: string): "default" | "primary" | "success" | "danger" | "warning" {
switch (status) {
case "completed":
return "success";
case "running":
return "primary";
case "failed":
return "danger";
case "cancelled":
return "default";
default:
return "default";
}
}
export function MafSeatStatusPanel({ checkpoint, currentRound }: MafSeatStatusPanelProps) {
@@ -75,13 +74,13 @@ export function MafSeatStatusPanel({ checkpoint, currentRound }: MafSeatStatusPa
{agentId}
</span>
{outcome.execution_stage && (
<Badge variant="outline" className="text-[10px] text-slate-500">
<TTag size="small" theme="default" variant="light-outline">
{STAGE_LABELS[outcome.execution_stage] ?? outcome.execution_stage}
</Badge>
</TTag>
)}
<span className={SEAT_STATUS_TONE[outcome.status ?? ""] ?? "text-slate-500"}>
{seatStatusBadge(outcome.status ?? "unknown")}
</span>
<TTag size="small" theme={seatStatusTheme(outcome.status ?? "unknown")} variant="light">
{outcome.status ?? "unknown"}
</TTag>
</li>
))}
</ul>
@@ -1,20 +1,23 @@
/**
* MAF 单 run 工作区(路由 runs/:runId)。
*
* MAF-06-01/07/08/09:可视层改用本地 `tdesign-react`。
*
* 三栏布局(桌面)/ 纵向堆叠(窄屏):
* 左:事件时间线(MafEventTimeline)—— run live 时 cursor 轮询事件
* 中:席位状态(MafSeatStatusPanel)+ 事实/结论流程图(MafFactFlowPanel)
* 中:席位状态(MafSeatStatusPanel)+ 事实/结论流程图(MafFactFlowPanel)+ 最终结论
* 右:审批卡片(MafApprovalCard)—— 仅 pending 审批时显示
*
* 顶部:MafRunHeader(状态徽标 + 操作)。整体由 MafHealthGate 守卫。
* 顶部:MafRunHeader(状态 Tag + 操作)。整体由 MafHealthGate 守卫。
* 刷新/切页/跨页返回时从数据库事件恢复,不依赖 React 内存保存运行状态(MAF-06-09)。
*/
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Loader2, ShieldQuestion } from "lucide-react";
import { Button as TButton, Tag as TTag } from "tdesign-react";
import { Button } from "@/components/ui/button";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { useDesensitizeSubtree } from "@/lib/use-desensitize-subtree";
import { useMafActions, useMafApprovals } from "../api/approvals";
import { runIsLive, type MafAction } from "../api/types";
@@ -66,14 +69,17 @@ function RunBody({ runId }: { runId: string }) {
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-500">
<p>加载失败:{(runError as Error)?.message ?? "run 不存在或无权访问"}</p>
<Link to="/page/maf">
<Button variant="outline" size="sm">
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> 返回会话列表
</Button>
<TButton theme="default" variant="outline" size="small" icon={<ArrowLeft className="h-3.5 w-3.5" />}>
返回会话列表
</TButton>
</Link>
</div>
);
}
const finalResult = run.maf_checkpoint?.final_result;
const showFinalResult = !!finalResult?.content;
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div ref={headerRef}>
@@ -86,7 +92,7 @@ function RunBody({ runId }: { runId: string }) {
<MafEventTimeline entries={timeline} streaming={live} />
</section>
{/* 中:席位 + 流程图 */}
{/* 中:席位 + 流程图 + 最终结论 */}
<section className="flex min-h-0 flex-col gap-2 overflow-y-auto">
<MafSeatStatusPanel
checkpoint={run.maf_checkpoint}
@@ -96,16 +102,27 @@ function RunBody({ runId }: { runId: string }) {
/>
<MafFactFlowPanel checkpoint={run.maf_checkpoint} streaming={live} />
{/* final_result 展示 */}
{run.maf_checkpoint?.final_result?.content && (
{/* 最终结论(MAF-06-07:区分建议动作与已执行动作) */}
{showFinalResult && (
<div className="rounded-lg border border-slate-200 bg-white p-3">
<h3 className="mb-1.5 text-sm font-semibold text-slate-700">最终结论</h3>
<div className="mb-1.5 flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-slate-700">最终结论</h3>
{finalResult?.degraded && (
<TTag size="small" theme="warning" variant="light-outline">降级输出</TTag>
)}
{finalResult?.partial_failure && (
<TTag size="small" theme="danger" variant="light-outline">部分失败</TTag>
)}
</div>
{/* final_result.content 可能含 flow-json 块;这里只展示文字部分(流程图已在上方画布) */}
<div className="prose prose-sm max-h-72 max-w-none overflow-auto text-slate-700 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{/* final_result.content 可能含 flow-json 块;这里只展示文字部分(流程图已在上方画布) */}
<pre className="whitespace-pre-wrap break-words font-sans text-xs">
{stripFlowJson(run.maf_checkpoint.final_result.content)}
{stripFlowJson(finalResult!.content!)}
</pre>
</div>
{finalResult?.note && (
<div className="mt-2 text-[11px] text-slate-400">{finalResult.note}</div>
)}
</div>
)}
</section>
@@ -116,9 +133,7 @@ function RunBody({ runId }: { runId: string }) {
<ShieldQuestion className="h-3.5 w-3.5" />
写工具审批
{pendingApprovals.length > 0 && (
<span className="rounded-full bg-red-100 px-1.5 text-[10px] font-bold text-red-600">
{pendingApprovals.length}
</span>
<TTag size="small" theme="danger" variant="light">{pendingApprovals.length}</TTag>
)}
</div>
{pendingApprovals.length === 0 ? (
@@ -4,7 +4,8 @@
* 左:会话列表(按创建时间倒序);右:选中会话的 run 列表 + 「启动会商」。
* 点击某条 run → 跳 `/page/maf/runs/:runId`。
*
* P0-2:新建弹窗包含 task_text(必填)、模式、经理智能体、席位智能体多选、
* 可视 UI 统一使用本地 `tdesign-react`(MAF-06-01/02/03)。
* 新建弹窗包含 task_text(必填)、模式、经理智能体、席位智能体多选、
* 可选高级项(模型/推理强度/最大轮数/写入策略)。智能体列表复用 @/core/agents。
*/
@@ -12,20 +13,18 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Loader2, MessageSquarePlus, Play, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
Button as TButton,
Checkbox as TCheckbox,
Dialog as TDialog,
Input as TInput,
Select as TSelect,
Tag as TTag,
Textarea as TTextarea,
} from "tdesign-react";
import { useAgents } from "@/core/agents";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { useDesensitizeSubtree } from "@/lib/use-desensitize-subtree";
import { cn } from "@/lib/utils";
import { useMafSessionActions, useMafSessions, type CreateSessionBody, type ParticipantInput } from "../api/sessions";
@@ -43,20 +42,21 @@ const STATUS_LABEL: Record<string, string> = {
cancelled: "已取消",
};
function statusTone(status: string): string {
/** session 状态 → TDesign Tag 主题。 */
function statusTagTheme(status: string): "default" | "primary" | "warning" | "success" | "danger" {
switch (status) {
case "running":
return "text-blue-600";
return "primary";
case "waiting_approval":
case "waiting_input":
return "text-amber-600";
return "warning";
case "completed":
return "text-emerald-600";
return "success";
case "failed":
case "cancelled":
return "text-red-500";
return "danger";
default:
return "text-slate-500";
return "default";
}
}
@@ -85,10 +85,15 @@ function SessionsBody() {
<aside className="flex w-72 shrink-0 flex-col border-r border-slate-200 bg-white">
<div ref={headerRef} className="flex items-center justify-between border-b border-slate-100 px-3 py-2.5">
<h2 className="text-sm font-semibold text-slate-700">会话</h2>
<Button size="sm" variant="outline" className="h-7 gap-1" onClick={() => setCreateOpen(true)}>
<MessageSquarePlus className="h-3.5 w-3.5" />
<TButton
theme="default"
variant="outline"
size="small"
onClick={() => setCreateOpen(true)}
>
icon={<MessageSquarePlus className="h-3.5 w-3.5" />}
新建
</Button>
</TButton>
</div>
<div className="flex-1 overflow-y-auto">
@@ -123,12 +128,12 @@ function SessionsBody() {
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-700">
{s.title || "未命名会话"}
</span>
<span className={cn("text-[10px]", statusTone(s.status))}>
<TTag size="small" theme={statusTagTheme(s.status)} variant="light-outline">
{STATUS_LABEL[s.status] ?? s.status}
</span>
</TTag>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-slate-400">
<Badge variant="outline" className="text-[9px]">{s.mode}</Badge>
<TTag size="small" theme="default" variant="outline">{s.mode}</TTag>
<span className="truncate">{s.task_text || "无任务说明"}</span>
</div>
</button>
@@ -189,29 +194,34 @@ function SessionDetail({
<div className="min-w-0">
<h1 className="truncate text-base font-semibold text-slate-800">{title || "未命名会话"}</h1>
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
<Badge variant="outline" className="text-[10px]">{mode}</Badge>
<span className={statusTone(status)}>{STATUS_LABEL[status] ?? status}</span>
<TTag size="small" theme="default" variant="outline">{mode}</TTag>
<TTag size="small" theme={statusTagTheme(status)} variant="light-outline">
{STATUS_LABEL[status] ?? status}
</TTag>
</div>
</div>
<div className="flex items-center gap-2">
{liveRun && (
<Button
size="sm"
<TButton
theme="default"
variant="outline"
size="small"
onClick={() => navigate(`/page/maf/runs/${liveRun.id}`)}
>
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin text-blue-500" />
已有会商进行中
</Button>
icon={<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />}
已有会商进行中
</TButton>
)}
<Button size="sm" onClick={onStart} disabled={actions.start.isPending || !!liveRun}>
{actions.start.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Play className="mr-1 h-3.5 w-3.5" />
)}
<TButton
theme="primary"
size="small"
loading={actions.start.isPending}
disabled={!!liveRun}
onClick={onStart}
>
icon={<Play className="h-3.5 w-3.5" />}
启动会商
</Button>
</TButton>
</div>
</header>
@@ -244,7 +254,7 @@ function SessionDetail({
{run.started_at ? new Date(run.started_at).toLocaleString() : "未开始"}
</div>
</div>
<Badge variant="outline" className="text-[10px]">{run.status}</Badge>
<TTag size="small" theme="default" variant="outline">{run.status}</TTag>
</button>
</li>
))}
@@ -286,7 +296,14 @@ function CreateSessionDialog({
}
}, [health.data?.default_mode]);
// P0:切换经理时,自动从已选席位中移除(后端参与者唯一约束:同一 agent 不能同时是 manager 和 seat)
// 默认 write_policy 跟随后端 default_write_policy
useEffect(() => {
if (health.data?.default_write_policy) {
setWritePolicy(health.data.default_write_policy);
}
}, [health.data?.default_write_policy]);
// 切换经理时,自动从已选席位中移除(后端参与者唯一约束:同一 agent 不能同时是 manager 和 seat)
useEffect(() => {
if (managerAgentId) {
setSeatAgentIds((prev) => prev.filter((id) => id !== managerAgentId));
@@ -382,163 +399,195 @@ function CreateSessionDialog({
);
};
const agentOptions = agents.map((a) => ({ label: a.name, value: a.id }));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>新建 MAF 会商会话</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
{/* 标题 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">会商标题</label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="例如:某任务多视角研讨" />
</div>
<TDialog
visible={open}
onClose={() => onOpenChange(false)}
header="新建 MAF 会商会话"
width="560px"
closeOnEscKeydown
footer={
<div className="flex items-center justify-end gap-2">
<TButton theme="default" variant="outline" onClick={() => onOpenChange(false)}>
取消
</TButton>
<TButton
theme="primary"
loading={sessionActions.create.isPending}
onClick={onSubmit}
>
创建
</TButton>
</div>
}
>
<div className="space-y-3 py-2">
{/* 标题 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">会商标题</label>
<TInput
value={title}
onChange={(val: string) => setTitle(val)}
placeholder="例如:某任务多视角研讨"
/>
</div>
{/* 任务说明(必填) */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
任务说明 <span className="text-red-500">*</span>
</label>
<Textarea
value={taskText}
onChange={(e) => setTaskText(e.target.value)}
placeholder="描述本次会商要解决的问题、背景和目标(必填)"
className="min-h-24"
{/* 任务说明(必填) */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
任务说明 <span className="text-red-500">*</span>
</label>
<TTextarea
value={taskText}
onChange={(val: string) => setTaskText(val)}
placeholder="描述本次会商要解决的问题、背景和目标(必填)"
autosize={{ minRows: 3, maxRows: 8 }}
/>
</div>
{/* 模式 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">会商模式</label>
<TSelect
value={mode}
onChange={(val) => setMode(String(val))}
options={[
{ label: "structured(规划→专家→评审→总结)", value: "structured" },
{ label: "group_chat(协调员动态点名)", value: "group_chat" },
]}
/>
</div>
{/* 经理智能体 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
经理智能体 <span className="text-red-500">*</span>
</label>
{agentsLoading ? (
<div className="flex h-9 items-center px-2 text-xs text-slate-400">
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载智能体…
</div>
) : agentsError ? (
<div className="flex h-9 items-center px-2 text-xs text-red-500">
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
</div>
) : (
<TSelect
value={managerAgentId}
onChange={(val) => setManagerAgentId(String(val))}
options={agentOptions}
placeholder="请选择…"
filterable
clearable
/>
</div>
)}
</div>
{/* 模式 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">会商模式</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2 text-sm"
>
<option value="structured">structured(规划→专家→评审→总结)</option>
<option value="group_chat">group_chat(协调员动态点名)</option>
</select>
</div>
{/* 经理智能体 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
经理智能体 <span className="text-red-500">*</span>
</label>
{agentsLoading ? (
<div className="flex h-9 items-center px-2 text-xs text-slate-400">
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载智能体…
{/* 席位智能体多选 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
席位智能体(至少一个) <span className="text-red-500">*</span>
</label>
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-200 p-2">
{agentsLoading && (
<div className="flex items-center justify-center py-2 text-xs text-slate-400">
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载中…
</div>
) : agentsError ? (
<div className="flex h-9 items-center px-2 text-xs text-red-500">
)}
{agentsError && (
<div className="py-2 text-center text-xs text-red-500">
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
</div>
) : (
<select
value={managerAgentId}
onChange={(e) => setManagerAgentId(e.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2 text-sm"
>
<option value="">请选择…</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
)}
{!agentsLoading && !agentsError && agents.length === 0 && (
<div className="py-2 text-center text-xs text-slate-400">暂无可用智能体</div>
)}
{!agentsLoading && !agentsError && agents.map((a) => (
<label key={a.id} className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-slate-50">
<TCheckbox
checked={seatAgentIds.includes(a.id)}
onChange={() => toggleSeat(a.id)}
disabled={a.id === managerAgentId}
/>
<span className="text-sm text-slate-700">{a.name}</span>
{a.id === managerAgentId && (
<span className="text-[10px] text-slate-400">(已选为经理)</span>
)}
</label>
))}
</div>
{/* 席位智能体多选 */}
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
席位智能体(至少一个) <span className="text-red-500">*</span>
</label>
<div className="max-h-40 overflow-y-auto rounded-md border border-slate-200 p-2">
{agentsLoading && (
<div className="flex items-center justify-center py-2 text-xs text-slate-400">
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> 加载中…
</div>
)}
{agentsError && (
<div className="py-2 text-center text-xs text-red-500">
加载失败:{(agentsError as Error)?.message ?? "未知错误"}
</div>
)}
{!agentsLoading && !agentsError && agents.length === 0 && (
<div className="py-2 text-center text-xs text-slate-400">暂无可用智能体</div>
)}
{!agentsLoading && !agentsError && agents.map((a) => (
<label key={a.id} className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-slate-50">
<input
type="checkbox"
checked={seatAgentIds.includes(a.id)}
onChange={() => toggleSeat(a.id)}
disabled={a.id === managerAgentId}
className="h-3.5 w-3.5 rounded border-slate-300"
/>
<span className="text-sm text-slate-700">{a.name}</span>
{a.id === managerAgentId && (
<span className="text-[10px] text-slate-400">(已选为经理)</span>
)}
</label>
))}
{seatAgentIds.length > 0 && (
<div className="mt-1 text-[10px] text-slate-400">
已选 {seatAgentIds.length} 个席位
</div>
{seatAgentIds.length > 0 && (
<div className="mt-1 text-[10px] text-slate-400">
已选 {seatAgentIds.length} 个席位
</div>
)}
</div>
{/* 高级项折叠 */}
<div>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs text-blue-600 hover:underline"
>
{showAdvanced ? "收起高级选项" : "展开高级选项"}
</button>
{showAdvanced && (
<div className="mt-2 space-y-2 rounded-md border border-slate-100 bg-slate-50/50 p-2">
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">模型名称</label>
<Input value={modelName} onChange={(e) => setModelName(e.target.value)} placeholder="留空使用默认" className="h-8 text-xs" />
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">推理强度</label>
<Input value={reasoningEffort} onChange={(e) => setReasoningEffort(e.target.value)} placeholder="low / medium / high" className="h-8 text-xs" />
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">最大轮数</label>
<Input value={maxRounds} onChange={(e) => setMaxRounds(e.target.value)} placeholder="留空使用默认" type="number" min={1} max={64} className="h-8 text-xs" />
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">写入策略</label>
<select
value={writePolicy}
onChange={(e) => setWritePolicy(e.target.value)}
className="h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
>
<option value="">默认</option>
<option value="auto">auto(自动执行)</option>
<option value="confirm_high_risk">confirm_high_risk(高风险确认)</option>
<option value="confirm_all_writes">confirm_all_writes(全部确认)</option>
</select>
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={onSubmit} disabled={sessionActions.create.isPending}>
{sessionActions.create.isPending && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 高级项折叠 */}
<div>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs text-blue-600 hover:underline"
>
{showAdvanced ? "收起高级选项" : "展开高级选项"}
</button>
{showAdvanced && (
<div className="mt-2 space-y-2 rounded-md border border-slate-100 bg-slate-50/50 p-2">
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">模型名称</label>
<TInput
value={modelName}
onChange={(val: string) => setModelName(val)}
placeholder="留空使用默认"
size="small"
/>
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">推理强度</label>
<TSelect
value={reasoningEffort}
onChange={(val) => setReasoningEffort(String(val))}
size="small"
options={[
{ label: "留空使用默认", value: "" },
{ label: "low", value: "low" },
{ label: "medium", value: "medium" },
{ label: "high", value: "high" },
]}
/>
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">最大轮数(1–64)</label>
<TInput
value={maxRounds}
onChange={(val: string) => setMaxRounds(val)}
placeholder="留空使用默认"
type="number"
size="small"
/>
</div>
<div>
<label className="mb-0.5 block text-[11px] text-slate-500">写入策略</label>
<TSelect
value={writePolicy}
onChange={(val) => setWritePolicy(String(val))}
size="small"
options={[
{ label: "默认", value: "" },
{ label: "auto(自动执行)", value: "auto" },
{ label: "confirm_high_risk(高风险确认)", value: "confirm_high_risk" },
{ label: "confirm_all_writes(全部确认)", value: "confirm_all_writes" },
]}
/>
</div>
</div>
)}
</div>
</div>
</TDialog>
);
}
@@ -546,20 +595,20 @@ function CreateSessionDialog({
export function DeleteSessionButton({ sessionId }: { sessionId: string }) {
const actions = useMafSessionActions();
return (
<Button
size="sm"
variant="ghost"
className="text-red-500 hover:bg-red-50 hover:text-red-600"
<TButton
theme="danger"
variant="outline"
size="small"
loading={actions.remove.isPending}
onClick={() => {
actions.remove.mutate(sessionId, {
onSuccess: () => toast.success("已删除"),
onError: (e: Error) => toast.error(`删除失败:${e.message}`),
});
}}
disabled={actions.remove.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
icon={<Trash2 className="h-3.5 w-3.5" />}
删除
</Button>
</TButton>
);
}
@@ -1,67 +1,9 @@
/**
* 区域级脱敏 Hook —— 把某个容器(DOM 子树)里的**所有文本节点**做脱敏,
* 用于「左/右侧面板 + 顶部标题」这类需要整片脱敏的区域:静态标题、动态内容、
* 嵌套子组件文本一并覆盖,无需逐串包 `desensitize()`(那样易漏、难维护)。
* 区域级脱敏 Hook —— re-export shim。
*
* 用法:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
* 实现已抽到中性共享位置 `@/lib/use-desensitize-subtree`(MAF 会商、业务链条等多模块共享)。
* 本文件保留导出,旧 roundtable-planning 消费者零改动、行为完全一致。
*
* 设计要点:
* - 只读改文本节点(display-only),不碰 `<input>/<textarea>` 的 value、不改 React 状态/回传值;
* - `desensitize` 幂等(替换词不再命中词表),MutationObserver 重入安全;改写前先 disconnect 防自触发循环;
* - 想在区域内**保留原文**(如内嵌沙箱/消息列表),给该子树根加 `data-no-desensitize` 属性即可跳过。
*
* 注意:**不要**用它包「消息列表正文」或「沙箱产物预览」——这两处按需求保持原文,
* 应作为独立容器、不挂本 Hook(智能体名称的脱敏单独在 MessageBubble 的 senderName 处理)。
* 用法不变:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
*/
import { useEffect, useRef } from "react";
import { desensitize } from "@/lib/desensitize";
const _SKIP_TAGS = new Set(["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "IFRAME", "CODE", "PRE"]);
export function useDesensitizeSubtree<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
useEffect(() => {
const root = ref.current;
if (!root || typeof MutationObserver === "undefined") return;
let observer: MutationObserver | null = null;
const apply = () => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (_SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
// 显式标记 data-no-desensitize 的子树保留原文(如内嵌沙箱)。
if (parent.closest("[data-no-desensitize]")) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});
const edits: Array<[Text, string]> = [];
let current = walker.nextNode();
while (current) {
const textNode = current as Text;
const original = textNode.nodeValue ?? "";
if (original.trim()) {
const masked = desensitize(original);
if (masked !== original) edits.push([textNode, masked]);
}
current = walker.nextNode();
}
if (edits.length === 0) return;
// 改写期间停掉观察,避免自身改动再次触发,改完恢复。
observer?.disconnect();
for (const [textNode, masked] of edits) textNode.nodeValue = masked;
observer?.observe(root, { subtree: true, childList: true, characterData: true });
};
apply();
observer = new MutationObserver(() => apply());
observer.observe(root, { subtree: true, childList: true, characterData: true });
return () => observer?.disconnect();
});
return ref;
}
export { useDesensitizeSubtree } from "@/lib/use-desensitize-subtree";