feat: 完善 8BF 会商第一步产物与智能体列表,第二步对话区对齐第一步样式

抽出共用分析成果卡片,第一步展示第二、三步 Markdown 产物;智能体列表对齐岗位会商选择样式。第二步对话面板改为深蓝顶栏,第一步保留可配置返回按钮。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-19 12:12:35 +08:00
1 parent 30799f17be
commit 5e1765d889
17 files changed
+1254 -230

No files matched your search

+4
View File
@@ -54,6 +54,10 @@ window.__CM_RUNTIME_CONFIG__ = {
// 不影响原多智能体会商使用的任务详情接口。
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
// 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。
// 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。
VITE_8BF_ROUNDTABLE_BACK_URL: "",
// 佳来模型对话接口。
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden="true">
<path d="M13 6H1.5M1.5 6L6.2 1.4M1.5 6L6.2 10.6" stroke="#A2E1F9" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 258 B

@@ -0,0 +1,104 @@
import { FileOutput } from "lucide-react";
import { toast } from "sonner";
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import "./analysis-results.css";
import type { AnalysisResultCardItem } from "./types";
function formatResultTime(value: string | undefined, fallback: string): string {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
export function AnalysisResultCardList({
items,
emptyTitle = "暂无交付产物",
emptyDescription = "第二步研讨与第三步总结生成 Markdown 后,产物会显示在这里。",
padded = true,
onOpen,
}: {
items: AnalysisResultCardItem[];
emptyTitle?: string;
emptyDescription?: string;
padded?: boolean;
onOpen: (item: AnalysisResultCardItem) => void;
}) {
if (items.length === 0) {
return (
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
<p className="text-sm font-semibold">{emptyTitle}</p>
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
{emptyDescription}
</p>
</div>
);
}
return (
<div className={cn("analysis-result-card-list", padded && "p-[15px]")}>
{items.map((item, index) => {
const displayedTime = formatResultTime(
item.updatedAt,
item.fallbackTime || "刚刚",
);
const displayedProductName = item.delivered ? item.productName : "未交付";
return (
<button
key={item.id}
type="button"
className="position-roundtable-analysis-result-card"
onClick={() => {
if (!item.delivered) {
toast.info(
item.unavailableMessage ||
`「${desensitize(item.agentName)}」尚未交付可预览的 Markdown 文件。`,
);
return;
}
onOpen(item);
}}
>
<div className="position-roundtable-analysis-result-card-body">
<div
className={cn(
"position-roundtable-analysis-result-tag",
`position-roundtable-analysis-result-tag--${(index % 6) + 1}`,
)}
aria-label={desensitize(item.agentName)}
>
<span>{desensitize(item.agentName)}</span>
</div>
<p className="position-roundtable-analysis-result-product">
{desensitize(displayedProductName)}
</p>
<footer className="position-roundtable-analysis-result-meta">
<span>
<img src={responsibilityIcon} alt="" aria-hidden="true" />
{desensitize(item.positionName)}
</span>
<i aria-hidden="true" />
<span>
<img src={resultTimeIcon} alt="" aria-hidden="true" />
{displayedTime}
</span>
</footer>
</div>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,141 @@
import { useQuery } from "@tanstack/react-query";
import { FileOutput, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { ArtifactFilePreview } from "@/components/workspace/artifacts";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { loadArtifactContent } from "@/core/artifacts/loader";
import { desensitize } from "@/lib/desensitize";
import { Button as TButton } from "tdesign-react";
import type { AnalysisResultCardItem } from "./types";
function isArtifactErrorContent(content: string | null | undefined): boolean {
const trimmed = content?.trim();
if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return false;
}
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const detail = parsed.detail ?? parsed.message ?? parsed.error;
return typeof detail === "string" && detail.trim().length > 0;
} catch {
return false;
}
}
function fallbackContent(item: AnalysisResultCardItem | null): string {
return (
item?.artifactContent?.trim() ||
item?.summary?.trim() ||
"暂无产物正文"
);
}
export function AnalysisResultDetailDialog({
item,
onOpenChange,
extraBody,
footerStart,
}: {
item: AnalysisResultCardItem | null;
onOpenChange: (open: boolean) => void;
extraBody?: ReactNode;
footerStart?: ReactNode;
}) {
const artifactThreadId = item?.artifactThreadId || null;
const artifactPathValue = item?.artifactPath || "";
const artifactContentQuery = useQuery({
queryKey: [
"analysis-results",
"deliverable-artifact-content",
artifactThreadId,
artifactPathValue,
],
queryFn: async () => {
if (!artifactThreadId || !artifactPathValue) return "";
const result = await loadArtifactContent({
filepath: artifactPathValue,
threadId: artifactThreadId,
isMock: false,
});
return result.content;
},
enabled: Boolean(item && artifactThreadId && artifactPathValue),
staleTime: 30_000,
});
const loadedArtifactContent =
typeof artifactContentQuery.data === "string" ? artifactContentQuery.data : "";
const loadedArtifactIsError = isArtifactErrorContent(loadedArtifactContent);
const artifactContent =
loadedArtifactContent.trim() && !loadedArtifactIsError ? loadedArtifactContent : null;
const displayedContent = artifactContent || fallbackContent(item);
const artifactLoadFailed =
(artifactContentQuery.isError || loadedArtifactIsError) &&
!item?.artifactContent?.trim();
return (
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
<DialogContent
overlayClassName="!z-[6000]"
className="!z-[6001] flex max-h-[86vh] w-[900px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="flex items-center gap-2 text-base">
<FileOutput className="size-4 text-primary" />
交付产物详情
</DialogTitle>
</DialogHeader>
{!item ? null : (
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{desensitize(item.productName)}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{desensitize(item.agentName)} · {desensitize(item.positionName)}
</p>
</div>
{extraBody}
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/65">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">产物内容</p>
</div>
<div className="h-[52vh] min-h-[320px] overflow-hidden rounded-lg border border-slate-100 bg-slate-50/60 dark:border-[#2b8ffe]/20 dark:bg-[#021d3d]/70">
{artifactContentQuery.isLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground dark:text-[#8aa0c4]">
<Loader2 className="size-4 animate-spin" />
正在加载 Markdown 文件内容…
</div>
) : (
<ArtifactFilePreview
content={displayedContent}
language="markdown"
threadId={artifactThreadId ?? ""}
/>
)}
</div>
{artifactLoadFailed && (
<p className="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
Markdown 文件内容加载失败,当前展示的是已保存正文。
</p>
)}
</div>
</div>
)}
<DialogFooter className="shrink-0 border-t px-5 py-3 dark:border-[#2b8ffe]/25">
{footerStart}
<TButton variant="outline" onClick={() => onOpenChange(false)}>
关闭
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,130 @@
.analysis-result-card-list {
display: grid;
gap: 10px;
min-width: 0;
max-width: 100%;
}
.position-roundtable-analysis-result-card {
position: relative;
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 102px;
box-sizing: border-box;
padding: 40px 12px 12px;
border: 1px solid rgba(85, 166, 254, 0.1);
border-radius: 4px;
background: rgba(0, 98, 217, 0.1);
color: #ffffff;
text-align: left;
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.position-roundtable-analysis-result-card:hover,
.position-roundtable-analysis-result-card:focus-visible {
border-color: #0062d9;
background: rgba(0, 98, 217, 0.3);
box-shadow: 0 4px 14px rgba(0, 44, 110, 0.18);
outline: none;
}
.position-roundtable-analysis-result-card-body {
position: relative;
min-width: 0;
max-width: 100%;
}
.position-roundtable-analysis-result-tag {
position: absolute;
top: -40px;
left: -12px;
display: inline-flex;
width: fit-content;
max-width: calc(100% + 12px);
height: 26px;
align-items: center;
box-sizing: border-box;
padding: 0 22px 0 12px;
overflow: hidden;
border-radius: 4px 0 0 0;
background: rgba(212, 120, 0, 0.5);
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
line-height: normal;
white-space: nowrap;
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
}
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
.position-roundtable-analysis-result-tag span {
position: relative;
z-index: 1;
box-sizing: border-box;
min-width: 0;
max-width: 100%;
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
}
.position-roundtable-analysis-result-product {
margin: 0;
overflow: hidden;
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 18px;
font-weight: 500;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-analysis-result-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
margin-top: 6px;
color: #80b7ff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
font-weight: 300;
line-height: 24px;
}
.position-roundtable-analysis-result-meta span {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-analysis-result-meta img {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.position-roundtable-analysis-result-meta i {
width: 1px;
height: 14px;
flex: 0 0 auto;
background: rgba(128, 183, 255, 0.45);
}
.position-roundtable-analysis-results-empty {
color: #80b7ff;
}
@@ -0,0 +1,3 @@
export { AnalysisResultCardList } from "./AnalysisResultCardList";
export { AnalysisResultDetailDialog } from "./AnalysisResultDetailDialog";
export type { AnalysisResultCardItem } from "./types";
@@ -0,0 +1,17 @@
export interface AnalysisResultCardItem {
id: string;
agentName: string;
productName: string;
positionName: string;
updatedAt?: string;
/** Shown when `updatedAt` is missing. */
fallbackTime?: string;
/** Clickable preview is available. */
delivered: boolean;
artifactPath?: string;
artifactMimeType?: string | null;
artifactThreadId?: string | null;
artifactContent?: string | null;
summary?: string;
unavailableMessage?: string;
}
@@ -91,6 +91,11 @@ function externalApiBase(): string {
);
}
/** 8BF 会商页右上角「返回」按钮的跳转地址,来自 runtime-config.js。 */
export function getEightBfRoundtableBackUrl(): string {
return readRuntimeEnv("VITE_8BF_ROUNDTABLE_BACK_URL") ?? "";
}
function externalUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
@@ -1,21 +1,29 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Bot,
ChevronDown,
ChevronLeft,
Loader2,
Search,
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 { getEightBfRoundtableBackUrl } from "@/eightbf-roundtable/api/task-context";
import { useEightBfAnalysisResults } from "@/eightbf-roundtable/lib/collect-md-artifacts";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
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";
@@ -31,6 +39,32 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
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 handleBack = () => {
const url = getEightBfRoundtableBackUrl();
if (!url) {
toast.warning("尚未配置返回地址,请在 runtime-config.js 填写 VITE_8BF_ROUNDTABLE_BACK_URL");
return;
}
window.location.href = url;
};
return (
<button
type="button"
className="eightbf-roundtable__back-button"
onClick={handleBack}
>
<img src={backArrowIcon} alt="" />
返回
</button>
);
}
export function EightBfStep1Panel({
@@ -41,6 +75,11 @@ export function EightBfStep1Panel({
modelOptions,
onSelectedModelChange,
intentReady,
step2Dialogues = [],
step2ThreadIds = null,
step2CoordinatorName = null,
step2SeatStubMessages,
step3Summary = null,
...step1Props
}: EightBfStep1PanelProps) {
const [rightPanelOpen, setRightPanelOpen] = useState(true);
@@ -82,6 +121,7 @@ export function EightBfStep1Panel({
>
分析结果
</button>
<EightBfRoundtableBackButton />
</div>
}
rightSidebar={
@@ -96,10 +136,14 @@ export function EightBfStep1Panel({
<EightBfAgentWorkspace
tab={rightTab}
chain={chain}
intentReady={intentReady}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds}
step2CoordinatorName={step2CoordinatorName}
step2SeatStubMessages={step2SeatStubMessages}
step3Summary={step3Summary}
/>
</div>
</aside>
@@ -223,22 +267,40 @@ export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) {
function EightBfAgentWorkspace({
tab,
chain,
intentReady,
canConfigureChain,
onConfigureChain,
onCollapse,
step2Dialogues,
step2ThreadIds,
step2CoordinatorName,
step2SeatStubMessages,
step3Summary,
}: {
tab: "agents" | "results";
chain: BusinessChain | null;
intentReady: object | 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 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,
});
useEffect(() => {
if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
@@ -250,13 +312,22 @@ function EightBfAgentWorkspace({
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>
<span>分析成果列表</span>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
@@ -268,8 +339,20 @@ function EightBfAgentWorkspace({
</button>
</div>
<div className="position-roundtable-intent-analysis-results-scroll eightbf-roundtable-intent-results-scroll">
<EightBfIntentResults intentReady={intentReady} />
<AnalysisResultCardList
items={analysisItems}
emptyTitle="暂无交付产物"
emptyDescription="完成第二步研讨与第三步方案总结并生成 Markdown 后,产物会显示在这里。"
padded={false}
onOpen={setDetailItem}
/>
</div>
<AnalysisResultDetailDialog
item={detailItem}
onOpenChange={(open) => {
if (!open) setDetailItem(null);
}}
/>
</section>
);
}
@@ -320,11 +403,8 @@ function EightBfAgentWorkspace({
return (
<section className="eightbf-roundtable__agent-list-workspace">
<header>
<div>
<h3>智能体配置</h3>
<p>{chain?.title ? `业务链「${chain.title}」已配置的智能体` : "请先配置 8BF 业务链"}</p>
</div>
<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}>
@@ -341,27 +421,41 @@ function EightBfAgentWorkspace({
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
</header>
<div className="eightbf-roundtable__agent-list-search">
<Search className="size-3.5" />
<span>业务链智能体</span>
<span>{seatAgents.length} 个</span>
</div>
<div className="eightbf-roundtable__agent-list-scroll">
<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-list-loading"><Loader2 className="size-4 animate-spin" /> 正在加载智能体…</div>
) : seats.length === 0 ? (
<div className="eightbf-roundtable__agent-list-empty">当前业务链未配置智能体</div>
<div className="eightbf-roundtable__agent-selector-empty">正在加载智能体…</div>
) : filteredSeatAgents.length === 0 ? (
<div className="eightbf-roundtable__agent-selector-empty">
<p>{seatAgents.length ? "没有符合条件的智能体" : "当前岗位尚未配置智能体"}</p>
{!seatAgents.length && canConfigureChain && (
<button type="button" onClick={onConfigureChain}>前往业务链配置</button>
)}
</div>
) : (
seatAgents.map(({ seat, agent }, index) => (
<EightBfAgentRow
key={seat.agent_id}
seat={seat}
agent={agent}
index={index}
onSelect={() => setSelectedAgentId(seat.agent_id)}
/>
))
<div className="eightbf-roundtable__agent-selector-list">
{filteredSeatAgents.map(({ seat, agent }) => (
<EightBfAgentRow
key={seat.agent_id}
seat={seat}
agent={agent}
onSelect={() => setSelectedAgentId(seat.agent_id)}
/>
))}
</div>
)}
</div>
</section>
@@ -371,45 +465,27 @@ function EightBfAgentWorkspace({
function EightBfAgentRow({
seat,
agent,
index,
onSelect,
}: {
seat: ChainSeat;
agent: Agent | null;
index: number;
onSelect: () => void;
}) {
const name = agent?.name || seat.name || "未命名智能体";
const description = agent?.description || seat.description || "查看该智能体的模型、技能与常用问题配置";
const description = agent?.description || seat.description || "智能体助手";
const avatarText = Array.from(name.trim() || "?")[0]?.toUpperCase() || "?";
return (
<button type="button" className="eightbf-roundtable__agent-list-row" onClick={onSelect}>
<span className="eightbf-roundtable__agent-list-avatar" aria-hidden="true"><Bot /></span>
<span className="min-w-0 flex-1 text-left">
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
<span className="eightbf-roundtable__agent-selector-avatar" aria-hidden="true">{avatarText}</span>
<span className="eightbf-roundtable__agent-selector-main">
<strong>{name}</strong>
<small>{description}</small>
<p>{description}</p>
</span>
<span className="eightbf-roundtable__agent-list-index">{String(index + 1).padStart(2, "0")}</span>
<i className="eightbf-roundtable__agent-selector-bar" aria-hidden="true" />
</button>
);
}
function EightBfIntentResults({ intentReady }: { intentReady: object | null }) {
const sections = intentSections(intentReady);
if (sections.length === 0) {
return <p className="eightbf-roundtable__intent-results-empty">完成意图识别后,将在这里展示任务意图与关键约束。</p>;
}
return (
<div className="eightbf-roundtable__intent-results-list">
{sections.map((section) => (
<section key={section.label}>
<h3>{section.label}</h3>
<ul>{section.items.map((item) => <li key={item}>{item}</li>)}</ul>
</section>
))}
</div>
);
}
function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> {
const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null;
if (!source) return [];
@@ -0,0 +1,343 @@
import { useEffect, useMemo, useState } from "react";
import type { AnalysisResultCardItem } from "@/components/analysis-results";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import {
listSessionArtifacts,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
const MD_PATH_RE = /\.md$/i;
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
type DialogueLike = {
sender?: string;
agentThreadId?: string;
time?: string;
steps?: Array<{ key?: string; args?: Record<string, unknown> }>;
};
interface MdFileHit {
path: string;
name: string;
threadId: string | null;
content: string | null;
updatedAt?: string;
agentName: string;
order?: number;
}
function isMarkdownPath(path: string): boolean {
return MD_PATH_RE.test(path.split("/").pop() ?? path);
}
function filenameOf(path: string): string {
return path.split("/").filter(Boolean).at(-1) || path;
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function fileKey(threadId: string | null, path: string): string {
return `${threadId ?? ""}::${path}`;
}
function collectPathsFromSteps(
steps: DialogueLike["steps"],
): Array<{ path: string; content: string | null }> {
if (!Array.isArray(steps) || steps.length === 0) return [];
const out: Array<{ path: string; content: string | null }> = [];
const seen = new Set<string>();
const add = (path: string, content: string | null) => {
if (!path || seen.has(path) || !isMarkdownPath(path)) return;
seen.add(path);
out.push({ path, content });
};
for (const step of steps) {
if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths;
if (Array.isArray(fps)) {
for (const fp of fps) add(asString(fp), null);
}
continue;
}
if (step.key === "tool_calling:write_file" || step.key === "tool_calling:str_replace") {
add(asString(step.args?.path), asString(step.args?.content) || null);
}
}
return out;
}
function collectFromDialogues(dialogues: unknown[]): MdFileHit[] {
const hits: MdFileHit[] = [];
let order = 0;
for (const raw of dialogues) {
if (!raw || typeof raw !== "object") continue;
const dialogue = raw as DialogueLike;
const sender = asString(dialogue.sender) || "智能体";
const threadId = asString(dialogue.agentThreadId) || null;
for (const file of collectPathsFromSteps(dialogue.steps)) {
hits.push({
path: file.path,
name: filenameOf(file.path),
threadId,
content: file.content,
updatedAt: asString(dialogue.time) || undefined,
agentName: sender,
order: order++,
});
}
}
return hits;
}
function collectFromStubs(stubs: Record<string, unknown[]> | undefined): MdFileHit[] {
const hits: MdFileHit[] = [];
for (const [threadId, messages] of Object.entries(stubs ?? {})) {
if (!Array.isArray(messages)) continue;
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const calls = (message as { tool_calls?: unknown }).tool_calls;
if (!Array.isArray(calls)) continue;
for (const call of calls) {
if (!call || typeof call !== "object") continue;
const tool = call as { name?: unknown; args?: Record<string, unknown> };
if (asString(tool.name) !== "write_file") continue;
const path = asString(tool.args?.path);
if (!path || !isMarkdownPath(path)) continue;
hits.push({
path,
name: filenameOf(path),
threadId,
content: asString(tool.args?.content) || null,
agentName: "",
});
}
}
}
return hits;
}
function mergeHits(groups: MdFileHit[][]): Map<string, MdFileHit> {
const merged = new Map<string, MdFileHit>();
for (const group of groups) {
for (const hit of group) {
const key = fileKey(hit.threadId, hit.path);
const prev = merged.get(key);
if (!prev) {
merged.set(key, hit);
continue;
}
merged.set(key, {
path: hit.path || prev.path,
name: hit.name || prev.name,
threadId: hit.threadId || prev.threadId,
content: hit.content || prev.content,
updatedAt: hit.updatedAt || prev.updatedAt,
agentName: prev.agentName || hit.agentName,
order: Math.max(prev.order ?? 0, hit.order ?? 0),
});
}
}
return merged;
}
function latestHit(hits: MdFileHit[]): MdFileHit | null {
if (hits.length === 0) return null;
return hits.reduce((latest, hit) => {
const latestTime = Date.parse(latest.updatedAt ?? "") || 0;
const hitTime = Date.parse(hit.updatedAt ?? "") || 0;
if (hitTime !== latestTime) return hitTime > latestTime ? hit : latest;
return (hit.order ?? 0) >= (latest.order ?? 0) ? hit : latest;
});
}
function pendingSeatCard(seat: BusinessChain["seats"][number]): AnalysisResultCardItem {
const agentName = seat.name?.trim() || seat.agent_id;
return {
id: `eightbf:seat:${seat.agent_id}:pending`,
agentName,
productName: `待交付:${agentName} Markdown报告`,
positionName: agentName,
delivered: false,
fallbackTime: "待完成",
unavailableMessage: `「${agentName}」尚未交付可预览的 Markdown 文件。完成第二步研讨后,这里会直接展示产物。`,
};
}
function hitToCard(
hit: MdFileHit,
positionName: string,
id: string,
): AnalysisResultCardItem {
return {
id,
agentName: hit.agentName || positionName,
productName: hit.name,
positionName,
updatedAt: hit.updatedAt,
fallbackTime: "刚刚",
delivered: true,
artifactPath: hit.path,
artifactMimeType: "text/markdown",
artifactThreadId: hit.threadId,
artifactContent: hit.content,
};
}
export function collectEightBfAnalysisResults({
chain,
dialogues,
threadIds,
coordinatorName,
seatStubMessages,
summaryReport,
remoteFiles = [],
}: {
chain: BusinessChain | null;
dialogues: unknown[];
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatStubMessages?: Record<string, unknown[]>;
summaryReport: DraftStep3SummarySnapshot | null;
remoteFiles?: MdFileHit[];
}): AnalysisResultCardItem[] {
const merged = mergeHits([
collectFromDialogues(dialogues),
collectFromStubs(seatStubMessages),
]);
const seats = chain?.seats ?? [];
const coordinator = coordinatorName?.trim() || "";
const items: AnalysisResultCardItem[] = [];
const threadIdOfSeat = (seat: BusinessChain["seats"][number]): string | null => {
const byId = threadIds?.[seat.agent_id];
if (byId) return byId;
const byName = Object.entries(threadIds ?? {}).find(
([name]) => name.toLowerCase() === seat.agent_id.toLowerCase() || name === seat.name,
)?.[1];
return byName ?? null;
};
for (const seat of seats) {
if (coordinator && (seat.name === coordinator || seat.agent_id === coordinator)) continue;
const seatThreadId = threadIdOfSeat(seat);
const agentName = seat.name?.trim() || seat.agent_id;
const seatHits = [...merged.values()].filter((hit) => {
if (seatThreadId && hit.threadId === seatThreadId) return true;
return hit.agentName === agentName || hit.agentName === seat.agent_id;
});
const latest = latestHit(seatHits) ?? latestHit(
remoteFiles.filter((hit) => Boolean(seatThreadId) && hit.threadId === seatThreadId),
);
if (!latest) {
items.push(pendingSeatCard(seat));
continue;
}
const enriched = latest.content
? latest
: remoteFiles.find((hit) =>
hit.threadId === latest.threadId &&
(hit.path === latest.path || hit.name === latest.name),
) ?? latest;
items.push(hitToCard(enriched, agentName, `eightbf:seat:${seat.agent_id}:latest`));
}
const deliveredSeatCount = items.filter((item) => item.delivered).length;
if (summaryReport?.md.trim()) {
items.push({
id: "eightbf:summary",
agentName: "方案总结智能体",
productName: "方案总结报告.md",
positionName: "方案总结",
updatedAt: summaryReport.generatedAt,
fallbackTime: "刚刚",
delivered: true,
artifactPath: SUMMARY_OUTPUT_PATH,
artifactMimeType: "text/markdown",
artifactThreadId: summaryReport.threadId ?? null,
artifactContent: summaryReport.md,
summary: summaryReport.closing ?? summaryReport.md,
});
} else if (deliveredSeatCount > 0) {
items.push({
id: "eightbf:summary:pending",
agentName: "方案总结智能体",
productName: "待生成:方案总结报告.md",
positionName: "方案总结",
delivered: false,
fallbackTime: "待完成",
unavailableMessage: "第三步方案总结尚未生成 Markdown 报告。完成总结后,这里会直接展示产物。",
});
}
return items;
}
export function useEightBfAnalysisResults(input: {
chain: BusinessChain | null;
dialogues: unknown[];
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatStubMessages?: Record<string, unknown[]>;
summaryReport: DraftStep3SummarySnapshot | null;
}): AnalysisResultCardItem[] {
const [remoteFiles, setRemoteFiles] = useState<MdFileHit[]>([]);
const threadEntries = useMemo(
() => Object.entries(input.threadIds ?? {}).filter(([, threadId]) => Boolean(threadId)),
[input.threadIds],
);
useEffect(() => {
if (threadEntries.length === 0) {
setRemoteFiles([]);
return;
}
const abort = new AbortController();
let cancelled = false;
void Promise.all(
threadEntries.map(async ([agentName, threadId]) => {
try {
const res = await listSessionArtifacts(threadId, { signal: abort.signal });
return res.files
.filter((file) => isMarkdownPath(file.path) || isMarkdownPath(file.name))
.map((file) => ({
path: file.path,
name: file.name || filenameOf(file.path),
threadId,
content: null,
updatedAt: file.modifiedAt,
agentName,
}));
} catch {
return [] as MdFileHit[];
}
}),
).then((groups) => {
if (!cancelled) setRemoteFiles(groups.flat());
});
return () => {
cancelled = true;
abort.abort();
};
}, [threadEntries]);
return useMemo(
() =>
collectEightBfAnalysisResults({
...input,
remoteFiles,
}),
[
input.chain,
input.coordinatorName,
input.dialogues,
input.seatStubMessages,
input.summaryReport,
input.threadIds,
remoteFiles,
],
);
}
@@ -266,6 +266,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
hideHeaderSettings
newTaskLabel="新建会话"
hideDefaultStep1RightSidebar
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
renderStep1={(props) => (
<EightBfStep1Panel
{...props}
@@ -17,6 +17,25 @@
font-size: 12px;
}
.eightbf-roundtable .position-roundtable-intent-header-actions button:nth-child(2) {
width: 100px;
}
.eightbf-roundtable .position-roundtable-intent-header-actions .eightbf-roundtable__back-button {
width: 86.04px;
height: 28px;
gap: 4px;
padding: 0 8px;
background: linear-gradient(109.46deg, rgba(0, 98, 217, 0.59) 3.83%, rgba(0, 98, 217, 0.3) 98.23%);
border-radius: 20px;
}
.eightbf-roundtable .position-roundtable-intent-header-actions .eightbf-roundtable__back-button img {
width: 14px;
height: 12px;
flex: 0 0 auto;
}
.eightbf-roundtable .eightbf-roundtable__chain-status-label { color: #0f5f56; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
@@ -178,17 +197,6 @@
flex-direction: column;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace > header {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgb(43 143 254 / 35%);
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace h3 { margin: 0; color: #fff; font-size: 17px; font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace p { margin: 4px 0 0; color: #aaceff; font-size: 11px; line-height: 1.45; }
.eightbf-roundtable .eightbf-roundtable__configure-chain-button {
display: inline-flex;
height: 28px;
@@ -202,45 +210,170 @@
font-size: 11px;
}
.eightbf-roundtable .eightbf-roundtable__configure-chain-button:hover { background: rgb(0 98 217 / 40%); }
.eightbf-roundtable .eightbf-roundtable__agent-list-search {
.eightbf-roundtable .eightbf-roundtable__agent-selector {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
overflow: hidden;
padding: 16px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-head {
display: flex;
flex-direction: column;
gap: 8px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-title {
display: inline-flex;
align-items: center;
gap: 7px;
margin: 12px;
border: 1px solid rgb(43 143 254 / 36%);
border-radius: 4px;
background: rgb(0 12 28 / 20%);
gap: 8px;
color: #fff;
font-size: 14px;
font-weight: 500;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-title b { font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__agent-selector-title i {
min-width: 18px;
padding: 0 5px;
border-radius: 9px;
color: #aaceff;
padding: 8px 9px;
background: rgb(21 66 128 / 58%);
font-size: 12px;
font-style: normal;
line-height: 18px;
text-align: center;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-search {
width: 100%;
box-sizing: border-box;
padding: 6px 10px;
border: 1px solid rgb(43 143 254 / 40%);
border-radius: 4px;
color: #fff;
background: rgb(0 12 28 / 40%);
font-size: 13px;
outline: none;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-search::placeholder { color: rgb(255 255 255 / 40%); }
.eightbf-roundtable .eightbf-roundtable__agent-selector-search:focus { border-color: #45c3f3; }
.eightbf-roundtable .eightbf-roundtable__agent-selector-list {
display: grid;
min-height: 0;
flex: 1;
align-content: start;
gap: 8px;
overflow-x: hidden;
overflow-y: auto;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-card {
position: relative;
display: grid;
width: 100%;
grid-template-columns: 40px 1fr;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgb(43 143 254 / 20%);
border-radius: 8px;
color: inherit;
font: inherit;
text-align: left;
background: rgb(0 98 217 / 8%);
cursor: pointer;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-card:hover {
border-color: rgb(43 143 254 / 60%);
background: rgb(0 98 217 / 22%);
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-card:hover .eightbf-roundtable__agent-selector-bar {
background: #45c3f3;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-avatar {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border-radius: 8px;
color: #fff;
background: linear-gradient(135deg, #1675d1, #06447f);
font-size: 16px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-main strong {
overflow: hidden;
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-main p {
margin: 0;
overflow: hidden;
color: #9ebfe2;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-search span:last-child { margin-left: auto; border-radius: 999px; background: #0b386d; padding: 1px 6px; color: #83c1ff; font-size: 10px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 12px 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-loading,
.eightbf-roundtable .eightbf-roundtable__agent-list-empty { display: flex; min-height: 100px; align-items: center; justify-content: center; gap: 7px; color: #8aa0c4; font-size: 12px; text-align: center; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgb(43 143 254 / 21%);
padding: 11px 2px;
color: inherit;
text-align: left;
transition: background-color .16s ease, transform .16s ease;
.eightbf-roundtable .eightbf-roundtable__agent-selector-bar {
position: absolute;
top: 50%;
right: 6px;
width: 6px;
height: 32px;
border-radius: 3px;
background: transparent;
transform: translateY(-50%);
transition: background 0.15s ease;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty {
display: flex;
min-height: 200px;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px 16px;
color: rgb(255 255 255 / 60%);
font-size: 13px;
text-align: center;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty p { margin: 0; }
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty button {
padding: 6px 16px;
border: 1px solid rgb(43 143 254 / 60%);
border-radius: 4px;
color: #fff;
background: rgb(0 98 217 / 18%);
cursor: pointer;
}
.eightbf-roundtable .eightbf-roundtable__agent-selector-empty button:hover {
background: rgb(0 98 217 / 32%);
}
.eightbf-roundtable .eightbf-roundtable__agent-list-row:hover { background: rgb(0 98 217 / 13%); transform: translateX(2px); }
.eightbf-roundtable .eightbf-roundtable__agent-list-avatar { display: inline-flex; width: 34px; height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; border: 1px solid #007e82; border-radius: 7px; background: #00306a; color: #73c8ff; }
.eightbf-roundtable .eightbf-roundtable__agent-list-avatar svg { width: 18px; height: 18px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row strong,
.eightbf-roundtable .eightbf-roundtable__agent-list-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row strong { color: #fff; font-size: 13px; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row small { margin-top: 3px; color: #aaceff; font-size: 11px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-index { color: #75b7f5; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar { display: flex; height: 48px; align-items: center; justify-content: space-between; border-bottom: 1px solid rgb(43 143 254 / 35%); padding: 0 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child { display: inline-flex; align-items: center; gap: 2px; color: #aaceff; font-size: 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child:hover { color: #fff; }
.eightbf-roundtable .eightbf-roundtable__intent-results-scroll { padding: 12px; }
.eightbf-roundtable .position-roundtable-intent-config-pane-inner {
width: 100% !important;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.eightbf-roundtable .eightbf-roundtable-intent-results-scroll {
min-width: 0;
max-width: 100%;
overflow-x: hidden;
padding: 10px;
}
.eightbf-roundtable .eightbf-roundtable__intent-results-empty { margin: 0; color: #aaceff; font-size: 13px; line-height: 1.8; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list { display: grid; gap: 10px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list section { border: 1px solid rgb(43 143 254 / 32%); border-radius: 4px; background: rgb(0 12 28 / 20%); padding: 12px; }
@@ -81,6 +81,46 @@
border-radius: 4px !important;
}
.dark .eightbf-roundtable[data-roundtable-step="2"] > main {
padding: 12px 0;
}
.dark .eightbf-roundtable[data-roundtable-step="2"] #middle-viewport {
border-color: rgb(43 143 254 / 50%);
border-radius: 4px;
background: #032650;
box-shadow: none;
}
.eightbf-roundtable .eightbf-roundtable-step2-chat {
min-height: 0;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable-step2-log {
background: #032650;
border: 0;
box-shadow: none;
}
.eightbf-roundtable .eightbf-roundtable-step2-chat .position-roundtable-intent-header-actions .eightbf-roundtable__suspend-button {
width: 100px;
}
.eightbf-roundtable .eightbf-roundtable-step2-chat .relative.flex-1 > .shrink-0 {
background: #032650;
border-color: rgb(43 143 254 / 28%);
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-700,
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-500 {
color: #aaceff;
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-log .text-slate-400 {
color: #8aa0c4;
}
/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其
#enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input {
@@ -48,6 +48,7 @@ import {
} from "tdesign-react";
import { toast } from "sonner";
import { AnalysisResultCardList, type AnalysisResultCardItem } from "@/components/analysis-results";
import { agentColor } from "@/components/workspace/agent-icon-color";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-module-sidebar";
@@ -91,14 +92,6 @@ import askQuestionIcon from "@/assets/position-roundtable/ask-question.svg";
import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg";
import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import analysisTag1 from "@/assets/position-roundtable/标签1.svg";
import analysisTag2 from "@/assets/position-roundtable/标签2.svg";
import analysisTag3 from "@/assets/position-roundtable/标签3.svg";
import analysisTag4 from "@/assets/position-roundtable/标签4.svg";
import analysisTag5 from "@/assets/position-roundtable/标签5.svg";
import analysisTag6 from "@/assets/position-roundtable/标签6.svg";
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
import { deleteChain, listChains } from "@/roundtable-planning/api/chains";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
@@ -5961,15 +5954,6 @@ interface ResultItem {
artifactContent?: string | null;
}
const ANALYSIS_RESULT_TAG_ICONS = [
analysisTag1,
analysisTag2,
analysisTag3,
analysisTag4,
analysisTag5,
analysisTag6,
] as const;
function canOpenDeliverable(item: ResultItem): boolean {
return Boolean(
item.status === "done" &&
@@ -6407,6 +6391,25 @@ function AnalysisResults({
),
[kindFilter, nodeFilter, positionFilter, resultItems],
);
const visibleCardItems = useMemo<AnalysisResultCardItem[]>(
() =>
visibleItems.map((item) => ({
id: item.id,
agentName: item.agentName,
productName: item.productName,
positionName: resultPositionName(item.positionId),
updatedAt: item.updatedAt,
fallbackTime: intentCompletedAt || "刚刚",
delivered: canOpenDeliverable(item),
artifactPath: item.artifactPath,
artifactMimeType: item.artifactMimeType,
artifactThreadId: item.artifactThreadId,
artifactContent: item.artifactContent,
summary: item.summary,
unavailableMessage: deliverableUnavailableMessage(item),
})),
[intentCompletedAt, visibleItems],
);
const openDetail = useCallback(
(item: ResultItem) => {
@@ -6462,13 +6465,12 @@ function AnalysisResults({
if (resultItems.length === 0) {
return (
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
<p className="text-sm font-semibold">暂无交付产物</p>
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
情报分析岗启动业务链后,各岗位待交付产物会在这里排队展示。
</p>
</div>
<AnalysisResultCardList
items={[]}
emptyTitle="暂无交付产物"
emptyDescription="情报分析岗启动业务链后,各岗位待交付产物会在这里排队展示。"
onOpen={() => {}}
/>
);
}
@@ -6547,67 +6549,14 @@ function AnalysisResults({
当前筛选条件下没有结果
</div>
) : (
visibleItems.map((item, index) => {
const displayedTime = item.updatedAt
? new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(item.updatedAt))
: intentCompletedAt || "刚刚";
const hasDeliveredArtifact = Boolean(item.artifactPath || item.artifactContent?.trim());
const displayedProductName =
hasDeliveredArtifact || (item.kind !== "delivery" && item.status === "done")
? item.productName
: "未交付";
const contents = (
<div className="position-roundtable-analysis-result-card-body">
<div className="position-roundtable-analysis-result-tag" aria-label={desensitize(item.agentName)}>
<img
src={ANALYSIS_RESULT_TAG_ICONS[index % ANALYSIS_RESULT_TAG_ICONS.length]}
alt=""
aria-hidden="true"
/>
<span>{desensitize(item.agentName)}</span>
</div>
<p className="position-roundtable-analysis-result-product">
{desensitize(displayedProductName)}
</p>
<footer className="position-roundtable-analysis-result-meta">
<span>
<img src={responsibilityIcon} alt="" aria-hidden="true" />
{resultPositionName(item.positionId)}
</span>
<i aria-hidden="true" />
<span>
<img src={resultTimeIcon} alt="" aria-hidden="true" />
{displayedTime}
</span>
</footer>
</div>
);
return item.node ? (
<button
key={item.id}
type="button"
onClick={() => openDetail(item)}
className="position-roundtable-analysis-result-card"
>
{contents}
</button>
) : (
<button
key={item.id}
type="button"
onClick={() => openDetail(item)}
className="position-roundtable-analysis-result-card"
>
{contents}
</button>
);
})
<AnalysisResultCardList
items={visibleCardItems}
padded={false}
onOpen={(card) => {
const original = resultItems.find((item) => item.id === card.id);
if (original) openDetail(original);
}}
/>
)}
</div>
@@ -1108,8 +1108,10 @@
}
.position-roundtable-intent-analysis-results-scroll {
min-width: 0;
min-height: 0;
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
}
@@ -1147,26 +1149,32 @@
top: -40px;
left: -12px;
display: inline-flex;
width: max-content;
min-width: 95px;
max-width: calc(100% - 12px);
width: fit-content;
max-width: calc(100% + 12px);
height: 26px;
align-items: center;
overflow: hidden;
box-sizing: border-box;
padding: 0 22px 0 12px;
overflow: hidden;
border-radius: 4px 0 0 0;
background: rgba(212, 120, 0, 0.5);
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
line-height: normal;
white-space: nowrap;
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
}
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
.position-roundtable-analysis-result-tag img {
position: absolute;
inset: 0 auto auto 0;
z-index: 0;
width: 100%;
height: 26px;
display: none;
}
.position-roundtable-analysis-result-tag span {
@@ -1176,7 +1184,7 @@
min-width: 0;
max-width: 100%;
overflow: hidden;
padding: 0 12px;
padding: 0;
text-overflow: ellipsis;
}
@@ -96,6 +96,10 @@ interface ModelOption {
export interface Step2PanelProps {
/** 独立业务页需要与第一步完全相同的输入组件(8BF 专用,默认不改变原会商)。 */
useStep1InputAppearance?: boolean;
/** 8BF 第二步对话区标题,与第一步「业务链 · 意图识别」同一套顶栏。 */
headerTitle?: string;
/** 8BF 第二步顶栏右侧附加操作(如返回)。 */
headerRightSlot?: ReactNode;
// ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean;
@@ -165,6 +169,8 @@ export interface Step2PanelProps {
export function Step2Panel(props: Step2PanelProps) {
const {
useStep1InputAppearance = false,
headerTitle,
headerRightSlot,
step2RoundtableDialogues,
isInitializing,
isOrchestrating,
@@ -412,42 +418,64 @@ export function Step2Panel(props: Step2PanelProps) {
const chatBody = (
<>
{/* Headline */}
<div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
<div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div>
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
<p className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-medium">
分布式领域专家实时动态博弈与纳什协商收敛中
</p>
<div
ref={headerDzRef}
className={
useStep1InputAppearance
? "shrink-0 flex items-center justify-between gap-3"
: "shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm"
}
>
{useStep1InputAppearance ? (
<div className="position-roundtable-intent-header-title">
<span aria-hidden="true" />
<h2>{headerTitle || "多智能体会商"}</h2>
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<select
id="step2-global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型"
}
className="max-w-[220px] bg-slate-50/50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
) : (
<div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div>
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
<p className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-medium">
分布式领域专家实时动态博弈与纳什协商收敛中
</p>
</div>
</div>
)}
<div
className={
useStep1InputAppearance
? "position-roundtable-intent-header-actions"
: "flex items-center gap-2.5 shrink-0"
}
>
{!useStep1InputAppearance && (
<select
id="step2-global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型"
}
className="max-w-[220px] bg-slate-50/50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
)}
{/* 后台挂起:查看历史记录时隐藏(isViewingHistory),
重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */}
{SHOW_BACKGROUND_SUSPEND && !isViewingHistory && (
@@ -455,12 +483,17 @@ export function Step2Panel(props: Step2PanelProps) {
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-slate-100 hover:bg-slate-200 text-slate-700 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-slate-200/60 shadow-sm dark:bg-secondary dark:text-foreground dark:border-border"
className={
useStep1InputAppearance
? "eightbf-roundtable__suspend-button"
: "bg-slate-100 hover:bg-slate-200 text-slate-700 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-slate-200/60 shadow-sm dark:bg-secondary dark:text-foreground dark:border-border"
}
>
<Network className="w-3.5 h-3.5" />
{!useStep1InputAppearance && <Network className="w-3.5 h-3.5" />}
后台挂起
</button>
)}
{headerRightSlot}
</div>
</div>
@@ -530,7 +563,12 @@ export function Step2Panel(props: Step2PanelProps) {
// 另一轴会被计算成 auto,任何子元素溢出一两像素都会让整列横向可滚 → 鼠标移到右侧
// 竖滚条滑块时极易误触发横向拖动、抓不住滑块。宽内容(代码块/表格)在各自气泡内
// 已有 max-w-full + overflow-x-auto 自带横滚,故此处裁掉横向不影响查看。
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
className={cn(
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4",
useStep1InputAppearance
? "eightbf-roundtable-step2-log"
: "bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner",
)}
>
{isInitializing && step2RoundtableDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
@@ -791,7 +829,16 @@ export function Step2Panel(props: Step2PanelProps) {
seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission}
>
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">{chatBody}</div>
<div
className={cn(
"flex h-full min-h-0 flex-col",
useStep1InputAppearance
? "position-roundtable-intent-panel eightbf-roundtable-intent-panel eightbf-roundtable-step2-chat"
: "bg-slate-50/20",
)}
>
{chatBody}
</div>
</Step2SandboxLayout>
);
}
@@ -102,7 +102,9 @@ import type {
DraftStep2RunSnapshot,
DraftStep2Snapshot,
DraftStep3Snapshot,
DraftStep3SummarySnapshot,
} from "@/roundtable-planning/api/drafts";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import {
useStep1Intent,
type InteractiveMessage,
@@ -316,6 +318,8 @@ export default function RoundtablePlanningPage({
renderStep2LeftSidebarHeader,
/** 第一阶段使用内嵌右栏时,隐藏通用的模型/摘要/澄清记录栏。 */
hideDefaultStep1RightSidebar = false,
step2HeaderTitle,
step2HeaderRightSlot,
/** 独立业务页的工作区品牌区,取代通用步骤导航。 */
headerLeftSlot,
/** 放在历史记录后面的独立业务操作或状态。 */
@@ -343,6 +347,11 @@ export default function RoundtablePlanningPage({
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;
}) => ReactNode;
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode;
renderStep2LeftSidebarHeader?: (context: {
@@ -350,6 +359,10 @@ export default function RoundtablePlanningPage({
onCollapse: () => void;
}) => ReactNode;
hideDefaultStep1RightSidebar?: boolean;
/** 8BF 第二步对话区标题,与第一步顶栏同一套样式。 */
step2HeaderTitle?: string;
/** 8BF 第二步顶栏右侧附加操作(如返回)。 */
step2HeaderRightSlot?: ReactNode;
headerLeftSlot?: ReactNode;
headerActionAfterHistory?: ReactNode;
hideHeaderSaveDraft?: boolean;
@@ -2401,6 +2414,11 @@ export default function RoundtablePlanningPage({
selectedModel,
modelOptions: availableModels.map((model) => ({ label: model.name, value: model.name })),
onSelectedModelChange: setSelectedModel,
step2Dialogues: step2.step2RoundtableDialogues,
step2ThreadIds: step2.threadIds,
step2CoordinatorName: step2.coordinatorName,
step2SeatStubMessages: step2.seatStubMessages,
step3Summary: step3Report?.summaryReport ?? null,
}) : <Step1Panel
{...step1PanelProps}
headerLeftSlot={
@@ -2421,6 +2439,8 @@ export default function RoundtablePlanningPage({
{currentStep === 2 && (
<Step2Panel
useStep1InputAppearance={rootClassName?.includes("eightbf-roundtable") ?? false}
headerTitle={step2HeaderTitle}
headerRightSlot={step2HeaderRightSlot}
step2RoundtableDialogues={step2.step2RoundtableDialogues}
isInitializing={step2.isInitializing}
isOrchestrating={step2.isOrchestrating}