From 45049975c2172d50018c5b9711e195a12c37fd7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 16 Jun 2026 16:39:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(roundtable):=20=E5=A4=9A=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E4=BC=9A=E5=95=86=20+=20=E4=B8=9A=E5=8A=A1=E9=93=BE?= =?UTF-8?q?=E6=9D=A1=E9=A1=B5=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 区域级脱敏 Hook useDesensitizeSubtree:对左右侧面板、顶部标题、 业务链条选择器/列表弹窗/列表页/配置页/映射页整片脱敏(文本节点, 跳过 input/textarea 等可编辑控件) - 消息列表正文保持原文,仅智能体名称脱敏(senderName / 席位名 / 流程图节点) - Step3 总结/报告/大屏、推荐弹窗等结果面板脱敏 - 沙箱产物预览不脱敏:ArtifactFileDetail 加 disableDesensitize,会商沙箱传入 - desensitize.ts 新增 desensitizeDeep 递归脱敏结构化数据 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/agents/agent-selector.tsx | 24 +- .../artifacts/artifact-file-detail.tsx | 11 +- .../workspace/general-qa-sidebar.tsx | 586 ++++++++++++++++++ .../workspace/workspace-sidebar.tsx | 22 +- frontend-web/src/lib/desensitize.ts | 23 + frontend-web/src/pages/AgentChatPage.tsx | 20 +- .../src/pages/BusinessChainMappingPage.tsx | 5 +- frontend-web/src/pages/ChatPage.tsx | 16 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 17 +- .../components/BusinessChainListDialog.tsx | 5 +- .../components/BusinessChainPicker.tsx | 6 +- .../components/DagFlowChart.tsx | 3 +- .../components/DagFlowMiniPanel.tsx | 3 +- .../components/DispatchChainModal.tsx | 5 +- .../components/HighFidelityReport.tsx | 9 +- .../components/MessageBubble.tsx | 5 +- .../components/ParallelSeatStrip.tsx | 3 +- .../components/RecommendAgentsDialog.tsx | 7 +- .../components/Step1Panel.tsx | 6 +- .../components/Step2Panel.tsx | 15 +- .../components/Step2SandboxLayout.tsx | 2 + .../components/Step3DashboardMode.tsx | 11 +- .../components/Step3ReportMode.tsx | 7 +- .../components/Step3SummaryMode.tsx | 7 +- .../roundtable-planning/lib/step-display.tsx | 3 +- .../lib/use-desensitize-subtree.ts | 67 ++ .../pages/BusinessChainEditorPage.tsx | 5 +- .../pages/BusinessChainListPage.tsx | 5 +- .../pages/RoundtablePlanningPage.tsx | 21 +- 29 files changed, 853 insertions(+), 66 deletions(-) create mode 100644 frontend-web/src/components/workspace/general-qa-sidebar.tsx create mode 100644 frontend-web/src/roundtable-planning/lib/use-desensitize-subtree.ts diff --git a/frontend-web/src/components/workspace/agents/agent-selector.tsx b/frontend-web/src/components/workspace/agents/agent-selector.tsx index a605ae7f..cf4d1f1a 100644 --- a/frontend-web/src/components/workspace/agents/agent-selector.tsx +++ b/frontend-web/src/components/workspace/agents/agent-selector.tsx @@ -20,9 +20,10 @@ import { } from "@/components/ui/dialog"; import { PromptInputButton } from "@/components/ai-elements/prompt-input"; import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog"; -import { useAgentSelector } from "@/core/agents"; +import { useAgentSelector, useAgentsPaginated } from "@/core/agents"; import type { Agent } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; +import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; interface AgentSelectorProps { @@ -51,12 +52,21 @@ export function AgentSelector({ const [open, setOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false); const { agents, isLoading } = useAgentSelector(); + // 弹窗列表只展示管理弹窗里配置的 agent(agents);但「已选中 agent」的名称解析要更宽—— + // 侧边栏「常用智能体」可以选中未配置进管理弹窗的 agent(如用户自己的智能体), + // 这些 agent 也要能在胶囊上显示名称,故额外用「我的 / 广场」列表兜底解析(缓存与侧边栏共享)。 + const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 }); + const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 }); const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []); const selectedAgent = useMemo(() => { if (!selectedAgentId) return null; - return agents.find((a) => a.id === selectedAgentId) ?? null; - }, [agents, selectedAgentId]); + return ( + [...agents, ...mineAgents, ...squareAgents].find( + (a) => a.id === selectedAgentId, + ) ?? null + ); + }, [agents, mineAgents, squareAgents, selectedAgentId]); const handleSelect = (agent: Agent) => { onAgentChange(agent.id); @@ -68,7 +78,7 @@ export function AgentSelector({ onAgentChange(null); }; - const triggerLabel = selectedAgent?.name ?? "选择 Agent"; + const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent"; return ( <> @@ -125,7 +135,7 @@ export function AgentSelector({ {agents.map((agent) => ( handleSelect(agent)} className={cn( "flex w-full min-w-0 items-start gap-2 overflow-hidden", @@ -134,10 +144,10 @@ export function AgentSelector({ >
- {agent.name} + {desensitize(agent.name)} {agent.description && ( - {agent.description} + {desensitize(agent.description)} )}
diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 5188d8d5..37bdd9cf 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -122,6 +122,7 @@ export function ArtifactFileDetail({ threadId, onClose, allowVirtualMarkdownEdit = false, + disableDesensitize = false, }: { className?: string; filepath: string; @@ -134,6 +135,12 @@ export function ArtifactFileDetail({ * 默认 false —— 主聊天页等其它调用方行为不变。 */ allowVirtualMarkdownEdit?: boolean; + /** + * 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的 + * 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方 + * 默认 false,沿用全局脱敏。 + */ + disableDesensitize?: boolean; }) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); @@ -802,7 +809,7 @@ export function ArtifactFileDetail({ language === "html" || language === "mermaid") && ( >> 0; + } + return AGENT_COLORS[hash % AGENT_COLORS.length]!; +} + +/** 折叠区块标题(带 chevron 与可选计数徽标)。 */ +function SectionLabel({ + label, + count, + collapsed, + onToggle, +}: { + label: string; + count?: number; + collapsed: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +export function GeneralQaSidebar() { + const { t } = useI18n(); + const pathname = usePathname(); + const router = useRouter(); + const navigate = useNavigate(); + + // ── 区块折叠状态(持久化,键独立于旧组件,避免互相影响)── + const [agentsCollapsed, setAgentsCollapsed] = useState( + () => localStorage.getItem("gqa-sidebar:agents-collapsed") === "true", + ); + const [chatsCollapsed, setChatsCollapsed] = useState( + () => localStorage.getItem("gqa-sidebar:chats-collapsed") === "true", + ); + const toggleAgents = () => + setAgentsCollapsed((v) => { + localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v)); + return !v; + }); + const toggleChats = () => + setChatsCollapsed((v) => { + localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v)); + return !v; + }); + + // ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)── + const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 }); + const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 }); + const frequent = useMemo(() => { + const out: Agent[] = []; + const seen = new Set(); + const add = (agent: Agent | undefined) => { + if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return; + seen.add(agent.id); + out.push(agent); + }; + [...mine, ...square].filter((a) => a.is_pinned).forEach(add); + mine.forEach(add); + square.forEach(add); + return out; + }, [mine, square]); + + // 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。 + const searchParams = useSearchParams(); + const activeAgentId = searchParams.get("agent"); + + // 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new, + // 仅给 URL 加 ?agent=,由 NewChatContent 把右侧问答区换成 AgentChatPage。 + // 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。 + const handleSelectAgent = useCallback( + (agentId: string) => { + navigate(`/page/workspace/chats/new?agent=${encodeURIComponent(agentId)}`); + }, + [navigate], + ); + + // ── 最近的对话取数 + 搜索 ── + const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{ + thread_id: string; + agent_id?: string; + }>(); + const { data: threads = [] } = useThreads(); + const { mutate: deleteThread } = useDeleteThread(); + const { mutate: renameThread } = useRenameThread(); + const { toggle: togglePin } = useToggleThreadPin(); + const { toggle: toggleFavorite } = useToggleThreadFavorite(); + + const [query, setQuery] = useState(""); + const [renameDialogOpen, setRenameDialogOpen] = useState(false); + const [renameThreadId, setRenameThreadId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [shareThreadId, setShareThreadId] = useState(null); + + const visibleThreads = useMemo( + () => + sortThreadsPinnedFirst( + threads.filter( + (thread) => + thread.metadata?.thread_type !== "scheduler" && + thread.metadata?.thread_type !== "bootstrap" && + thread.metadata?.thread_type !== "roundtable", + ), + ), + [threads], + ); + + const filteredThreads = useMemo(() => { + const q = query.trim().toLowerCase(); + const list = q + ? visibleThreads.filter((thread) => + titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q), + ) + : visibleThreads; + // 未搜索时与旧版一致:最多展示 10 条 + return q ? list : list.slice(0, 10); + }, [visibleThreads, query, t.pages.newChat]); + + const handleNewChatClick = (event: React.MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return; + event.preventDefault(); + navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } }); + }; + + const handleDelete = useCallback( + (threadId: string) => { + deleteThread({ threadId }); + if (threadId === threadIdFromPath) { + const threadIndex = threads.findIndex((th) => th.thread_id === threadId); + let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath }); + if (threadIndex > -1) { + if (threads[threadIndex + 1]) { + nextThreadPath = pathOfThread(threads[threadIndex + 1]!); + } else if (threads[threadIndex - 1]) { + nextThreadPath = pathOfThread(threads[threadIndex - 1]!); + } + } + void router.push(nextThreadPath); + } + }, + [agentIdFromPath, deleteThread, router, threadIdFromPath, threads], + ); + + const handleRenameClick = useCallback((threadId: string, currentTitle: string) => { + setRenameThreadId(threadId); + setRenameValue(currentTitle); + setRenameDialogOpen(true); + }, []); + + const handleRenameSubmit = useCallback(() => { + if (renameThreadId && renameValue.trim()) { + renameThread({ threadId: renameThreadId, title: renameValue.trim() }); + setRenameDialogOpen(false); + setRenameThreadId(null); + setRenameValue(""); + } + }, [renameThread, renameThreadId, renameValue]); + + const handleExport = useCallback( + async (thread: AgentThread, format: "markdown" | "json") => { + try { + const apiClient = getAPIClient(); + const state = await apiClient.threads.getState( + thread.thread_id, + ); + const messages = state.values?.messages ?? []; + if (messages.length === 0) { + toast.error(t.conversation.noMessages); + return; + } + if (format === "markdown") exportThreadAsMarkdown(thread, messages); + else exportThreadAsJSON(thread, messages); + toast.success(t.common.exportSuccess); + } catch { + toast.error("Failed to export conversation"); + } + }, + [t], + ); + + const isStatic = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; + + return ( +
+ {/* ── 顶部:新对话胶囊 + 收起按钮 ── */} +
+ {/* 新对话按钮:完全自定义样式(不复用 Button variant)。borderColor / color 走 + inline style,避免被全局 border-color(--border)覆盖;无阴影。浅主色底 + 主色描边 + + 5px 圆角,全部走 --primary token,自动适配红/蓝/深蓝主题。 */} + + + {t.sidebar.newChat} + + +
+ +
+ {/* ── 常用智能体 ── */} + {frequent.length > 0 && ( +
+ + {!agentsCollapsed && ( +
+ {frequent.map((agent) => { + const isActive = activeAgentId === agent.id; + const { from, to } = agentColor(agent.id); + return ( + + ); + })} + + {t.common.more} + + +
+ )} +
+ )} + + {/* ── 最近的对话 ── */} +
+ + {!chatsCollapsed && ( +
+ {/* 历史会话搜索框 */} +
+ + setQuery(e.target.value)} + placeholder="搜索历史会话…" + className="h-8 rounded-lg border-border bg-card pl-8 text-[12px]" + /> +
+ +
+ {filteredThreads.length === 0 ? ( +
+ {query ? "没有匹配的会话" : t.chats.noChats} +
+ ) : ( + filteredThreads.map((thread) => { + const isActive = pathOfThread(thread) === pathname; + const pinned = isThreadPinned(thread); + const favorited = isThreadFavorite(thread); + return ( +
+ + + {pinned && ( + + )} + + {desensitize(titleOfThread(thread, t.pages.newChat))} + + {isActive && ( + + )} + + {!isStatic && ( + + + + + + + handleRenameClick( + thread.thread_id, + titleOfThread(thread, t.pages.newChat), + ) + } + > + + {t.common.rename} + + togglePin(thread.thread_id, !pinned)}> + {pinned ? ( + + ) : ( + + )} + {pinned ? t.common.unpin : t.common.pin} + + toggleFavorite(thread.thread_id, !favorited)} + > + + {favorited ? t.common.unfavorite : t.common.favorite} + + setShareThreadId(thread.thread_id)}> + + {t.common.share} + + + + + {t.common.export} + + + handleExport(thread, "markdown")}> + + {t.common.exportAsMarkdown} + + handleExport(thread, "json")}> + + {t.common.exportAsJSON} + + + + + handleDelete(thread.thread_id)}> + + {t.common.delete} + + + + )} +
+ ); + }) + )} + {!query && ( + + 更多最近对话 + + + )} +
+
+ )} +
+
+ + {/* 重命名弹窗 */} + + + + {t.common.rename} + +
+ setRenameValue(e.target.value)} + placeholder={t.common.rename} + onKeyDown={(e) => { + if (e.key === "Enter" && !isIMEComposing(e)) { + e.preventDefault(); + handleRenameSubmit(); + } + }} + /> +
+ + + + +
+
+ + {/* 分享弹窗 */} + { + if (!o) setShareThreadId(null); + }} + /> +
+ ); +} diff --git a/frontend-web/src/components/workspace/workspace-sidebar.tsx b/frontend-web/src/components/workspace/workspace-sidebar.tsx index de7afebd..39206d90 100644 --- a/frontend-web/src/components/workspace/workspace-sidebar.tsx +++ b/frontend-web/src/components/workspace/workspace-sidebar.tsx @@ -12,7 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext"; import { cn } from "@/lib/utils"; import { usePathname } from "next/navigation"; -import { FrequentAgentsList } from "./frequent-agents-list"; +import { GeneralQaSidebar } from "./general-qa-sidebar"; import { RecentChatList } from "./recent-chat-list"; import { RecentRoundtableList } from "./recent-roundtable-list"; import { RecentWritingList } from "./recent-writing-list"; @@ -30,7 +30,8 @@ export function WorkspaceSidebar({ const pathname = usePathname(); const useWhiteSidebar = compactChrome && theme === "light"; // 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。 - // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;其余 workspace 页保持原样。 + // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是 + // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 const isGeneralQa = pathname.startsWith("/page/workspace/chats"); // 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。 @@ -58,13 +59,16 @@ export function WorkspaceSidebar({ {...props} > - - {isGeneralQa && } - + {isGeneralQa ? ( + // 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成 + // GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。 + + ) : ( + <> + + + + )} diff --git a/frontend-web/src/lib/desensitize.ts b/frontend-web/src/lib/desensitize.ts index 3778cc98..a6e6bd0f 100644 --- a/frontend-web/src/lib/desensitize.ts +++ b/frontend-web/src/lib/desensitize.ts @@ -4,6 +4,9 @@ * value: 脱敏后替换词 */ export const DESENSITIZE_MAP: Record = { + 方案: "FA", + 设计: "SJ", + 任务: "RW", 态势: "TS", 滨海作战体系: "第一张网", 滨海作战部队: "第一张网", @@ -72,6 +75,26 @@ export function desensitize(text: string): string { return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched); } +/** + * 深度脱敏:递归遍历对象 / 数组,对其中所有字符串值做 {@link desensitize}, + * 其余类型原样保留。返回**新值**(不修改入参),用于对结构化数据(如大屏 report-json + * 解析出的对象)整体脱敏后再渲染。 + * + * 注意:仅用于「展示」路径——不要用它改写要回传后端 / 落库的数据。 + */ +export function desensitizeDeep(value: T): T { + if (typeof value === "string") return desensitize(value) as unknown as T; + if (Array.isArray(value)) return value.map((v) => desensitizeDeep(v)) as unknown as T; + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = desensitizeDeep(v); + } + return out as T; + } + return value; +} + /** * 运行时动态追加脱敏词,追加后自动重编译正则。 * diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index c9f5b3ad..e1e4861a 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -31,15 +31,23 @@ import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; -export default function AgentChatPage() { +export default function AgentChatPage({ + embeddedAgentId, +}: { + /** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。 + * 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */ + embeddedAgentId?: string; +} = {}) { const routeBase = useRouteBase(); const { t } = useI18n(); const [showFollowups, setShowFollowups] = useState(false); const router = useRouter(); - const { agent_id } = useParams<{ + const { agent_id: agentIdFromPath } = useParams<{ agent_id: string; }>(); + const agent_id = embeddedAgentId ?? agentIdFromPath; + const isEmbedded = !!embeddedAgentId; // If ChatPage stashed an opening message for this agent, skip the landing // screen on first render so the user doesn't see a flash of the welcome UI @@ -208,7 +216,13 @@ export default function AgentChatPage() { size="sm" variant="secondary" onClick={() => { - router.push(`${routeBase}/agents/${agent_id}/chats/new`); + // 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话; + // 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。 + router.push( + isEmbedded + ? `/page/workspace/chats/new?agent=${agent_id}` + : `${routeBase}/agents/${agent_id}/chats/new`, + ); }} > {t.agents.newChat} diff --git a/frontend-web/src/pages/BusinessChainMappingPage.tsx b/frontend-web/src/pages/BusinessChainMappingPage.tsx index 291f3cdd..7efffe08 100644 --- a/frontend-web/src/pages/BusinessChainMappingPage.tsx +++ b/frontend-web/src/pages/BusinessChainMappingPage.tsx @@ -31,10 +31,13 @@ import { type BusinessMapping, } from "@/strategy-components/api/business-mapping"; import { cn } from "@/lib/utils"; +import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; export default function BusinessChainMappingPage() { const { theme } = useTheme(); const isDark = theme === "dark"; + // 整片脱敏:业务链条映射页展示文本(链条名/描述/席位名/标题)。输入框 Hook 自动跳过。 + const dzRef = useDesensitizeSubtree(); const [rows, setRows] = useState([]); const [agents, setAgents] = useState([]); @@ -160,7 +163,7 @@ export default function BusinessChainMappingPage() { ]; return ( -
+
{/* ── 页头 banner ── */} diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 930dac06..7300ae31 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -34,7 +34,7 @@ import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-rout import { useSidebar } from "@/components/ui/sidebar"; import { Button } from "@/components/ui/button"; import { PanelLeftOpen } from "lucide-react"; -import { useAgentSelector } from "@/core/agents"; +import { useAgentSelector, useAgentsPaginated } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; @@ -130,6 +130,10 @@ export default function ChatPage() { const browserContextAutoSubmitRef = useRef(null); const { agents: selectorAgents, isLoading: selectorLoading } = useAgentSelector(); + // 已选 agent 的存在性校验需要更宽的来源:侧边栏可选未配置进管理弹窗的 agent + // (用户自己的智能体),这些也算有效,故把「我的 / 广场」一并纳入校验集(缓存共享)。 + const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 }); + const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 }); const { selectedArtifact } = useArtifacts(); useSpecificChatMode(); @@ -142,7 +146,7 @@ export default function ChatPage() { setHasStartedConversation(false); setPromptPrefixOn(false); // Pre-select the agent the user last started a conversation with so a - // fresh chat defaults to it. Validated against the selector list below. + // fresh chat defaults to it. Validated against the known agent list below. setSelectedAgentId(getLastAgentId()); } }, [isNewThread, threadId]); @@ -152,10 +156,14 @@ export default function ChatPage() { // dead agent. Wait for the list to load before deciding. useEffect(() => { if (selectorLoading || !selectedAgentId) return; - if (!selectorAgents.some((a) => a.id === selectedAgentId)) { + // 校验集 = 管理弹窗 ∪ 我的 ∪ 广场;任一存在即视为有效(含用户未配置进管理弹窗 + // 的自有智能体)。三者都还没加载出来时不做清除,避免把恢复值误删。 + const known = [...selectorAgents, ...mineAgents, ...squareAgents]; + if (known.length === 0) return; + if (!known.some((a) => a.id === selectedAgentId)) { setSelectedAgentId(null); } - }, [selectedAgentId, selectorAgents, selectorLoading]); + }, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]); const { showNotification } = useNotification(); diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index edf48355..04a48059 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -145,6 +145,21 @@ export function ChatRuntime({ children }: { children: ReactNode }) { ); } +/** + * 通用问答新对话内容区:默认渲染 ChatPage;当 URL 带 ``?agent=``(点击左栏 + * 「常用智能体」时由侧边栏写入)时,**在右侧问答区原地嵌入**该智能体的对话页 + * (AgentChatPage),路由仍停留在 /chats/new,不跳转出去、不更换左侧栏。 + * key 绑定 location.key —— 每次点击(即便同一 agent)都重开一个干净的会话。 + */ +function NewChatContent() { + const location = useLocation(); + const agentId = new URLSearchParams(location.search).get('agent'); + if (agentId) { + return ; + } + return ; +} + function NewChatEntryRoute({ layoutProps, }: { @@ -163,7 +178,7 @@ function NewChatEntryRoute({ return ( - + ); diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx index 5a7faa6f..1b813bda 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx @@ -29,6 +29,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; +import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { listAgents } from "@/core/agents"; @@ -212,9 +213,11 @@ export function BusinessChainListDialog({ onOpenChange, onConfigure, }: BusinessChainListDialogProps) { + // 整片脱敏:弹窗内链条列表(链条名/描述/席位名/标题)。 + const dzRef = useDesensitizeSubtree(); return ( - + diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx index f484cc6f..60dda10c 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx @@ -26,6 +26,7 @@ import { import { Button } from "@/components/ui/button"; import { DialogFooter } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; +import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { listAgents } from "@/core/agents"; import { filterRecommendCandidates } from "../api/recommend"; @@ -161,9 +162,12 @@ export function BusinessChainPicker({ onConfirm({ agents, mode: "chain", chain: chainMeta }); }; + // 整片脱敏:链条卡片区(链条名/描述/席位智能体名)。输入框不受影响(Hook 跳过)。 + const dzRef = useDesensitizeSubtree(); + return ( <> -
+
{/* 筛选:我的 / 公共 */}
{(["mine", "public"] as const).map((s) => ( diff --git a/frontend-web/src/roundtable-planning/components/DagFlowChart.tsx b/frontend-web/src/roundtable-planning/components/DagFlowChart.tsx index 5570d03b..59ca95f3 100644 --- a/frontend-web/src/roundtable-planning/components/DagFlowChart.tsx +++ b/frontend-web/src/roundtable-planning/components/DagFlowChart.tsx @@ -21,6 +21,7 @@ */ import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react"; +import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; import { getAvatar } from "./Avatars"; @@ -137,7 +138,7 @@ export function DagFlowChart({ return ( diff --git a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx index c95e82a6..c3d988d6 100644 --- a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx +++ b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx @@ -26,6 +26,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; import { getAvatar } from "./Avatars"; @@ -236,7 +237,7 @@ export function DispatchChainModal({ done={node.state === "done"} />
{getAvatar(avatarForDialogue(d), "h-7 w-7")}
-
{d.name}
+
{desensitize(d.name)}
{d.content || "…"}
diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index f12a0bb5..90903387 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -32,6 +32,7 @@ import { type ThreadIdMap, } from "@/roundtable-planning/api/multi-agent"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; +import { desensitize } from "@/lib/desensitize"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { Step3ReportMode, @@ -707,7 +708,7 @@ export const HighFidelityReport: React.FC = ({ lit ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground", )} > - {seat.displayName} + {desensitize(seat.displayName)} {lit ? `${seat.delivery!.length} 字` : "无交付"} @@ -771,7 +772,7 @@ export const HighFidelityReport: React.FC = ({ {consensusText ? (
- {consensusText} + {desensitize(consensusText)}
) : ( @@ -830,7 +831,7 @@ export const HighFidelityReport: React.FC = ({ {getAvatar(seat.avatarType, "h-5.5 w-5.5")}
- {seat.displayName} + {desensitize(seat.displayName)}
@@ -858,7 +859,7 @@ export const HighFidelityReport: React.FC = ({ remarkPlugins={[remarkGfm]} components={markdownComponents} > - {seat.delivery!} + {desensitize(seat.delivery!)} ) : (

该席位暂无交付内容。

diff --git a/frontend-web/src/roundtable-planning/components/MessageBubble.tsx b/frontend-web/src/roundtable-planning/components/MessageBubble.tsx index 06e90626..89748b3a 100644 --- a/frontend-web/src/roundtable-planning/components/MessageBubble.tsx +++ b/frontend-web/src/roundtable-planning/components/MessageBubble.tsx @@ -18,6 +18,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react"; +import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; export interface MessageBubbleProps { @@ -58,7 +59,8 @@ export function MessageBubble({ senderNameClassName, )} > - {senderName} + {/* 智能体名称需脱敏(消息列表里唯一要脱敏的就是名称)。 */} + {desensitize(senderName)}
{badge && ( @@ -190,6 +192,7 @@ export const sharedMarkdownComponents = { * 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。 * 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。 */ +// 消息列表正文**不脱敏**(仅名称脱敏);MarkdownContent 是会商对话气泡正文渲染器,故原文渲染。 export function MarkdownContent({ children }: { children: string }) { return ( diff --git a/frontend-web/src/roundtable-planning/components/ParallelSeatStrip.tsx b/frontend-web/src/roundtable-planning/components/ParallelSeatStrip.tsx index 5e03c3e1..d5b63bce 100644 --- a/frontend-web/src/roundtable-planning/components/ParallelSeatStrip.tsx +++ b/frontend-web/src/roundtable-planning/components/ParallelSeatStrip.tsx @@ -11,6 +11,7 @@ */ import { CheckIcon, Loader2Icon, XIcon } from "lucide-react"; +import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; import { getAvatar } from "@/roundtable-planning/components/Avatars"; import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration"; @@ -42,7 +43,7 @@ export function ParallelSeatStrip({ batch, selectedSeats, onToggleSeat }: Parall key={seat.agentId} type="button" onClick={() => onToggleSeat(seat.agentId)} - title={`${seat.displayName}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`} + title={`${desensitize(seat.displayName)}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`} className={cn( "relative shrink-0 rounded-full transition", isSelected ? "ring-2 ring-primary ring-offset-1" : "ring-1 ring-transparent hover:ring-slate-300", diff --git a/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx index 61bb1de5..6619cc2f 100644 --- a/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx +++ b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx @@ -23,6 +23,7 @@ import { import { Button } from "@/components/ui/button"; import { listAgents, type Agent } from "@/core/agents"; import { useTheme } from "@/contexts/ThemeContext"; +import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; import { @@ -1122,7 +1123,7 @@ function AgentCard({
-
{candidate.name || candidate.agent_id}
+
{desensitize(candidate.name || candidate.agent_id)}
{recommended && ( 推荐 @@ -1131,12 +1132,12 @@ function AgentCard({
{candidate.description && (
- {candidate.description} + {desensitize(candidate.description)}
)} {reason ? (
- 推荐理由: {reason} + 推荐理由: {desensitize(reason)}
) : streaming ? (
正在生成推荐理由...
diff --git a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx index 695bae14..202dbaee 100644 --- a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx @@ -56,6 +56,7 @@ import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToB import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent"; +import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning"; import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants"; import type { @@ -149,10 +150,13 @@ export function Step1Panel(props: Step1PanelProps) { setAttachedFiles([]); }; + // 顶部标题栏整片脱敏(消息列表正文在下方,不在此容器内,保持原文)。 + const headerDzRef = useDesensitizeSubtree(); + return (
{/* Dialogue Header */} -
+

diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 7835841a..6d3e2d90 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -81,6 +81,8 @@ import { getAvatarTypeFor, type RoleEntry, } from "@/roundtable-planning/lib/roundtable-constants"; +import { desensitize } from "@/lib/desensitize"; +import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning"; import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; @@ -405,10 +407,13 @@ export function Step2Panel(props: Step2PanelProps) { isOrchestrating || step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true); + // 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。 + const headerDzRef = useDesensitizeSubtree(); + const chatBody = ( <> {/* Headline */} -
+
@@ -584,7 +589,7 @@ export function Step2Panel(props: Step2PanelProps) { /> ) : (
- {seat.displayName} · 等待输出… + {desensitize(seat.displayName)} · 等待输出…
)}
@@ -599,7 +604,7 @@ export function Step2Panel(props: Step2PanelProps) { 仅显示{" "} - {seatFilterLabel(seatFilter)} + {desensitize(seatFilterLabel(seatFilter))} {" "} 的发言 @@ -615,7 +620,7 @@ export function Step2Panel(props: Step2PanelProps) { )} {seatFilter && visibleDialogues.length === 0 ? (
- 没有匹配「{seatFilterLabel(seatFilter)}」的消息 + 没有匹配「{desensitize(seatFilterLabel(seatFilter))}」的消息
) : ( visibleDialogues.map((msg) => ( @@ -878,7 +883,7 @@ export function Step2Message({ title={ isAvatarSelected ? "取消筛选,显示全部发言" - : `只看 ${msg.sender} 的发言` + : `只看 ${desensitize(msg.sender)} 的发言` } className={cn( "shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60", diff --git a/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx b/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx index 59585fa9..6c6bec18 100644 --- a/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx @@ -116,6 +116,8 @@ export function Step2SandboxLayout({ threadId={previewThreadId} onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined} allowVirtualMarkdownEdit + // 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。 + disableDesensitize /> ) : (
diff --git a/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx b/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx index 9fce3033..09fd6470 100644 --- a/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx @@ -35,6 +35,7 @@ import { } from "lucide-react"; import { useLocalSettings } from "@/core/settings"; +import { desensitizeDeep } from "@/lib/desensitize"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp"; import { adaptReportData } from "@/roundtable-planning/dashboard/data-context"; @@ -101,10 +102,12 @@ export function Step3DashboardMode({ } = dashboard; // 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。 - const report = useMemo( - () => dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null), - [dashboard.data, savedReport?.dashboardJson], - ); + // 整体深度脱敏后再渲染——大屏(DashboardApp)与自包含 HTML(iframe/新窗口/分享/下载) + // 都从这份 report 派生,故在此一处脱敏即覆盖大屏的所有展示文本(会商页非沙箱内容需脱敏)。 + const report = useMemo(() => { + const raw = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null); + return raw ? desensitizeDeep(raw) : raw; + }, [dashboard.data, savedReport?.dashboardJson]); // 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。 const dashboardData = useMemo( diff --git a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx index c9a49fed..f743d794 100644 --- a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx @@ -44,6 +44,7 @@ import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/Co import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; +import { desensitize } from "@/lib/desensitize"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode"; @@ -407,7 +408,7 @@ export function Step3ReportMode({ > {consensusText ? ( - {consensusText} + {desensitize(consensusText)} ) : (

暂无总控结论。

@@ -773,14 +774,14 @@ function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] >
- {seat.sender} + {desensitize(seat.sender)}
{seat.content.length} 字 {open && (
- {seat.content || "(空)"} + {desensitize(seat.content || "(空)")}
)} diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx index a8dce3a7..4a317329 100644 --- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx @@ -51,6 +51,7 @@ import { import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; +import { desensitize } from "@/lib/desensitize"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; @@ -449,7 +450,7 @@ export function Step3SummaryMode({ > {consensusText ? ( - {consensusText} + {desensitize(consensusText)} ) : (

暂无总控结论。

@@ -846,14 +847,14 @@ function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] >
- {seat.sender} + {desensitize(seat.sender)}
{seat.content.length} 字 {open && (
- {seat.content || "(空)"} + {desensitize(seat.content || "(空)")}
)} diff --git a/frontend-web/src/roundtable-planning/lib/step-display.tsx b/frontend-web/src/roundtable-planning/lib/step-display.tsx index e7887c64..36ffa9dd 100644 --- a/frontend-web/src/roundtable-planning/lib/step-display.tsx +++ b/frontend-web/src/roundtable-planning/lib/step-display.tsx @@ -24,6 +24,7 @@ import { import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought"; import { CodeBlock } from "@/components/ai-elements/code-block"; +import { desensitize } from "@/lib/desensitize"; import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble"; /** @@ -52,7 +53,7 @@ export function renderStepArgs( return ( <> {displayName && ( - {displayName} + {desensitize(displayName)} )} {task && (
diff --git a/frontend-web/src/roundtable-planning/lib/use-desensitize-subtree.ts b/frontend-web/src/roundtable-planning/lib/use-desensitize-subtree.ts new file mode 100644 index 00000000..c5185621 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/use-desensitize-subtree.ts @@ -0,0 +1,67 @@ +/** + * 区域级脱敏 Hook —— 把某个容器(DOM 子树)里的**所有文本节点**做脱敏, + * 用于「左/右侧面板 + 顶部标题」这类需要整片脱敏的区域:静态标题、动态内容、 + * 嵌套子组件文本一并覆盖,无需逐串包 `desensitize()`(那样易漏、难维护)。 + * + * 用法:`const ref = useDesensitizeSubtree();
…
`。 + * + * 设计要点: + * - 只读改文本节点(display-only),不碰 `/