Files
deerflow-code/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx
T
2026-09-07 17:30:53 +08:00

615 lines
22 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useSearchParams } from "react-router-dom";
import {
ChevronDown,
ChevronLeft,
Loader2,
Settings2,
UserRoundCog,
} from "lucide-react";
import { toast } from "sonner";
import {
AnalysisResultCardList,
AnalysisResultDetailDialog,
type AnalysisResultCardItem,
} from "@/components/analysis-results";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { useAgent, useAgents, type Agent } from "@/core/agents";
import backArrowIcon from "@/assets/eightbf-roundtable/back-arrow.svg";
import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg";
import {
parentReturnRoute,
requestEightBfParentReturn,
} from "@/eightbf-roundtable/lib/parent-return";
import { useEightBfAnalysisResults } from "@/eightbf-roundtable/lib/collect-md-artifacts";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import {
positionNameById,
syncPositionOptionsFromRoles,
} from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { usePositionRoles } from "@/strategy-components/api/position-roles";
import { cn } from "@/lib/utils";
type IntentRecord = Record<string, unknown>;
export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
step2Dialogues?: unknown[];
step2ThreadIds?: ThreadIdMap | null;
step2CoordinatorName?: string | null;
step2SeatStubMessages?: Record<string, unknown[]>;
step3Summary?: DraftStep3SummarySnapshot | null;
}
export function EightBfRoundtableBackButton() {
const [searchParams] = useSearchParams();
const handleBack = () => {
const route = parentReturnRoute(
searchParams.get("parentReturnTo")
?? searchParams.get("parentRoute")
?? searchParams.get("returnTo")
?? "/list",
);
if (requestEightBfParentReturn(route)) return;
toast.warning("尚未配置返回地址,请在 runtime-config.js 填写 VITE_8BF_ROUNDTABLE_BACK_URL");
};
return (
<button
type="button"
className="eightbf-roundtable__back-button"
onClick={handleBack}
>
<img src={backArrowIcon} alt="" />
返回
</button>
);
}
export function EightBfStep1Panel({
chain,
canConfigureChain,
onConfigureChain,
selectedModel,
modelOptions,
onSelectedModelChange,
intentReady,
step2Dialogues = [],
step2ThreadIds = null,
step2CoordinatorName = null,
step2SeatStubMessages,
step3Summary = null,
...step1Props
}: EightBfStep1PanelProps) {
const [rightPanelOpen, setRightPanelOpen] = useState(true);
const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
// 第一步内置任务理解智能体:其「常用问题」显示在输入框左上角,点击直接发送。
const { agent: intentAgent } = useAgent(EIGHTBF_INTENT_AGENT_ID);
const openPanel = (tab: "agents" | "results") => {
if (rightPanelOpen && rightTab === tab) {
setRightPanelOpen(false);
return;
}
setRightTab(tab);
setRightPanelOpen(true);
};
return (
<div className="position-roundtable-intent-page eightbf-roundtable-intent-page contents">
<Step1Panel
{...step1Props}
rootClassName="position-roundtable-intent-panel eightbf-roundtable-intent-panel [&_#save-draft-btn]:hidden [&_#skip-question-btn]:hidden [&_#suggest-followup-btn]:hidden"
headerLeftSlot={
<div className="position-roundtable-intent-header-title">
<span aria-hidden="true" />
<h2>{chain?.title || "8BF 业务链"} · 意图识别</h2>
</div>
}
headerRightSlot={
<div className="position-roundtable-intent-header-actions">
<button
type="button"
className={cn(rightPanelOpen && rightTab === "agents" && "is-selected")}
onClick={() => openPanel("agents")}
>
智能体配置
</button>
<button
type="button"
className={cn(rightPanelOpen && rightTab === "results" && "is-selected")}
onClick={() => openPanel("results")}
>
分析结果
</button>
<EightBfRoundtableBackButton />
</div>
}
rightSidebar={
<aside
aria-hidden={!rightPanelOpen || undefined}
className={cn(
"position-roundtable-intent-config-pane eightbf-roundtable-intent-config-pane",
rightPanelOpen ? "is-open" : "is-closed",
)}
>
<div className="position-roundtable-intent-config-pane-inner">
<EightBfAgentWorkspace
tab={rightTab}
chain={chain}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds}
step2CoordinatorName={step2CoordinatorName}
step2SeatStubMessages={step2SeatStubMessages}
step3Summary={step3Summary}
/>
</div>
</aside>
}
hideQuestionCount
inputArea={(inputProps) => (
<PositionIntentInput
{...inputProps}
modelOptions={modelOptions}
selectedModel={selectedModel}
onSelectedModelChange={onSelectedModelChange}
commonQuestions={intentAgent?.common_questions ?? null}
sendCommonQuestionOnClick
/>
)}
intentReady={intentReady}
/>
</div>
);
}
export function EightBfStep1Sidebar({
taskCard,
businessChain,
intentReady,
onCollapse,
}: {
taskCard?: ReactNode;
businessChain?: ReactNode;
intentReady: object | null;
onCollapse?: () => void;
}) {
return (
<div className="eightbf-roundtable__step1-sidebar position-roundtable-entry-sidebar">
{taskCard}
{onCollapse && !taskCard && (
<button
type="button"
className="position-roundtable-intent-config-collapse-btn ml-auto"
onClick={onCollapse}
aria-label="收起左侧面板"
title="收起左侧面板"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
)}
<EightBfIntentDelivery intentReady={intentReady} businessChain={businessChain} />
</div>
);
}
/** 第一、二步共用的意图结果面板,保持完全相同的展示结构和样式。 */
export function EightBfIntentDelivery({
intentReady,
businessChain,
}: {
intentReady: object | null;
businessChain?: ReactNode;
}) {
const sections = intentSections(intentReady);
return (
<section className="eightbf-roundtable__intent-delivery-sidebar position-roundtable-intent-delivery-sidebar" aria-labelledby="eightbf-intent-sidebar-title">
{sections.length > 0 && (
<div className="position-roundtable-intent-delivery-result">
<header className="position-roundtable-intent-delivery-header">
<span aria-hidden="true" />
<h2 id="eightbf-intent-sidebar-title">意图理解结果</h2>
</header>
<div className="position-roundtable-intent-delivery-sections">
{sections.map((section) => (
<article
key={section.label}
className={cn(
"position-roundtable-intent-delivery-section",
intentSectionTone(section.label),
)}
>
<h3>{section.label}</h3>
<ul>
{section.items.map((item) => <li key={item}>{item}</li>)}
</ul>
</article>
))}
</div>
</div>
)}
{businessChain}
</section>
);
}
/** 第二步不需要第一步的整列占位,只复用其任务卡和意图面板。 */
export function EightBfStep2SidebarHeader({
taskCard,
intentReady,
}: {
taskCard?: ReactNode;
intentReady: object | null;
}) {
return (
<div className="eightbf-roundtable__step2-sidebar-header">
{taskCard}
<EightBfIntentDelivery intentReady={intentReady} />
</div>
);
}
export function EightBfHeaderBrand({ chainTitle }: { chainTitle?: string }) {
return (
<div className="eightbf-roundtable__header-brand">
<span className="eightbf-roundtable__header-brand-icon" aria-hidden="true">
<UserRoundCog />
</span>
<div className="min-w-0">
<h1>8BF 多智能体会商</h1>
<p>按业务链承接任务、对话与分析产物</p>
</div>
<span className="eightbf-roundtable__header-status">
<span aria-hidden="true">⌁</span>
{chainTitle ? "业务链已启动" : "等待业务链配置"}
</span>
</div>
);
}
export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) {
return (
<span className="eightbf-roundtable__header-chain" title={chainTitle || "尚未配置业务链"}>
<span>当前业务链</span>
<strong>{chainTitle || "暂未配置"}</strong>
<ChevronDown aria-hidden="true" />
</span>
);
}
function EightBfAgentWorkspace({
tab,
chain,
canConfigureChain,
onConfigureChain,
onCollapse,
step2Dialogues,
step2ThreadIds,
step2CoordinatorName,
step2SeatStubMessages,
step3Summary,
}: {
tab: "agents" | "results";
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
onCollapse: () => void;
step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null;
step2CoordinatorName: string | null;
step2SeatStubMessages?: Record<string, unknown[]>;
step3Summary: DraftStep3SummarySnapshot | null;
}) {
const { agents, isLoading } = useAgents();
const { data: positionRoles = [] } = usePositionRoles();
useEffect(() => {
syncPositionOptionsFromRoles(positionRoles);
}, [positionRoles]);
const seats = chain?.seats ?? [];
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentKeyword, setAgentKeyword] = useState("");
const { agent: selectedAgent, isLoading: selectedAgentLoading } = useAgent(selectedAgentId);
const [detailItem, setDetailItem] = useState<AnalysisResultCardItem | null>(null);
const analysisItems = useEightBfAnalysisResults({
chain,
dialogues: step2Dialogues,
threadIds: step2ThreadIds,
coordinatorName: step2CoordinatorName,
seatStubMessages: step2SeatStubMessages,
summaryReport: step3Summary,
});
// 第一步绑定的内置任务理解智能体:不在业务链席位里,固定置顶展示,
// 供管理员在本面板查看 / 配置其提示词与常用问题。
const intentAgent = useMemo(
() => agents.find((agent) => agent.id === EIGHTBF_INTENT_AGENT_ID) ?? null,
[agents],
);
const intentAgentMatchesKeyword = useMemo(() => {
const keyword = agentKeyword.trim().toLowerCase();
if (!keyword) return true;
const name = (intentAgent?.name || "8BF 任务理解").toLowerCase();
const description = (intentAgent?.description || "").toLowerCase();
return name.includes(keyword) || description.includes(keyword);
}, [agentKeyword, intentAgent]);
useEffect(() => {
if (
selectedAgentId &&
selectedAgentId !== EIGHTBF_INTENT_AGENT_ID &&
!seats.some((seat) => seat.agent_id === selectedAgentId)
) {
setSelectedAgentId(null);
}
}, [seats, selectedAgentId]);
const seatAgents = useMemo(() => {
const agentById = new Map(agents.map((agent) => [agent.id, agent]));
return seats.map((seat) => ({ seat, agent: agentById.get(seat.agent_id) ?? null }));
}, [agents, seats]);
const filteredSeatAgents = useMemo(() => {
const keyword = agentKeyword.trim().toLowerCase();
if (!keyword) return seatAgents;
return seatAgents.filter(({ seat, agent }) => {
const name = (agent?.name || seat.name || "").toLowerCase();
const description = (agent?.description || seat.description || "").toLowerCase();
return name.includes(keyword) || description.includes(keyword);
});
}, [agentKeyword, seatAgents]);
const selectedSeat = seats.find((seat) => seat.agent_id === selectedAgentId) ?? null;
if (tab === "results") {
return (
<section className="position-roundtable-intent-analysis-results eightbf-roundtable-intent-analysis-results">
<div className="position-roundtable-intent-analysis-header">
<span>分析成果列表</span>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起分析结果"
title="收起分析结果"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
<div className="position-roundtable-intent-analysis-results-scroll eightbf-roundtable-intent-results-scroll">
<AnalysisResultCardList
items={analysisItems}
emptyTitle="暂无交付产物"
emptyDescription="完成第二步研讨与第三步方案总结并生成 Markdown 后,产物会显示在这里。"
padded={false}
onOpen={setDetailItem}
/>
</div>
<AnalysisResultDetailDialog
item={detailItem}
onOpenChange={(open) => {
if (!open) setDetailItem(null);
}}
/>
</section>
);
}
if (selectedAgentId) {
return (
<div className="position-roundtable-intent-config-workspace">
<div className="eightbf-roundtable__agent-detail-topbar">
<button type="button" onClick={() => setSelectedAgentId(null)}>
<ChevronLeft className="size-4" />
智能体列表
</button>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起智能体配置"
title="收起智能体配置"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
<div className="position-roundtable-agent-detail position-roundtable-agent-detail--special h-[calc(100%-48px)] min-h-0">
{selectedAgentLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#aaceff]">
<Loader2 className="size-4 animate-spin" /> 正在读取智能体配置…
</div>
) : (
<AgentDetailPanel
agent={selectedAgent}
agentName={
selectedAgent?.name
?? (selectedAgentId === EIGHTBF_INTENT_AGENT_ID
? "8BF 任务理解"
: selectedSeat?.name ?? "")
}
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
showRecentChats={false}
avatarSrc={agentConfigLogo}
positionName={positionNameById(selectedSeat?.position_id)}
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
showCommonQuestions
panelTitle="智能体配置"
embedded
hideStatusBadges
/>
)}
</div>
</div>
);
}
return (
<section className="eightbf-roundtable__agent-list-workspace">
<div className="position-roundtable-intent-analysis-header">
<span>智能体配置</span>
<div className="flex items-center gap-1.5">
{canConfigureChain && (
<button type="button" className="eightbf-roundtable__configure-chain-button" onClick={onConfigureChain}>
<Settings2 className="size-3.5" /> 配置业务链
</button>
)}
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起智能体配置"
title="收起智能体配置"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
</div>
<div className="eightbf-roundtable__agent-selector">
<header className="eightbf-roundtable__agent-selector-head">
<div className="eightbf-roundtable__agent-selector-title">
<b>当前岗位智能体</b>
<i>{seatAgents.length}</i>
</div>
<input
type="text"
value={agentKeyword}
onChange={(event) => setAgentKeyword(event.target.value)}
placeholder="搜索名称或说明"
className="eightbf-roundtable__agent-selector-search"
/>
</header>
{isLoading && seats.length > 0 ? (
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
) : filteredSeatAgents.length === 0 && !intentAgentMatchesKeyword ? (
<div className="eightbf-roundtable__agent-selector-empty">
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
{!seatAgents.length && canConfigureChain && (
<button type="button" onClick={onConfigureChain}>前往业务链配置</button>
)}
</div>
) : (
<div className="eightbf-roundtable__agent-selector-list">
{intentAgentMatchesKeyword && (
<EightBfAgentCard
name={intentAgent?.name || "8BF 任务理解"}
description={intentAgent?.description || "第一步「意图识别」的内置任务理解智能体"}
onSelect={() => setSelectedAgentId(EIGHTBF_INTENT_AGENT_ID)}
/>
)}
{filteredSeatAgents.map(({ seat, agent }) => (
<EightBfAgentRow
key={seat.agent_id}
seat={seat}
agent={agent}
onSelect={() => setSelectedAgentId(seat.agent_id)}
/>
))}
</div>
)}
</div>
</section>
);
}
function EightBfAgentRow({
seat,
agent,
onSelect,
}: {
seat: ChainSeat;
agent: Agent | null;
onSelect: () => void;
}) {
const name = agent?.name || seat.name || "未命名智能体";
const description = agent?.description || seat.description || "智能体助手";
return <EightBfAgentCard name={name} description={description} onSelect={onSelect} />;
}
/** 智能体列表卡片:席位智能体与置顶的内置任务理解智能体共用同一展示结构。 */
function EightBfAgentCard({
name,
description,
onSelect,
}: {
name: string;
description: string;
onSelect: () => void;
}) {
const avatarText = firstAgentAvatarChar(name);
return (
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
<span className="eightbf-roundtable__agent-selector-avatar" aria-hidden="true">
{avatarText ? avatarText : <img src={agentConfigLogo} alt="" />}
</span>
<span className="eightbf-roundtable__agent-selector-main">
<strong>{name}</strong>
<p>{description}</p>
</span>
<i className="eightbf-roundtable__agent-selector-bar" aria-hidden="true" />
</button>
);
}
/** 名称首字若是【、( 这类标点,列表卡片改用智能体 logo,不把标点当头像。 */
function firstAgentAvatarChar(name: string): string | null {
const first = Array.from(name.trim())[0];
if (!first || /^[\p{P}\p{S}]$/u.test(first)) return null;
return first.toUpperCase();
}
function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> {
const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null;
if (!source) return [];
const entries = [
["核心目标", arrayValue(source.coreGoals, source.objective)],
["风险提示", arrayValue(source.riskWarnings, source.constraints)],
["关键要点", arrayValue(source.keyPoints, source.assumptions)],
["战略意义", arrayValue(source.strategicSignificance)],
] as const;
return entries
.filter(([, items]) => items.length > 0)
.map(([label, items]) => ({ label, items }));
}
function intentSectionTone(label: string) {
switch (label) {
case "核心目标":
return "position-roundtable-intent-delivery-section--goals";
case "风险提示":
return "position-roundtable-intent-delivery-section--constraints";
case "关键要点":
return "position-roundtable-intent-delivery-section--purpose";
case "战略意义":
return "position-roundtable-intent-delivery-section--significance";
default:
return "position-roundtable-intent-delivery-section--significance";
}
}
function arrayValue(...values: unknown[]): string[] {
for (const value of values) {
if (Array.isArray(value)) {
const items = value.map((item) => String(item).trim()).filter(Boolean);
if (items.length > 0) return items;
}
if (typeof value === "string" && value.trim()) return [value.trim()];
}
return [];
}