feat(maf): Phase 6 - migrate frontend workbench to tdesign-react and extract shared desensitize hook
This commit is contained in:
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";
|
||||
Reference in new issue
Block a user