圆桌第二步支持按席位筛选会商记录,左侧角色卡与消息头像联动。
新增 seat-filter 归一化匹配逻辑,点击角色卡或气泡头像可只看该席位发言,并支持一键显示全部。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
9002ae157a
commit
dd329e7a4f
3 files changed
+350
-36
No files matched your search
@@ -68,6 +68,12 @@ import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeat
|
|||||||
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
||||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||||
|
import {
|
||||||
|
seatFilterForSender,
|
||||||
|
seatFilterLabel,
|
||||||
|
senderMatchesSeatFilter,
|
||||||
|
type SeatFilter,
|
||||||
|
} from "@/roundtable-planning/lib/seat-filter";
|
||||||
import type {
|
import type {
|
||||||
AgentConfig,
|
AgentConfig,
|
||||||
ParallelSeatView,
|
ParallelSeatView,
|
||||||
@@ -100,6 +106,10 @@ export interface Step2PanelProps {
|
|||||||
agentConfigs: Record<number, AgentConfig>;
|
agentConfigs: Record<number, AgentConfig>;
|
||||||
selectedAgentId: number | null;
|
selectedAgentId: number | null;
|
||||||
roles: RoleEntry[];
|
roles: RoleEntry[];
|
||||||
|
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
|
||||||
|
/** 当前筛选标识;null = 显示全部。 */
|
||||||
|
seatFilter: SeatFilter | null;
|
||||||
|
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
|
||||||
// ─── 并行显示(§5)────────────────────────────────────────────
|
// ─── 并行显示(§5)────────────────────────────────────────────
|
||||||
parallelBatch: ParallelSeatView[];
|
parallelBatch: ParallelSeatView[];
|
||||||
selectedParallelSeats: string[];
|
selectedParallelSeats: string[];
|
||||||
@@ -161,6 +171,8 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
agentConfigs,
|
agentConfigs,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
roles,
|
roles,
|
||||||
|
seatFilter,
|
||||||
|
setSeatFilter,
|
||||||
parallelBatch,
|
parallelBatch,
|
||||||
selectedParallelSeats,
|
selectedParallelSeats,
|
||||||
isParallelRunning,
|
isParallelRunning,
|
||||||
@@ -201,6 +213,36 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
|
||||||
const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues);
|
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 误判成「用户上滚」而停跟随
|
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
|
||||||
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
|
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
|
||||||
@@ -485,20 +527,55 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
step2RoundtableDialogues.map((msg) => (
|
<>
|
||||||
<Step2Message
|
{/* 席位筛选提示条:点卡片 / 头像只看某席位发言时出现,可一键显示全部。 */}
|
||||||
key={msg.id}
|
{seatFilter && (
|
||||||
msg={msg}
|
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between gap-2 rounded-lg border border-primary/20 bg-[var(--brand-200)]/20 px-3 py-1.5 backdrop-blur-sm">
|
||||||
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
<span className="roundtable-chat-meta font-medium text-slate-600">
|
||||||
resolveAgentName={resolveAgentName}
|
仅显示{" "}
|
||||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
<span className="font-bold text-primary">
|
||||||
onFileCardClick={(path) => {
|
{seatFilterLabel(seatFilter)}
|
||||||
if (msg.agentThreadId) {
|
</span>{" "}
|
||||||
openArtifactPreview(msg.agentThreadId, path);
|
的发言
|
||||||
}
|
</span>
|
||||||
}}
|
<button
|
||||||
/>
|
type="button"
|
||||||
))
|
onClick={() => setSeatFilter(null)}
|
||||||
|
className="roundtable-chat-meta inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 font-semibold text-primary transition hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
显示全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{seatFilter && visibleDialogues.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-xs text-slate-400">
|
||||||
|
没有匹配「{seatFilterLabel(seatFilter)}」的消息
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
visibleDialogues.map((msg) => (
|
||||||
|
<Step2Message
|
||||||
|
key={msg.id}
|
||||||
|
msg={msg}
|
||||||
|
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
||||||
|
isAvatarSelected={
|
||||||
|
seatFilter !== null &&
|
||||||
|
senderMatchesSeatFilter(msg.sender, seatFilter)
|
||||||
|
}
|
||||||
|
onAvatarClick={() =>
|
||||||
|
toggleSeatFilter(seatFilterForSender(msg.sender))
|
||||||
|
}
|
||||||
|
resolveAgentName={resolveAgentName}
|
||||||
|
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||||
|
onFileCardClick={(path) => {
|
||||||
|
if (msg.agentThreadId) {
|
||||||
|
openArtifactPreview(msg.agentThreadId, path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
||||||
@@ -706,6 +783,13 @@ export interface Step2MessageProps {
|
|||||||
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
|
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
|
||||||
*/
|
*/
|
||||||
metric?: MessageMetric;
|
metric?: MessageMetric;
|
||||||
|
/**
|
||||||
|
* 点击头像的回调 —— 传入则头像变为可点的「按席位筛选」开关。不传(并行两列等
|
||||||
|
* 场景)则头像为纯展示,保持原样。
|
||||||
|
*/
|
||||||
|
onAvatarClick?: () => void;
|
||||||
|
/** 当前筛选是否正命中本气泡的席位(命中则头像高亮)。 */
|
||||||
|
isAvatarSelected?: boolean;
|
||||||
/** 把派活 chip 的 agent id 解析成中文席位名。 */
|
/** 把派活 chip 的 agent id 解析成中文席位名。 */
|
||||||
resolveAgentName?: (idOrName: string) => string;
|
resolveAgentName?: (idOrName: string) => string;
|
||||||
onArtifactStepClick?: (
|
onArtifactStepClick?: (
|
||||||
@@ -718,6 +802,8 @@ export interface Step2MessageProps {
|
|||||||
export function Step2Message({
|
export function Step2Message({
|
||||||
msg,
|
msg,
|
||||||
metric,
|
metric,
|
||||||
|
onAvatarClick,
|
||||||
|
isAvatarSelected = false,
|
||||||
resolveAgentName,
|
resolveAgentName,
|
||||||
onArtifactStepClick,
|
onArtifactStepClick,
|
||||||
onFileCardClick,
|
onFileCardClick,
|
||||||
@@ -732,9 +818,32 @@ export function Step2Message({
|
|||||||
const hasContent = content.length > 0;
|
const hasContent = content.length > 0;
|
||||||
const presentedFiles = collectPresentedFiles(msg.steps);
|
const presentedFiles = collectPresentedFiles(msg.steps);
|
||||||
|
|
||||||
|
const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0");
|
||||||
|
// 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。
|
||||||
|
const avatar = onAvatarClick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAvatarClick}
|
||||||
|
aria-pressed={isAvatarSelected}
|
||||||
|
title={
|
||||||
|
isAvatarSelected
|
||||||
|
? "取消筛选,显示全部发言"
|
||||||
|
: `只看 ${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",
|
||||||
|
isAvatarSelected && "ring-2 ring-primary ring-offset-1 ring-offset-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{avatarInner}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
avatarInner
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
avatar={getAvatar(msg.avatarType, "h-9 w-9 shrink-0")}
|
avatar={avatar}
|
||||||
senderName={msg.sender}
|
senderName={msg.sender}
|
||||||
badge={msg.confidence}
|
badge={msg.confidence}
|
||||||
time={msg.time}
|
time={msg.time}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 「按席位筛选会商记录」的匹配逻辑 —— 第二步左侧「参与角色 / 人工参与」卡片
|
||||||
|
* 与消息气泡头像共用同一套标识。
|
||||||
|
*
|
||||||
|
* 设计要点:把**任意一条气泡的 sender** 和**任意一张卡片**都归一化成同一个
|
||||||
|
* `SeatFilter` 字符串标识,于是「某气泡命中当前筛选」就等价于「两边标识相等」,
|
||||||
|
* 卡片点击与头像点击不再各写一套匹配规则。
|
||||||
|
*
|
||||||
|
* 三类标识:
|
||||||
|
* - `"coordinator"` —— 总控协调 / 系统 调度播报(对应「指挥员 / 主持人」卡片)
|
||||||
|
* - `"user"` —— 用户插话 / 澄清回复(对应「人工参与 · 用户」卡片)
|
||||||
|
* - `"agent:<显示名>"` —— 某 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;
|
||||||
|
}
|
||||||
@@ -86,6 +86,11 @@ import {
|
|||||||
type RoleEntry,
|
type RoleEntry,
|
||||||
type SelectedAgent,
|
type SelectedAgent,
|
||||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||||
|
import {
|
||||||
|
seatFilterForRole,
|
||||||
|
USER_SEAT_FILTER,
|
||||||
|
type SeatFilter,
|
||||||
|
} from "@/roundtable-planning/lib/seat-filter";
|
||||||
import {
|
import {
|
||||||
buildChainSeatSelection,
|
buildChainSeatSelection,
|
||||||
type SeatSelectionResult,
|
type SeatSelectionResult,
|
||||||
@@ -215,6 +220,8 @@ export default function RoundtablePlanningPage() {
|
|||||||
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
|
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
|
||||||
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
|
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
|
||||||
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
|
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
|
||||||
|
// 第二步「按席位筛选会商记录」—— 左侧角色卡片与中间消息头像共用的筛选标识。
|
||||||
|
const [seatFilter, setSeatFilter] = useState<SeatFilter | null>(null);
|
||||||
|
|
||||||
// Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入;
|
// Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入;
|
||||||
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
|
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
|
||||||
@@ -1153,6 +1160,8 @@ export default function RoundtablePlanningPage() {
|
|||||||
step2Runs={step2Runs}
|
step2Runs={step2Runs}
|
||||||
activeStep2RunId={activeStep2RunId}
|
activeStep2RunId={activeStep2RunId}
|
||||||
onSelectStep2Run={handleSelectStep2Run}
|
onSelectStep2Run={handleSelectStep2Run}
|
||||||
|
seatFilter={seatFilter}
|
||||||
|
setSeatFilter={setSeatFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MIDDLE VIEWPORT */}
|
{/* MIDDLE VIEWPORT */}
|
||||||
@@ -1216,6 +1225,8 @@ export default function RoundtablePlanningPage() {
|
|||||||
agentConfigs={step2.agentConfigs}
|
agentConfigs={step2.agentConfigs}
|
||||||
selectedAgentId={step2.selectedAgentId}
|
selectedAgentId={step2.selectedAgentId}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
|
seatFilter={seatFilter}
|
||||||
|
setSeatFilter={setSeatFilter}
|
||||||
parallelBatch={step2.parallelBatch}
|
parallelBatch={step2.parallelBatch}
|
||||||
selectedParallelSeats={step2.selectedParallelSeats}
|
selectedParallelSeats={step2.selectedParallelSeats}
|
||||||
isParallelRunning={step2.isParallelRunning}
|
isParallelRunning={step2.isParallelRunning}
|
||||||
@@ -1623,6 +1634,9 @@ interface LeftSidebarProps {
|
|||||||
step2Runs: DraftStep2RunSnapshot[];
|
step2Runs: DraftStep2RunSnapshot[];
|
||||||
activeStep2RunId: string | null;
|
activeStep2RunId: string | null;
|
||||||
onSelectStep2Run: (runId: string) => void;
|
onSelectStep2Run: (runId: string) => void;
|
||||||
|
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
|
||||||
|
seatFilter: SeatFilter | null;
|
||||||
|
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step2SandboxSidebarSync({
|
function Step2SandboxSidebarSync({
|
||||||
@@ -1662,8 +1676,13 @@ function LeftSidebar({
|
|||||||
step2Runs,
|
step2Runs,
|
||||||
activeStep2RunId,
|
activeStep2RunId,
|
||||||
onSelectStep2Run,
|
onSelectStep2Run,
|
||||||
|
seatFilter,
|
||||||
|
setSeatFilter,
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
const isStep2 = currentStep === 2;
|
const isStep2 = currentStep === 2;
|
||||||
|
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
|
||||||
|
const toggleSeatFilter = (filter: SeatFilter) =>
|
||||||
|
setSeatFilter((cur) => (cur === filter ? null : filter));
|
||||||
|
|
||||||
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
|
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
|
||||||
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
|
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
|
||||||
@@ -1708,13 +1727,25 @@ function LeftSidebar({
|
|||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
|
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
|
||||||
{roles.map((role) => (
|
{roles.map((role) => {
|
||||||
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} />
|
const filter = seatFilterForRole(role);
|
||||||
))}
|
return (
|
||||||
|
<RoleRow
|
||||||
|
key={role.id}
|
||||||
|
role={role}
|
||||||
|
agentNameToRole={agentNameToRole}
|
||||||
|
active={seatFilter === filter}
|
||||||
|
onSelect={() => toggleSeatFilter(filter)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HumanParticipationCard />
|
<HumanParticipationCard
|
||||||
|
active={seatFilter === USER_SEAT_FILTER}
|
||||||
|
onSelect={() => toggleSeatFilter(USER_SEAT_FILTER)}
|
||||||
|
/>
|
||||||
<Step2RunSwitcher
|
<Step2RunSwitcher
|
||||||
runs={step2Runs}
|
runs={step2Runs}
|
||||||
activeRunId={activeStep2RunId}
|
activeRunId={activeStep2RunId}
|
||||||
@@ -1730,20 +1761,41 @@ function LeftSidebar({
|
|||||||
|
|
||||||
<CollapsedSidebarSection title="参与角色">
|
<CollapsedSidebarSection title="参与角色">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
{roles.map((role) => (
|
{roles.map((role) => {
|
||||||
<RoleAvatarBadge
|
const filter = seatFilterForRole(role);
|
||||||
key={role.id}
|
return (
|
||||||
role={role}
|
<RoleAvatarBadge
|
||||||
agentNameToRole={agentNameToRole}
|
key={role.id}
|
||||||
size="h-8 w-8"
|
role={role}
|
||||||
showSpeakingAnimation
|
agentNameToRole={agentNameToRole}
|
||||||
/>
|
size="h-8 w-8"
|
||||||
))}
|
showSpeakingAnimation
|
||||||
|
active={seatFilter === filter}
|
||||||
|
onSelect={() => toggleSeatFilter(filter)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CollapsedSidebarSection>
|
</CollapsedSidebarSection>
|
||||||
|
|
||||||
<CollapsedSidebarSection title="人工参与">
|
<CollapsedSidebarSection title="人工参与">
|
||||||
<Man1Avatar size="h-8 w-8" />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSeatFilter(USER_SEAT_FILTER)}
|
||||||
|
aria-pressed={seatFilter === USER_SEAT_FILTER}
|
||||||
|
title={
|
||||||
|
seatFilter === USER_SEAT_FILTER
|
||||||
|
? "取消筛选,显示全部发言"
|
||||||
|
: "只看 用户 的发言"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
|
||||||
|
seatFilter === USER_SEAT_FILTER &&
|
||||||
|
"ring-2 ring-primary ring-offset-2 ring-offset-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Man1Avatar size="h-8 w-8" />
|
||||||
|
</button>
|
||||||
</CollapsedSidebarSection>
|
</CollapsedSidebarSection>
|
||||||
|
|
||||||
<CollapsedSidebarSection title="任务概览">
|
<CollapsedSidebarSection title="任务概览">
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3">
|
<div
|
||||||
|
role={clickable ? "button" : undefined}
|
||||||
|
tabIndex={clickable ? 0 : undefined}
|
||||||
|
aria-pressed={clickable ? active : undefined}
|
||||||
|
title={
|
||||||
|
clickable
|
||||||
|
? active
|
||||||
|
? "取消筛选,显示全部发言"
|
||||||
|
: "只看 用户 的发言"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={onSelect}
|
||||||
|
onKeyDown={
|
||||||
|
clickable
|
||||||
|
? (e) => {
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Man1Avatar size="h-10 w-10" />
|
<Man1Avatar size="h-10 w-10" />
|
||||||
<span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span>
|
<span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span>
|
||||||
@@ -2318,6 +2406,8 @@ function RoleAvatarBadge({
|
|||||||
agentNameToRole,
|
agentNameToRole,
|
||||||
size = "h-7 w-7",
|
size = "h-7 w-7",
|
||||||
showSpeakingAnimation = false,
|
showSpeakingAnimation = false,
|
||||||
|
active = false,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
role: RoleEntry;
|
role: RoleEntry;
|
||||||
agentNameToRole: Record<
|
agentNameToRole: Record<
|
||||||
@@ -2326,16 +2416,21 @@ function RoleAvatarBadge({
|
|||||||
>;
|
>;
|
||||||
size?: string;
|
size?: string;
|
||||||
showSpeakingAnimation?: boolean;
|
showSpeakingAnimation?: boolean;
|
||||||
|
/** 当前筛选是否命中本席位(命中则头像高亮)。 */
|
||||||
|
active?: boolean;
|
||||||
|
/** 传入则头像可点击,用于「按席位筛选会商记录」(收起态)。 */
|
||||||
|
onSelect?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isOngoing = role.status === "研讨中";
|
const isOngoing = role.status === "研讨中";
|
||||||
const animate = showSpeakingAnimation && isOngoing;
|
const animate = showSpeakingAnimation && isOngoing;
|
||||||
|
const clickable = Boolean(onSelect);
|
||||||
|
|
||||||
return (
|
const inner = (
|
||||||
<div
|
<div
|
||||||
title={`${role.name} · ${role.status}`}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative shrink-0 rounded-full",
|
"relative shrink-0 rounded-full",
|
||||||
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
|
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
|
||||||
|
active && "ring-2 ring-primary ring-offset-2 ring-offset-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{animate && (
|
{animate && (
|
||||||
@@ -2346,33 +2441,80 @@ function RoleAvatarBadge({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!clickable) {
|
||||||
|
return <div title={`${role.name} · ${role.status}`}>{inner}</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={active ? "取消筛选,显示全部发言" : `只看 ${role.name} 的发言`}
|
||||||
|
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoleRow({
|
function RoleRow({
|
||||||
role,
|
role,
|
||||||
agentNameToRole,
|
agentNameToRole,
|
||||||
|
active = false,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
role: RoleEntry;
|
role: RoleEntry;
|
||||||
agentNameToRole: Record<
|
agentNameToRole: Record<
|
||||||
string,
|
string,
|
||||||
{ speakerId: number; avatarType: string; displayName: string }
|
{ speakerId: number; avatarType: string; displayName: string }
|
||||||
>;
|
>;
|
||||||
|
/** 当前筛选是否命中本席位(命中则整卡高亮)。 */
|
||||||
|
active?: boolean;
|
||||||
|
/** 传入则整卡可点击,用于「按席位筛选会商记录」。 */
|
||||||
|
onSelect?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isActive =
|
const isActive =
|
||||||
role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
|
role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
|
||||||
const isOngoing = role.status === "研讨中";
|
const isOngoing = role.status === "研讨中";
|
||||||
const isBootingUp = role.status === "正在上线";
|
const isBootingUp = role.status === "正在上线";
|
||||||
|
const clickable = Boolean(onSelect);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`role-item-${role.id}`}
|
id={`role-item-${role.id}`}
|
||||||
className={`flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition ${
|
role={clickable ? "button" : undefined}
|
||||||
|
tabIndex={clickable ? 0 : undefined}
|
||||||
|
aria-pressed={clickable ? active : undefined}
|
||||||
|
title={
|
||||||
|
clickable
|
||||||
|
? active
|
||||||
|
? "取消筛选,显示全部发言"
|
||||||
|
: `只看 ${role.name} 的发言`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={onSelect}
|
||||||
|
onKeyDown={
|
||||||
|
clickable
|
||||||
|
? (e) => {
|
||||||
|
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
|
isActive
|
||||||
? "bg-slate-50 border border-slate-100"
|
? "bg-slate-50 border border-slate-100"
|
||||||
: isBootingUp
|
: isBootingUp
|
||||||
? "bg-amber-50/60 border border-amber-100"
|
? "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",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
|
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
|
||||||
|
|||||||
Reference in new issue
Block a user