From 814a44f0b941980e3691d8cacecd8f03bb3d56ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 5 Sep 2026 11:28:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/messages/message-list.tsx | 27 ++++++-- .../components/ReportConversationPanel.tsx | 7 +- .../components/ReportSpeakerShell.tsx | 60 +++++++++++++++++ .../pages/ReportCollaborationPage.tsx | 28 ++++++++ .../state/message-adapter.ts | 4 ++ .../src/report-collaboration/state/speaker.ts | 65 +++++++++++++++++++ .../styles/report-collaboration.css | 9 +++ .../tests/message-adapter.test.ts | 30 +++++++++ 8 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 frontend-web/src/report-collaboration/components/ReportSpeakerShell.tsx create mode 100644 frontend-web/src/report-collaboration/state/speaker.ts diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 577d844a..b9445235 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -1,7 +1,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react"; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useStickToBottomContext } from "use-stick-to-bottom"; import { @@ -137,6 +137,7 @@ export function MessageList({ researchApprovalSlot, taskReportImportSlot, businessCardSlot, + speakerShell, onOpenArtifact, onDownloadArtifact, afterDocumentRewriteItems, @@ -185,6 +186,11 @@ export function MessageList({ * 层定义,MessageList 不感知具体业务字段。 */ businessCardSlot?: (message: Message) => React.ReactNode; + /** + * 可选说话人壳(报告协作对齐会商第二步):包住 assistant / processing 分组。 + * 不传则问答页零变化。 + */ + speakerShell?: (messages: Message[], children: ReactNode) => ReactNode; onOpenArtifact?: (filepath: string) => void; /** Host override for downloading virtual/session-owned files in a present-files card. */ onDownloadArtifact?: (filepath: string) => void | Promise; @@ -536,7 +542,7 @@ export function MessageList({ let didRenderRewriteAboveFile = false; const grouped = groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { - return group.messages.map((msg) => { + const items = group.messages.map((msg) => { // 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态, // 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。 const messageIsLoading = @@ -582,6 +588,14 @@ export function MessageList({ /> ); }); + if (speakerShell && group.type === "assistant") { + return ( +
+ {speakerShell(group.messages, items)} +
+ ); + } + return items; } else if (group.type === "assistant:clarification") { const message = group.messages[0]; if (message && hasContent(message)) { @@ -851,8 +865,8 @@ export function MessageList({ const processingAi = [...group.messages] .reverse() .find((message) => message.type === "ai"); - return ( -
+ const processingBody = ( +
); + return ( +
+ {speakerShell ? speakerShell(group.messages, processingBody) : processingBody} +
+ ); }); const leftoverRewriteItems = didRenderRewriteAboveFile ? rewriteItemsBelowFile diff --git a/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx b/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx index 627b7aec..879571fc 100644 --- a/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx +++ b/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx @@ -14,6 +14,8 @@ interface ReportConversationPanelProps { sessionId: string; thread: BaseStream; businessCardSlot: (message: Message) => React.ReactNode; + /** 会商第二步式说话人壳;不传则只渲染原生 MessageList。 */ + speakerShell?: (messages: Message[], children: React.ReactNode) => React.ReactNode; stageSelector?: React.ReactNode; composer: React.ReactNode; loading: boolean; @@ -30,6 +32,7 @@ export function ReportConversationPanel({ sessionId, thread, businessCardSlot, + speakerShell, stageSelector, composer, loading, @@ -59,11 +62,13 @@ export function ReportConversationPanel({ thread={thread} paddingBottom={24} scrollButtonClassName="bottom-2" + contentClassName={speakerShell ? "gap-6 pt-6" : undefined} businessCardSlot={businessCardSlot} + speakerShell={speakerShell} /> ); - }, [businessCardSlot, emptyHint, hasMessages, listKey, loading, sessionId, thread]); + }, [businessCardSlot, emptyHint, hasMessages, listKey, loading, sessionId, speakerShell, thread]); return ( diff --git a/frontend-web/src/report-collaboration/components/ReportSpeakerShell.tsx b/frontend-web/src/report-collaboration/components/ReportSpeakerShell.tsx new file mode 100644 index 00000000..db02e974 --- /dev/null +++ b/frontend-web/src/report-collaboration/components/ReportSpeakerShell.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; +import { getAvatar } from "@/roundtable-planning/components/Avatars"; +import { MessageBubble } from "@/roundtable-planning/components/MessageBubble"; +import type { ReportSpeaker } from "@/report-collaboration/state/speaker"; + +interface ReportSpeakerShellProps { + speaker: ReportSpeaker; + onAvatarClick?: (agentRunId: string) => void; + avatarSelected?: boolean; + children: ReactNode; +} + +/** + * 会商第二步气泡壳:头像 + 席位名 + 正文(步骤条 / 思考 / 输出仍走 MessageList)。 + */ +export function ReportSpeakerShell({ + speaker, + onAvatarClick, + avatarSelected = false, + children, +}: ReportSpeakerShellProps) { + const avatarInner = getAvatar(speaker.avatarType, "h-9 w-9 shrink-0"); + const canFilter = Boolean(speaker.agentRunId && onAvatarClick); + const avatar = canFilter ? ( + + ) : ( + avatarInner + ); + + return ( + + {children} + + ); +} diff --git a/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx b/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx index 1a08adaa..93c498a1 100644 --- a/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx +++ b/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx @@ -25,6 +25,7 @@ import { ReportPreviewDialog, } from "@/report-collaboration/components/ReportPreviewDialog"; import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay"; +import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell"; import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel"; import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout"; import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages"; @@ -36,6 +37,7 @@ import { buildReportAvailableCard, readReportCollaborationCard, } from "@/report-collaboration/state/message-adapter"; +import { resolveReportSpeaker } from "@/report-collaboration/state/speaker"; import { derivePagePhase, selectAgentRunForNode, @@ -386,6 +388,31 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin [previewPlan, previewPlanId, session], ); + const speakerShell = useCallback( + (messages: Message[], children: React.ReactNode) => { + const speaker = resolveReportSpeaker(messages, state.agentRuns); + if (!speaker) return children; + return ( + { + if (focusPinned && effectiveFocus === agentRunId) { + setFocusedAgentRunId(null); + setFocusPinned(false); + return; + } + setFocusedAgentRunId(agentRunId); + setFocusPinned(true); + }} + > + {children} + + ); + }, + [effectiveFocus, focusPinned, state.agentRuns], + ); + const runActive = isRunActive(state.run?.status); const headVersion = selectHeadReportVersion(state); const nodeStates = selectNodeStatusMap(state); @@ -478,6 +505,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin sessionId={sessionId} thread={displayThread} businessCardSlot={businessCardSlot} + speakerShell={speakerShell} listKey={effectiveFocus ?? "timeline"} loading={session.snapshotLoading} stageSelector={ diff --git a/frontend-web/src/report-collaboration/state/message-adapter.ts b/frontend-web/src/report-collaboration/state/message-adapter.ts index 36b3df4e..32fa3b95 100644 --- a/frontend-web/src/report-collaboration/state/message-adapter.ts +++ b/frontend-web/src/report-collaboration/state/message-adapter.ts @@ -33,6 +33,8 @@ export interface ReportCollaborationKwargs { agentRunId?: string; nodeRunId?: string; phaseId?: string; + /** 消息创建时间,供会商式说话人行展示。 */ + createdAt?: string; /** 前端乐观消息(尚未被服务器确认)。 */ optimistic?: boolean; } @@ -42,6 +44,7 @@ export function collaborationKwargs(message: CollaborationMessage): ReportCollab if (message.agent_run_id) kwargs.agentRunId = message.agent_run_id; if (message.node_run_id) kwargs.nodeRunId = message.node_run_id; if (message.phase_id) kwargs.phaseId = message.phase_id; + if (message.created_at) kwargs.createdAt = message.created_at; return kwargs; } @@ -201,6 +204,7 @@ export function buildReportWriteFileMessages(source: ReportWriteFileSource): Mes agentRunId: writer.agent_run_id, nodeRunId: writer.node_run_id ?? undefined, phaseId: writer.phase_id ?? undefined, + createdAt: writer.started_at ?? undefined, }; } diff --git a/frontend-web/src/report-collaboration/state/speaker.ts b/frontend-web/src/report-collaboration/state/speaker.ts new file mode 100644 index 00000000..6374e880 --- /dev/null +++ b/frontend-web/src/report-collaboration/state/speaker.ts @@ -0,0 +1,65 @@ +/** + * 把协作消息投影成会商第二步气泡的说话人身份(头像色 + 席位名)。 + * MessageList 本身不展示 `message.name`,报告协作页用这一层补上。 + */ + +import type { Message } from "@langchain/langgraph-sdk"; + +import type { AgentRun } from "@/report-collaboration/api/types"; +import type { ReportCollaborationKwargs } from "@/report-collaboration/state/message-adapter"; +import { FALLBACK_AVATAR_TYPES } from "@/roundtable-planning/lib/roundtable-constants"; + +export interface ReportSpeaker { + name: string; + role?: string; + avatarType: string; + agentRunId?: string; + time: string; +} + +function readKwargs(message: Message): ReportCollaborationKwargs | undefined { + const raw = (message.additional_kwargs as Record | undefined) + ?.report_collaboration; + if (!raw || typeof raw !== "object") return undefined; + return raw as ReportCollaborationKwargs; +} + +function hashAvatarType(seed: string): string { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + return FALLBACK_AVATAR_TYPES[hash % FALLBACK_AVATAR_TYPES.length]; +} + +function formatSpeakerTime(iso?: string): string { + if (!iso) return ""; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleTimeString("zh-CN", { hour12: false }); +} + +export function resolveReportSpeaker( + messages: Message[], + agentRuns: Record, +): ReportSpeaker | null { + const owner = messages.find((message) => message.type === "ai"); + if (!owner) return null; + const kwargs = readKwargs(owner); + const agentRunId = kwargs?.agentRunId; + const run = agentRunId ? agentRuns[agentRunId] : undefined; + const named = typeof owner.name === "string" ? owner.name.trim() : ""; + const name = named || run?.display_name || run?.role || ""; + if (!name) return null; + const role = run?.role; + const seed = agentRunId || name; + const avatarType = + role === "协调者" || seed.includes("coordinator") ? "blue" : hashAvatarType(seed); + return { + name, + role, + avatarType, + agentRunId, + time: formatSpeakerTime(kwargs?.createdAt), + }; +} diff --git a/frontend-web/src/report-collaboration/styles/report-collaboration.css b/frontend-web/src/report-collaboration/styles/report-collaboration.css index 029da547..3164c835 100644 --- a/frontend-web/src/report-collaboration/styles/report-collaboration.css +++ b/frontend-web/src/report-collaboration/styles/report-collaboration.css @@ -314,6 +314,15 @@ flex-shrink: 0; } +/* 会商第二步说话人行:头像+席位名包在步骤条/正文外面 */ +.rc-root .roundtable-chat-message { + color: var(--rc-text-primary); +} + +.rc-root .roundtable-chat-meta { + color: var(--rc-text-secondary); +} + /* 画布上的沙箱覆盖层:write_file 打开后占满右侧,关闭归还流程图 */ .rc-sandbox-overlay { position: absolute; diff --git a/frontend-web/src/report-collaboration/tests/message-adapter.test.ts b/frontend-web/src/report-collaboration/tests/message-adapter.test.ts index 67310e76..60da54d7 100644 --- a/frontend-web/src/report-collaboration/tests/message-adapter.test.ts +++ b/frontend-web/src/report-collaboration/tests/message-adapter.test.ts @@ -11,6 +11,7 @@ import { toLangGraphMessages, } from "../state/message-adapter"; import { selectMessagesForAgentRun, selectAgentTabs, selectParallelMembers, selectConversationMessages, stitchParallelAgentBlocks } from "../state/selectors"; +import { resolveReportSpeaker } from "../state/speaker"; import { createInitialCollaborationState } from "../state/event-reducer"; import type { AgentRun, CollaborationMessage } from "../api/types"; @@ -60,6 +61,35 @@ test("一对一映射:human/ai/tool 保持原文与归属标记", () => { assert.equal(kwargs?.agentRunId, "a_brand"); }); +test("说话人身份:优先 message.name,缺省时用 agentRun.display_name", () => { + const named = toLangGraphMessages([ai("a1", "a_brand", "品牌结论")])[0]; + const fromName = resolveReportSpeaker([named], { + a_brand: agentRun({ agent_run_id: "a_brand", display_name: "品牌竞争分析师" }), + }); + assert.equal(fromName?.name, "品牌竞争分析师"); + assert.equal(fromName?.agentRunId, "a_brand"); + + const unnamed: CollaborationMessage = { + message_id: "a2", + role: "ai", + content: "", + agent_run_id: "a_verify", + visibility: "visible", + created_at: "2026-09-05T03:00:00.000Z", + completed_at: null, + tool_calls: [{ id: "tc1", name: "read_file", args: {} }], + }; + const fromRun = resolveReportSpeaker(toLangGraphMessages([unnamed]), { + a_verify: agentRun({ + agent_run_id: "a_verify", + display_name: "交叉核验", + role: "核验员", + }), + }); + assert.equal(fromRun?.name, "交叉核验"); + assert.equal(fromRun?.role, "核验员"); +}); + test("并行聚焦:只保留目标成员自己的消息,不混入 human / 协调者 / 其它成员", () => { const messages = [ human("h1", "开始"),