代码修改

This commit is contained in:
董宏凯 committed 2026-09-05 11:28:25 +08:00
1 parent b80433f091
commit 814a44f0b9
8 files changed
+225 -5

No files matched your search

@@ -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<void>;
@@ -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 (
<div key={group.id} className="w-full">
{speakerShell(group.messages, items)}
</div>
);
}
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 (
<div key={"group-" + group.id} className="w-full">
const processingBody = (
<div className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
@@ -866,6 +880,11 @@ export function MessageList({
/>
</div>
);
return (
<div key={"group-" + group.id} className="w-full">
{speakerShell ? speakerShell(group.messages, processingBody) : processingBody}
</div>
);
});
const leftoverRewriteItems = didRenderRewriteAboveFile
? rewriteItemsBelowFile
@@ -14,6 +14,8 @@ interface ReportConversationPanelProps {
sessionId: string;
thread: BaseStream<AgentThreadState>;
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}
/>
</ThreadContext.Provider>
);
}, [businessCardSlot, emptyHint, hasMessages, listKey, loading, sessionId, thread]);
}, [businessCardSlot, emptyHint, hasMessages, listKey, loading, sessionId, speakerShell, thread]);
return (
<SubtasksProvider>
@@ -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 ? (
<button
type="button"
onClick={() => onAvatarClick?.(speaker.agentRunId!)}
aria-pressed={avatarSelected}
title={
avatarSelected
? "取消筛选,显示全部发言"
: `只看 ${desensitize(speaker.name)} 的发言`
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
avatarSelected && "ring-2 ring-primary ring-offset-1 ring-offset-white",
)}
>
{avatarInner}
</button>
) : (
avatarInner
);
return (
<MessageBubble
avatar={avatar}
senderName={speaker.name}
badge={speaker.role && speaker.role !== speaker.name ? speaker.role : undefined}
time={speaker.time}
>
{children}
</MessageBubble>
);
}
@@ -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 (
<ReportSpeakerShell
speaker={speaker}
avatarSelected={Boolean(speaker.agentRunId) && effectiveFocus === speaker.agentRunId}
onAvatarClick={(agentRunId) => {
if (focusPinned && effectiveFocus === agentRunId) {
setFocusedAgentRunId(null);
setFocusPinned(false);
return;
}
setFocusedAgentRunId(agentRunId);
setFocusPinned(true);
}}
>
{children}
</ReportSpeakerShell>
);
},
[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={
@@ -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,
};
}
@@ -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<string, unknown> | 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<string, AgentRun>,
): 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),
};
}
@@ -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;
@@ -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", "开始"),