diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index 7903cd73..7978b9bf 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -68,6 +68,12 @@ import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeat import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; +import { + seatFilterForSender, + seatFilterLabel, + senderMatchesSeatFilter, + type SeatFilter, +} from "@/roundtable-planning/lib/seat-filter"; import type { AgentConfig, ParallelSeatView, @@ -100,6 +106,10 @@ export interface Step2PanelProps { agentConfigs: Record; selectedAgentId: number | null; roles: RoleEntry[]; + // ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)────────────── + /** 当前筛选标识;null = 显示全部。 */ + seatFilter: SeatFilter | null; + setSeatFilter: React.Dispatch>; // ─── 并行显示(§5)──────────────────────────────────────────── parallelBatch: ParallelSeatView[]; selectedParallelSeats: string[]; @@ -161,6 +171,8 @@ export function Step2Panel(props: Step2PanelProps) { agentConfigs, selectedAgentId, roles, + seatFilter, + setSeatFilter, parallelBatch, selectedParallelSeats, isParallelRunning, @@ -201,6 +213,36 @@ export function Step2Panel(props: Step2PanelProps) { const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues); + // ─── 按席位筛选会商记录 ────────────────────────────────────────────────── + // 筛选状态在页面层(与左侧「参与角色 / 人工参与」卡片共用)。点卡片或点气泡头像 + // 都把目标归一化成同一 SeatFilter 标识(见 lib/seat-filter),命中 = 标识相等。 + // 仅在单列(非并行)视图启用;并行进行时主区另有「头像条选席位」机制,互不干扰。 + const toggleSeatFilter = useCallback( + (filter: SeatFilter) => + setSeatFilter((cur) => (cur === filter ? null : filter)), + [setSeatFilter], + ); + const visibleDialogues = useMemo( + () => + seatFilter + ? step2RoundtableDialogues.filter((d) => + senderMatchesSeatFilter(d.sender, seatFilter), + ) + : step2RoundtableDialogues, + [seatFilter, step2RoundtableDialogues], + ); + // 选中的席位若已不在对话里(切换草稿 / 重置后),自动清除筛选,避免卡在空列表。 + useEffect(() => { + if ( + seatFilter && + !step2RoundtableDialogues.some((d) => + senderMatchesSeatFilter(d.sender, seatFilter), + ) + ) { + setSeatFilter(null); + } + }, [seatFilter, step2RoundtableDialogues, setSeatFilter]); + // 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换, // scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随 // (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。 @@ -485,20 +527,55 @@ export function Step2Panel(props: Step2PanelProps) { })} ) : ( - step2RoundtableDialogues.map((msg) => ( - openArtifactFromStep(msg, step)} - onFileCardClick={(path) => { - if (msg.agentThreadId) { - openArtifactPreview(msg.agentThreadId, path); - } - }} - /> - )) + <> + {/* 席位筛选提示条:点卡片 / 头像只看某席位发言时出现,可一键显示全部。 */} + {seatFilter && ( +
+ + 仅显示{" "} + + {seatFilterLabel(seatFilter)} + {" "} + 的发言 + + +
+ )} + {seatFilter && visibleDialogues.length === 0 ? ( +
+ 没有匹配「{seatFilterLabel(seatFilter)}」的消息 +
+ ) : ( + visibleDialogues.map((msg) => ( + + toggleSeatFilter(seatFilterForSender(msg.sender)) + } + resolveAgentName={resolveAgentName} + onArtifactStepClick={(step) => openArtifactFromStep(msg, step)} + onFileCardClick={(path) => { + if (msg.agentThreadId) { + openArtifactPreview(msg.agentThreadId, path); + } + }} + /> + )) + )} + )} {!step2AtBottom && } @@ -706,6 +783,13 @@ export interface Step2MessageProps { * 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。 */ metric?: MessageMetric; + /** + * 点击头像的回调 —— 传入则头像变为可点的「按席位筛选」开关。不传(并行两列等 + * 场景)则头像为纯展示,保持原样。 + */ + onAvatarClick?: () => void; + /** 当前筛选是否正命中本气泡的席位(命中则头像高亮)。 */ + isAvatarSelected?: boolean; /** 把派活 chip 的 agent id 解析成中文席位名。 */ resolveAgentName?: (idOrName: string) => string; onArtifactStepClick?: ( @@ -718,6 +802,8 @@ export interface Step2MessageProps { export function Step2Message({ msg, metric, + onAvatarClick, + isAvatarSelected = false, resolveAgentName, onArtifactStepClick, onFileCardClick, @@ -732,9 +818,32 @@ export function Step2Message({ const hasContent = content.length > 0; const presentedFiles = collectPresentedFiles(msg.steps); + const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0"); + // 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。 + const avatar = onAvatarClick ? ( + + ) : ( + avatarInner + ); + return ( "` —— 某 AI 席位的发言(对应该席位卡片) + */ +import { COMMANDER_SEAT_ID, type RoleEntry } from "./roundtable-constants"; + +export type SeatFilter = string; + +/** 总控协调气泡固定使用的 sender(见 useStep2Orchestration 的 appendStreamingDialogue)。 */ +export const COORDINATOR_SENDER = "总控协调"; + +export const COORDINATOR_SEAT_FILTER: SeatFilter = "coordinator"; +export const USER_SEAT_FILTER: SeatFilter = "user"; + +const AGENT_PREFIX = "agent:"; + +/** 把一条气泡的 sender 归一化成筛选标识。 */ +export function seatFilterForSender(sender: string): SeatFilter { + if (sender === COORDINATOR_SENDER || sender === "系统") { + return COORDINATOR_SEAT_FILTER; + } + if (sender.startsWith("用户")) { + return USER_SEAT_FILTER; + } + // 去掉「方案设计 (deepseek-…)」这种「模型偏好已保存」播报的模型后缀,回到席位基名, + // 让它与该席位的正常发言归到同一标识。 + const base = sender.replace(/\s*\([^)]*\)\s*$/, "").trim(); + return `${AGENT_PREFIX}${base}`; +} + +/** 把「参与角色」里的一行(席位)映射成筛选标识。 */ +export function seatFilterForRole(role: RoleEntry): SeatFilter { + // 指挥员 / 主持人(COMMANDER_SEAT_ID,human):对应总控协调 / 系统 调度播报流。 + if (role.type === "human" || role.id === COMMANDER_SEAT_ID) { + return COORDINATOR_SEAT_FILTER; + } + return `${AGENT_PREFIX}${role.name}`; +} + +/** 某 sender 是否命中当前筛选。 */ +export function senderMatchesSeatFilter( + sender: string, + filter: SeatFilter, +): boolean { + return seatFilterForSender(sender) === filter; +} + +/** 筛选提示条用的人类可读标签。 */ +export function seatFilterLabel(filter: SeatFilter): string { + if (filter === COORDINATOR_SEAT_FILTER) return "指挥员 / 主持人"; + if (filter === USER_SEAT_FILTER) return "用户"; + if (filter.startsWith(AGENT_PREFIX)) return filter.slice(AGENT_PREFIX.length); + return filter; +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 1ee88874..bcf6cd2b 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -86,6 +86,11 @@ import { type RoleEntry, type SelectedAgent, } from "@/roundtable-planning/lib/roundtable-constants"; +import { + seatFilterForRole, + USER_SEAT_FILTER, + type SeatFilter, +} from "@/roundtable-planning/lib/seat-filter"; import { buildChainSeatSelection, type SeatSelectionResult, @@ -215,6 +220,8 @@ export default function RoundtablePlanningPage() { const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true); const [step2Runs, setStep2Runs] = useState([]); const [activeStep2RunId, setActiveStep2RunId] = useState(null); + // 第二步「按席位筛选会商记录」—— 左侧角色卡片与中间消息头像共用的筛选标识。 + const [seatFilter, setSeatFilter] = useState(null); // Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入; // 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。 @@ -1153,6 +1160,8 @@ export default function RoundtablePlanningPage() { step2Runs={step2Runs} activeStep2RunId={activeStep2RunId} onSelectStep2Run={handleSelectStep2Run} + seatFilter={seatFilter} + setSeatFilter={setSeatFilter} /> {/* MIDDLE VIEWPORT */} @@ -1216,6 +1225,8 @@ export default function RoundtablePlanningPage() { agentConfigs={step2.agentConfigs} selectedAgentId={step2.selectedAgentId} roles={roles} + seatFilter={seatFilter} + setSeatFilter={setSeatFilter} parallelBatch={step2.parallelBatch} selectedParallelSeats={step2.selectedParallelSeats} isParallelRunning={step2.isParallelRunning} @@ -1623,6 +1634,9 @@ interface LeftSidebarProps { step2Runs: DraftStep2RunSnapshot[]; activeStep2RunId: string | null; onSelectStep2Run: (runId: string) => void; + /** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */ + seatFilter: SeatFilter | null; + setSeatFilter: React.Dispatch>; } function Step2SandboxSidebarSync({ @@ -1662,8 +1676,13 @@ function LeftSidebar({ step2Runs, activeStep2RunId, onSelectStep2Run, + seatFilter, + setSeatFilter, }: LeftSidebarProps) { const isStep2 = currentStep === 2; + // 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。 + const toggleSeatFilter = (filter: SeatFilter) => + setSeatFilter((cur) => (cur === filter ? null : filter)); // Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应 // 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。 @@ -1708,13 +1727,25 @@ function LeftSidebar({

参与角色

- {roles.map((role) => ( - - ))} + {roles.map((role) => { + const filter = seatFilterForRole(role); + return ( + toggleSeatFilter(filter)} + /> + ); + })}
- + toggleSeatFilter(USER_SEAT_FILTER)} + />
- {roles.map((role) => ( - - ))} + {roles.map((role) => { + const filter = seatFilterForRole(role); + return ( + toggleSeatFilter(filter)} + /> + ); + })}
- + @@ -1897,14 +1949,50 @@ function TaskOverviewCard({ currentStep }: { currentStep: number }) { ); } -function HumanParticipationCard() { +function HumanParticipationCard({ + active = false, + onSelect, +}: { + /** 当前筛选是否命中「用户」(命中则高亮)。 */ + active?: boolean; + /** 传入则可点击,用于筛选出用户的插话 / 澄清回复。 */ + onSelect?: () => void; +} = {}) { + const clickable = Boolean(onSelect); return (

人工参与

-
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect?.(); + } + } + : undefined + } + className={cn( + "flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition", + clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5", + active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40", + )} + >
用户 @@ -2318,6 +2406,8 @@ function RoleAvatarBadge({ agentNameToRole, size = "h-7 w-7", showSpeakingAnimation = false, + active = false, + onSelect, }: { role: RoleEntry; agentNameToRole: Record< @@ -2326,16 +2416,21 @@ function RoleAvatarBadge({ >; size?: string; showSpeakingAnimation?: boolean; + /** 当前筛选是否命中本席位(命中则头像高亮)。 */ + active?: boolean; + /** 传入则头像可点击,用于「按席位筛选会商记录」(收起态)。 */ + onSelect?: () => void; }) { const isOngoing = role.status === "研讨中"; const animate = showSpeakingAnimation && isOngoing; + const clickable = Boolean(onSelect); - return ( + const inner = (
{animate && ( @@ -2346,33 +2441,80 @@ function RoleAvatarBadge({
); + + if (!clickable) { + return
{inner}
; + } + return ( + + ); } function RoleRow({ role, agentNameToRole, + active = false, + onSelect, }: { role: RoleEntry; agentNameToRole: Record< string, { speakerId: number; avatarType: string; displayName: string } >; + /** 当前筛选是否命中本席位(命中则整卡高亮)。 */ + active?: boolean; + /** 传入则整卡可点击,用于「按席位筛选会商记录」。 */ + onSelect?: () => void; }) { const isActive = role.status === "在线" || role.status === "研讨中" || role.status === "已完成"; const isOngoing = role.status === "研讨中"; const isBootingUp = role.status === "正在上线"; + const clickable = Boolean(onSelect); return (
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect?.(); + } + } + : undefined + } + className={cn( + "flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition", isActive ? "bg-slate-50 border border-slate-100" : isBootingUp ? "bg-amber-50/60 border border-amber-100" - : "opacity-60" - }`} + : "opacity-60", + clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5", + active && + "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40 !opacity-100", + )} >