feat: 完善会商产物提交匹配与流式写文件预览

席位流程图按已提交 Markdown 对齐;流式未命名产物不再误请求 artifacts API,并兼容临时文件名路径。同时补强会话流中断识别与 8BF/岗位会商展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-19 17:08:52 +08:00
1 parent acf538ac01
commit 3afa76894f
19 files changed
+2459 -1881

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -1,21 +1,21 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUp,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUp,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
MessageCircleQuestionMarkIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ChainOfThought,
@@ -31,20 +31,20 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
resolveSearchQuery,
@@ -58,13 +58,13 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
buildSkillDisplayMap,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
@@ -235,7 +235,7 @@ export function MessageGroup({
);
}
function ToolCall({
function ToolCall({
id,
messageId,
name,
@@ -257,45 +257,45 @@ function ToolCall({
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
label={
displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`
}
label={
displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
: `获取 ${displayItems.length} 条结果`
}
icon={SearchIcon}
>
<DisplayResultsList results={displayItems} />
</ChainOfThoughtStep>
);
}
// Deep Research uses a virtual tool call for each durable writing phase.
// Rendering it here keeps its lifecycle in the very same ChainOfThought
// component as ordinary Q&A retrieval and sandbox tool calls.
if (name === "deep_research_progress") {
return <DeepResearchProgressStep id={id} args={args} />;
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
}
// Deep Research uses a virtual tool call for each durable writing phase.
// Rendering it here keeps its lifecycle in the very same ChainOfThought
// component as ordinary Q&A retrieval and sandbox tool calls.
if (name === "deep_research_progress") {
return <DeepResearchProgressStep id={id} args={args} />;
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
@@ -308,19 +308,19 @@ function ToolCall({
</ChainOfThoughtStep>
);
}
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? `知识库检索 "${query}"`
: t.toolCalls.searchFor(query);
} else if (name === "web_search") {
label = "知识库检索";
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? `知识库检索 "${query}"`
: t.toolCalls.searchFor(query);
} else if (name === "web_search") {
label = "知识库检索";
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{searchItems.length > 0 && (
<WebSearchResultsList results={searchItems} />
)}
@@ -437,18 +437,18 @@ function ToolCall({
if (!description) {
description = t.toolCalls.writeFile;
}
return (
<ArtifactWriteToolCall
id={id}
messageId={messageId}
name={name}
args={args}
result={result}
description={description}
isLast={isLast}
isLoading={isLoading}
/>
);
return (
<ArtifactWriteToolCall
id={id}
messageId={messageId}
name={name}
args={args}
result={result}
description={description}
isLast={isLast}
isLoading={isLoading}
/>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
@@ -509,161 +509,164 @@ function ToolCall({
icon={WrenchIcon}
></ChainOfThoughtStep>
);
}
}
function DeepResearchProgressStep({
id,
args,
}: {
id?: string;
args: Record<string, unknown>;
}) {
const label = typeof args.label === "string" && args.label.trim()
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
return (
<ChainOfThoughtStep
key={id}
status={active ? "active" : "complete"}
icon={active
? <Loader2Icon className="text-primary size-4 animate-spin" />
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
label={
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span>{label}</span>
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
{active ? "进行中" : "已完成"}
</span>
</span>
}
>
{detail && (
<div className="text-muted-foreground text-xs leading-5">
{detail}
</div>
)}
{thinking && (
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
<CollapsibleTrigger asChild>
<Button
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
{thinking}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</ChainOfThoughtStep>
);
}
function ArtifactWriteToolCall({
id,
messageId,
name,
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
messageId?: string;
name: "write_file" | "str_replace";
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
description: string;
isLast: boolean;
isLoading: boolean;
}) {
const artifacts = useArtifactsOptional();
const openStreamedArtifact = artifacts?.openStreamedArtifact;
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
: sourcePath;
useEffect(() => {
if (
!openStreamedArtifact ||
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
]);
return (
<ChainOfThoughtStep
key={id}
className={cn(artifactPath && "cursor-pointer")}
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) return;
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{normalizedPath || sourcePath}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
}
}
function DeepResearchProgressStep({
id,
args,
}: {
id?: string;
args: Record<string, unknown>;
}) {
const label = typeof args.label === "string" && args.label.trim()
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
return (
<ChainOfThoughtStep
key={id}
status={active ? "active" : "complete"}
icon={active
? <Loader2Icon className="text-primary size-4 animate-spin" />
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
label={
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span>{label}</span>
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
{active ? "进行中" : "已完成"}
</span>
</span>
}
>
{detail && (
<div className="text-muted-foreground text-xs leading-5">
{detail}
</div>
)}
{thinking && (
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
<CollapsibleTrigger asChild>
<Button
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
{thinking}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</ChainOfThoughtStep>
);
}
function ArtifactWriteToolCall({
id,
messageId,
name,
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
messageId?: string;
name: "write_file" | "str_replace";
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
description: string;
isLast: boolean;
isLoading: boolean;
}) {
const artifacts = useArtifactsOptional();
const openStreamedArtifact = artifacts?.openStreamedArtifact;
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Never select a bare provisional name like `未命名文件.md` — the artifacts
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
// tool call has ids so we can open a write-file: memory preview instead.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
: null;
useEffect(() => {
if (
!openStreamedArtifact ||
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
]);
return (
<ChainOfThoughtStep
key={id}
className={cn(artifactPath && "cursor-pointer")}
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) return;
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{normalizedPath || sourcePath}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
}
@@ -766,7 +769,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
open && "rotate-180",
)}
/>
<span>{results.length} 条真实返回结果</span>
<span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => (
+2 -1
View File
@@ -6,6 +6,7 @@ import { useThread } from "@/components/workspace/messages/context";
import { isImageFile } from "../utils/files";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import { isArtifactApiPath } from "./streaming-write-preview";
import { updateArtifactContent } from "./api";
function isWriteFileStreamingUrl(filepath: string): boolean {
@@ -101,7 +102,7 @@ export function useArtifactContent({
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
},
enabled,
enabled: Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath),
refetchInterval: thread.isLoading ? 1000 : false,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isArtifactApiPath,
isProvisionalStreamingArtifactPath,
provisionalWriteFilePath,
} from "./streaming-write-preview";
test("provisionalWriteFilePath uses html only for html documents", () => {
assert.equal(provisionalWriteFilePath("# title"), "未命名文件.md");
assert.equal(provisionalWriteFilePath("<!DOCTYPE html><html>"), "未命名文件.html");
assert.equal(provisionalWriteFilePath("<html lang='zh'>"), "未命名文件.html");
});
test("isProvisionalStreamingArtifactPath matches the streaming placeholder names", () => {
assert.equal(isProvisionalStreamingArtifactPath("未命名文件.md"), true);
assert.equal(isProvisionalStreamingArtifactPath("/未命名文件.html"), true);
assert.equal(isProvisionalStreamingArtifactPath("mnt/user-data/outputs/report.md"), false);
});
test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
assert.equal(isArtifactApiPath("未命名文件.md"), false);
assert.equal(
isArtifactApiPath(
"write-file:未命名文件.md?message_id=m1&tool_call_id=t1&language=markdown",
),
false,
);
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true);
});
@@ -23,6 +23,11 @@ export function hasCompleteArtifactExtension(path: string): boolean {
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
export const PROVISIONAL_WRITE_FILE_NAMES = new Set([
"未命名文件.md",
"未命名文件.html",
]);
export function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
@@ -31,6 +36,29 @@ export function provisionalWriteFilePath(content: string): string {
return "未命名文件.md";
}
export function isProvisionalStreamingArtifactPath(path: string): boolean {
const pathname =
normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
return PROVISIONAL_WRITE_FILE_NAMES.has(filename);
}
/** Paths the artifacts HTTP API can actually serve (sandbox virtual paths). */
export function isArtifactApiPath(path: string): boolean {
if (!path || path.startsWith("write-file:")) {
return false;
}
if (isProvisionalStreamingArtifactPath(path)) {
return false;
}
const normalized = path.replace(/^\/+/, "").replace(/\\/g, "/");
return (
normalized === "mnt/user-data" ||
normalized.startsWith("mnt/user-data/") ||
normalized.startsWith("mnt/skills/")
);
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
File diff suppressed because it is too large. Load diff
@@ -62,6 +62,37 @@ export function isThreadNotAccessibleError(message: string): boolean {
);
}
export function isAbortLikeError(error: unknown): boolean {
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
if (error instanceof Error && error.name === "AbortError") {
return true;
}
const message = getStreamErrorMessage(error).toLowerCase();
return (
message.includes("aborterror") ||
message.includes("the operation was aborted") ||
message.includes("bodystreambuffer")
);
}
export function isTransientStreamTransportError(message: string): boolean {
const lowered = message.toLowerCase();
return (
/failed to fetch/i.test(message) ||
lowered.includes("networkerror") ||
lowered.includes("network error") ||
lowered.includes("load failed") ||
lowered.includes("err_connection") ||
lowered.includes("err_incomplete") ||
lowered.includes("err_network") ||
lowered.includes("econnreset") ||
lowered.includes("econnaborted") ||
/fetch failed/i.test(message)
);
}
/** Build a label for the *current* logged-in identity (email → user_id → 未知). */
export function buildCurrentUserLabel(): string {
const auth = getStoredAuth();
@@ -167,14 +167,27 @@ 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>
);
@@ -267,6 +267,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
newTaskLabel="新建会话"
hideDefaultStep1RightSidebar
step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`}
step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`}
renderStep1={(props) => (
<EightBfStep1Panel
{...props}
@@ -275,15 +276,17 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
onConfigureChain={openChainSettings}
/>
)}
renderStep1LeftSidebar={({ intentReady }) => (
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
<EightBfStep1Sidebar
intentReady={intentReady}
onCollapse={onCollapse}
taskCard={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
onCollapse={onCollapse}
/>
) : undefined}
businessChain={
@@ -291,6 +294,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
chain={configuredChain}
loading={isLoading}
embedded
submittedSeatKeys={submittedSeatKeys}
onSeatClick={onSeatClick}
className="position-roundtable-intent-delivery-flow eightbf-roundtable__business-chain-flow"
/>
}
@@ -76,22 +76,70 @@
background: #21958a;
}
/* 第三步只保留「总结报告 + 沙箱」。沿用第一、二步的 4px 面板语言。 */
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable__step3-source-panel {
border: 1px solid rgb(43 143 254 / 50%);
border-radius: 4px;
background: #032650;
padding: 12px;
/* 第三步:8BF 左栏与第一步相同,对话区去掉原外层圆角白框,沿用第一、二步深蓝面板。 */
.dark .eightbf-roundtable[data-roundtable-step="3"] > main {
padding: 12px 0;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable__step3-report-panel {
.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="true"] {
gap: 0;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="false"] {
gap: 10px;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #middle-viewport {
border-color: rgb(43 143 254 / 50%);
border-radius: 4px !important;
border-radius: 4px;
background: #032650;
box-shadow: none;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] {
width: 374px;
padding: 0 12px;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="false"] {
width: 80px;
padding: 0 12px;
}
.eightbf-roundtable .eightbf-roundtable-step3-chat {
min-height: 0;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable-step3-log {
background: #032650;
border: 0;
box-shadow: none;
}
.eightbf-roundtable .eightbf-roundtable-step3-chat .position-roundtable-intent-header-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-700,
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-500 {
color: #aaceff;
}
.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-400 {
color: #8aa0c4;
}
/* PositionIntentInput is the shared Step 1/2 composer; Step 3 uses the exact same frame. */
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-input {
flex: 0 0 auto;
}
@media (max-width: 1280px) {
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
}
@@ -5,7 +5,7 @@
* 不会读取或写入岗位会商的会话、岗位节点状态。
*/
import { useRef, useState } from "react";
import { CheckCircle2, Circle, Search } from "lucide-react";
import { Check, Search } from "lucide-react";
import {
Dialog,
@@ -34,6 +34,14 @@ export interface PositionBusinessChainFlowProps {
* border and background; this flow keeps only its title row and canvas.
*/
embedded?: boolean;
/**
* Identity keys of seats whose Markdown artifact has been submitted.
* Matched against `seat.agent_id` and `seat.name` (case-insensitive).
* Green filled check = submitted only, same as 岗位会商 `is-delivered`.
*/
submittedSeatKeys?: readonly string[];
/** Click a seat node. Host opens the deliverable dialog when a submission exists. */
onSeatClick?: (seat: ChainSeat) => void;
}
function getFlowStages(chain: BusinessChain): FlowStage[] {
@@ -51,13 +59,27 @@ function getFlowStages(chain: BusinessChain): FlowStage[] {
return chain.seats.map((seat) => ({ goal: null, seats: [seat] }));
}
function isSeatSubmitted(seat: ChainSeat, submittedSeatKeys: ReadonlySet<string>): boolean {
return submittedSeatKeys.has(seat.agent_id.trim().toLowerCase())
|| submittedSeatKeys.has((seat.name ?? "").trim().toLowerCase());
}
export function PositionBusinessChainFlow({
chain,
loading = false,
className,
embedded = false,
submittedSeatKeys,
onSeatClick,
}: PositionBusinessChainFlowProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const submittedKeys = new Set(
(submittedSeatKeys ?? []).map((key) => key.trim().toLowerCase()).filter(Boolean),
);
const handleSeatClick = (seat: ChainSeat) => {
onSeatClick?.(seat);
setDialogOpen(false);
};
return (
<section
@@ -112,14 +134,26 @@ export function PositionBusinessChainFlow({
</div>
) : (
<>
<BusinessChainCanvas chain={chain} embedded={embedded} className="min-h-0 flex-1" />
<BusinessChainCanvas
chain={chain}
embedded={embedded}
submittedKeys={submittedKeys}
onSeatClick={onSeatClick ? handleSeatClick : undefined}
className="min-h-0 flex-1"
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="flex h-[86vh] w-[96vw] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="text-base font-semibold">{chain.title} · 业务链条流程图</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 p-4">
<BusinessChainCanvas chain={chain} expanded className="h-full" />
<BusinessChainCanvas
chain={chain}
expanded
submittedKeys={submittedKeys}
onSeatClick={onSeatClick ? handleSeatClick : undefined}
className="h-full"
/>
</div>
</DialogContent>
</Dialog>
@@ -133,11 +167,15 @@ function BusinessChainCanvas({
chain,
expanded = false,
embedded = false,
submittedKeys,
onSeatClick,
className,
}: {
chain: BusinessChain;
expanded?: boolean;
embedded?: boolean;
submittedKeys: ReadonlySet<string>;
onSeatClick?: (seat: ChainSeat) => void;
className?: string;
}) {
const [view, setView] = useState({ tx: 0, ty: 0 });
@@ -175,6 +213,7 @@ function BusinessChainCanvas({
onDoubleClick={() => setView({ tx: 0, ty: 0 })}
onPointerDown={(event) => {
if (event.button !== 0) return;
if ((event.target as HTMLElement | null)?.closest?.(".pr-flow-card")) return;
dragRef.current = { x: event.clientX, y: event.clientY, tx: view.tx, ty: view.ty };
setPanning(true);
event.currentTarget.setPointerCapture(event.pointerId);
@@ -217,6 +256,8 @@ function BusinessChainCanvas({
accent={accent}
width={nodeWidth}
compact={embedded}
completed={isSeatSubmitted(seat, submittedKeys)}
onClick={onSeatClick ? () => onSeatClick(seat) : undefined}
/>
);
})}
@@ -277,6 +318,7 @@ function FlowNode({
completed = false,
width,
compact = false,
onClick,
}: {
title: string;
description: string;
@@ -284,25 +326,27 @@ function FlowNode({
completed?: boolean;
width: number;
compact?: boolean;
onClick?: () => void;
}) {
const StatusIcon = completed ? CheckCircle2 : Circle;
return (
<article
className={cn(
"relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]",
compact && "rounded-lg",
)}
style={{ width, borderLeftWidth: 5, borderLeftColor: accent }}
>
const className = cn(
"pr-flow-card relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 text-left font-[inherit] shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]",
compact && "rounded-lg",
completed && "border-[#36b987]/60",
onClick && "cursor-pointer transition hover:-translate-y-0.5 hover:shadow-md",
);
const style = { width, borderLeftWidth: 5, borderLeftColor: accent };
const body = (
<>
<span
className={cn(
"absolute right-2 top-2 flex size-6 items-center justify-center rounded-full border bg-white shadow-sm dark:bg-[#0d3768]",
"absolute right-2 top-2 grid size-5 place-items-center rounded-full",
completed
? "border-emerald-500 text-emerald-500"
: "border-slate-300 text-slate-400 dark:border-[#8aa0c4] dark:text-[#8aa0c4]",
? "border-0 bg-[#36b987] text-white"
: "border-2 border-[#78a7e8] bg-transparent text-white",
)}
title={completed ? "已完成" : "待提交"}
>
<StatusIcon className="size-3.5" strokeWidth={2.5} />
{completed ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
<p className="truncate text-sm font-extrabold leading-5 text-slate-900 dark:text-[#e8edf5]" title={title}>
{title}
@@ -310,6 +354,25 @@ function FlowNode({
<p className="mt-0.5 truncate text-xs font-semibold leading-4" style={{ color: accent }} title={description}>
{description}
</p>
</>
);
if (onClick) {
return (
<button
type="button"
className={className}
style={style}
title={completed ? "已完成 · 点击查看产物" : "点击查看产物"}
onClick={onClick}
onPointerDown={(event) => event.stopPropagation()}
>
{body}
</button>
);
}
return (
<article className={className} style={style}>
{body}
</article>
);
}
@@ -59,6 +59,8 @@ interface SeatDelivery {
}
interface HighFidelityReportProps {
useStep1Chrome?: boolean;
headerTitle?: string;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
/** The most recent leader-agent text — rendered as the final consensus. */
@@ -120,6 +122,8 @@ function formatGeneratedAt(d: Date): string {
}
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
useStep1Chrome = false,
headerTitle,
threadIds,
coordinatorName,
consensusText,
@@ -433,6 +437,8 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
if (summaryMode) {
return (
<Step3SummaryMode
useStep1Chrome={useStep1Chrome}
headerTitle={headerTitle}
threadIds={threadIds}
coordinatorName={coordinatorName}
consensusText={consensusText}
@@ -23,6 +23,9 @@ import { useMemo } from "react";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
export interface Step3PanelProps {
/** 8BF 第三步对话区与第一、二步共用深蓝顶栏;默认不改变原会商。 */
useStep1Chrome?: boolean;
headerTitle?: string;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
consensusText: string;
@@ -60,6 +63,8 @@ export interface Step3PanelProps {
export function Step3Panel(props: Step3PanelProps) {
const {
useStep1Chrome = false,
headerTitle,
threadIds,
coordinatorName,
consensusText,
@@ -108,6 +113,8 @@ export function Step3Panel(props: Step3PanelProps) {
return (
<HighFidelityReport
useStep1Chrome={useStep1Chrome}
headerTitle={headerTitle}
threadIds={threadIds}
coordinatorName={coordinatorName}
consensusText={consensusText}
@@ -59,6 +59,8 @@ interface SeatDelivery {
}
interface Step3SummaryModeProps {
useStep1Chrome?: boolean;
headerTitle?: string;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
consensusText: string;
@@ -81,6 +83,8 @@ interface Step3SummaryModeProps {
type SourceModal = "consensus" | "seats" | "files" | null;
export function Step3SummaryMode({
useStep1Chrome = false,
headerTitle,
threadIds,
coordinatorName,
consensusText,
@@ -208,34 +212,37 @@ export function Step3SummaryMode({
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏 */}
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5">
<Sparkles className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && (
<Loader2 className="w-3 h-3 animate-spin" />
)}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{statusLabel}
</span>
{/* 顶栏。8BF 改走对话区内的第一步同款顶栏,这里不再套一层。 */}
{!useStep1Chrome && (
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5">
<Sparkles className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">方案总结 · 报告</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && (
<Loader2 className="w-3 h-3 animate-spin" />
)}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{statusLabel}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => redraw()}
disabled={phase === "creating" || phase === "streaming"}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新生成
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => redraw()}
disabled={phase === "creating" || phase === "streaming"}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新生成
</button>
</div>
</div>
)}
{/* 主体:左源卡 + 右沙箱 */}
<div className="flex-1 min-h-0 flex gap-3 p-3">
{/* 主体:左源卡 + 右沙箱。8BF 左栏改用第一步任务/意图区,这里只留对话面板。 */}
<div className={cn("flex-1 min-h-0 flex", !useStep1Chrome && "gap-3 p-3")}>
{!useStep1Chrome && (
<aside
data-step3-source-panel
className={cn(
@@ -317,11 +324,16 @@ export function Step3SummaryMode({
</>
)}
</aside>
)}
{/* 右栏:智能体进度 + Markdown 沙箱(复用第 2 步)。 */}
<section
data-step3-report-panel
className="eightbf-roundtable__step3-report-panel relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden"
className={cn(
"relative flex-1 min-w-0 overflow-hidden",
!useStep1Chrome &&
"eightbf-roundtable__step3-report-panel rounded-2xl border border-slate-200/60 bg-white",
)}
>
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
@@ -330,6 +342,8 @@ export function Step3SummaryMode({
taskArtifactSubmission={taskArtifactSubmission}
>
<AgentProgressPanel
useStep1Chrome={useStep1Chrome}
headerTitle={headerTitle}
phase={phase}
dialogues={displayDialogues}
error={error}
@@ -453,6 +467,8 @@ function CollapsedSourceCard({
// ── 右栏:智能体进度面板(沙箱的 60% 区域)+ 底部对话输入框 ──────────────────
function AgentProgressPanel({
useStep1Chrome = false,
headerTitle,
phase,
dialogues,
error,
@@ -468,6 +484,8 @@ function AgentProgressPanel({
setSelectedModel,
taskArtifactSubmission,
}: {
useStep1Chrome?: boolean;
headerTitle?: string;
phase: UseStep3SummaryReturn["phase"];
dialogues: UseStep3SummaryReturn["dialogues"];
error: string | null;
@@ -524,22 +542,53 @@ function AgentProgressPanel({
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
<div className="shrink-0 flex items-center gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
报
<div
className={cn(
"flex h-full min-h-0 flex-col",
useStep1Chrome
? "position-roundtable-intent-panel eightbf-roundtable-intent-panel eightbf-roundtable-step3-chat"
: "bg-slate-50/20",
)}
>
{useStep1Chrome ? (
<div className="shrink-0 flex items-center justify-between gap-3">
<div className="position-roundtable-intent-header-title">
<span aria-hidden="true" />
<h2>{headerTitle || "方案总结"}</h2>
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
<div className="position-roundtable-intent-header-actions">
<button
type="button"
onClick={onRetry}
disabled={phase === "creating" || phase === "streaming"}
>
重新生成
</button>
</div>
</div>
</div>
) : (
<div className="shrink-0 flex items-center gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
报
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">方案总结报告智能体</div>
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
</div>
</div>
</div>
)}
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
<div className="relative flex-1 min-h-0">
<div ref={scrollRef} className="size-full overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
<div
ref={scrollRef}
className={cn(
"size-full overflow-y-auto overflow-x-hidden p-4 space-y-4",
useStep1Chrome ? "eightbf-roundtable-step3-log" : "bg-white",
)}
>
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -193,6 +193,13 @@ export function useTaskArtifactSubmission(
return operation;
}, [config, resolveTarget]);
const submittedRecords = useMemo(
() => Object.values(entries)
.filter((entry) => entry.state === "submitted" && entry.record?.submitted)
.map((entry) => entry.record as RoundtableArtifactSubmissionRecord),
[entries],
);
return useMemo(() => {
if (!config) return undefined;
return {
@@ -202,6 +209,7 @@ export function useTaskArtifactSubmission(
stateFor: (target) => entries[recordKey(resolveTarget(target))]?.state || "idle",
errorFor: (target) => entries[recordKey(resolveTarget(target))]?.error || null,
submit,
submittedRecords,
} satisfies TaskArtifactSubmissionController;
}, [config, entries, resolveTarget, submit]);
}, [config, entries, resolveTarget, submit, submittedRecords]);
}
@@ -0,0 +1,92 @@
import type { RoundtableArtifactSubmissionRecord } from "@/roundtable-planning/api/artifact-submissions";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import type { ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { getDisplayNameFor, type SelectedAgent } from "@/roundtable-planning/lib/roundtable-constants";
function norm(value: string | null | undefined): string {
return (value ?? "").trim().toLowerCase();
}
function sourceKeyOf(record: RoundtableArtifactSubmissionRecord): string {
const raw = (record.artifact.sourceKey ?? "").trim();
if (!raw || raw.toLowerCase().startsWith("thread:")) return "";
return raw;
}
/**
* Match a chain seat to a submitted Markdown artifact.
*
* Submissions are keyed by the graph agent name (`threadIds` key) or UUID;
* chain seats expose both `agent_id` and display `name`.
*/
export function findSeatSubmission(
seat: ChainSeat,
records: RoundtableArtifactSubmissionRecord[],
selectedAgents: SelectedAgent[],
threadIds: ThreadIdMap | null,
): RoundtableArtifactSubmissionRecord | null {
const selected = selectedAgents.find((agent) => agent.agent_id === seat.agent_id);
const aliases = new Set(
[
seat.agent_id,
seat.name,
selected?.agent_id,
selected?.name,
selected ? getDisplayNameFor(selected) : "",
]
.map(norm)
.filter(Boolean),
);
const hits = records.filter((record) => {
if (!record.submitted) return false;
const sourceKey = norm(sourceKeyOf(record));
if (sourceKey && aliases.has(sourceKey)) return true;
if (!threadIds) return false;
const owner = Object.entries(threadIds).find(([, id]) => id === record.artifact.threadId)?.[0];
if (owner && aliases.has(norm(owner))) return true;
for (const alias of aliases) {
const mapped = threadIds[alias];
if (mapped && mapped === record.artifact.threadId) return true;
}
const byName = threadIds[seat.name] ?? (selected ? threadIds[selected.name] : undefined);
return Boolean(byName && byName === record.artifact.threadId);
});
if (hits.length === 0) return null;
return hits
.slice()
.sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? ""))[0] ?? null;
}
/** Identity keys the flowchart can match against `seat.agent_id` / `seat.name`. */
export function submittedSeatKeysFor(
seats: ChainSeat[],
records: RoundtableArtifactSubmissionRecord[],
selectedAgents: SelectedAgent[],
threadIds: ThreadIdMap | null,
): string[] {
const keys = new Set<string>();
for (const record of records) {
if (!record.submitted) continue;
const sourceKey = sourceKeyOf(record);
if (sourceKey) keys.add(norm(sourceKey));
if (threadIds) {
const owner = Object.entries(threadIds).find(([, id]) => id === record.artifact.threadId)?.[0];
if (owner) keys.add(norm(owner));
}
}
for (const seat of seats) {
if (!findSeatSubmission(seat, records, selectedAgents, threadIds)) continue;
keys.add(norm(seat.agent_id));
if (seat.name) keys.add(norm(seat.name));
}
for (const agent of selectedAgents) {
const seat = { agent_id: agent.agent_id, name: agent.name };
if (!findSeatSubmission(seat, records, selectedAgents, threadIds)) continue;
keys.add(norm(agent.agent_id));
keys.add(norm(agent.name));
keys.add(norm(getDisplayNameFor(agent)));
}
return [...keys];
}
@@ -37,4 +37,6 @@ export interface TaskArtifactSubmissionController {
stateFor: (target: TaskArtifactTarget) => TaskArtifactSubmissionState;
errorFor: (target: TaskArtifactTarget) => string | null;
submit: (target: TaskArtifactTarget) => Promise<RoundtableArtifactSubmissionRecord | null>;
/** Task-scoped ledger rows that have been formally submitted. */
submittedRecords: RoundtableArtifactSubmissionRecord[];
}
@@ -135,6 +135,7 @@ import {
import {
buildChainSeatSelection,
type BusinessChain,
type ChainSeat,
type SeatSelectionResult,
} from "@/roundtable-planning/lib/business-chain";
import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
@@ -156,6 +157,14 @@ import { emitWujieEvent, isWujieEnv, onWujieEvent, readWujieProps } from "@/core
import { collectRoundtableDeliverables } from "@/roundtable-planning/lib/roundtable-deliverables";
import { useTaskArtifactSubmission } from "@/roundtable-planning/hooks/useTaskArtifactSubmission";
import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission";
import {
findSeatSubmission,
submittedSeatKeysFor,
} from "@/roundtable-planning/lib/match-seat-submission";
import {
AnalysisResultDetailDialog,
type AnalysisResultCardItem,
} from "@/components/analysis-results";
/**
* 业务码 → 任务工作区业务码(``TaskBusiness``),用于深链 taskId 模式底部「快捷跳转」栏
@@ -320,6 +329,7 @@ export default function RoundtablePlanningPage({
hideDefaultStep1RightSidebar = false,
step2HeaderTitle,
step2HeaderRightSlot,
step3HeaderTitle,
/** 独立业务页的工作区品牌区,取代通用步骤导航。 */
headerLeftSlot,
/** 放在历史记录后面的独立业务操作或状态。 */
@@ -353,7 +363,12 @@ export default function RoundtablePlanningPage({
step2SeatStubMessages: Record<string, unknown[]>;
step3Summary: DraftStep3SummarySnapshot | null;
}) => ReactNode;
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode;
renderStep1LeftSidebar?: (context: {
intentReady: object | null;
onCollapse?: () => void;
submittedSeatKeys?: string[];
onSeatClick?: (seat: ChainSeat) => void;
}) => ReactNode;
renderStep2LeftSidebarHeader?: (context: {
intentReady: object | null;
onCollapse: () => void;
@@ -363,6 +378,8 @@ export default function RoundtablePlanningPage({
step2HeaderTitle?: string;
/** 8BF 第二步顶栏右侧附加操作(如返回)。 */
step2HeaderRightSlot?: ReactNode;
/** 8BF 第三步对话区标题,与第一、二步顶栏同一套样式。 */
step3HeaderTitle?: string;
headerLeftSlot?: ReactNode;
headerActionAfterHistory?: ReactNode;
hideHeaderSaveDraft?: boolean;
@@ -482,11 +499,11 @@ export default function RoundtablePlanningPage({
consensusEmittedRef.current = true;
}
}, [inWujieEmbed]);
// 带 taskId 时,先按任务回显最新草稿再决定是否 autoStart。用 state 而非 ref:
// 带存储键 / 深链 taskId 时,先回显最新草稿再决定是否 autoStart。用 state 而非 ref:
// 解析完成需触发重渲染让 C2 effect 重跑(否则 intentThreadId 先就绪会错过 autoStart)。
// 无 taskId 时初始即「已解析」,autoStart 照常。
// 无存储键时初始即「已解析」,autoStart 照常。8BF 走 storageTaskId,不依赖 prefill.taskId。
const [taskRestoreResolved, setTaskRestoreResolved] = useState<boolean>(
!prefillRef.current?.taskId,
!draftStorageTaskId,
);
// 深链/预填自动会话:进入页面后要先建意图线程、(带 taskId 时)回显历史草稿,这段时间
// 页面只有一句欢迎语、问答还没真正开始,线程创建慢时像卡住。下面这个标志用来在超时后
@@ -742,6 +759,58 @@ export default function RoundtablePlanningPage({
step2.agentNameToRole,
);
const [seatDeliverable, setSeatDeliverable] = useState<AnalysisResultCardItem | null>(null);
const submittedRecords = taskArtifactSubmission?.submittedRecords;
const submittedSeatKeys = useMemo(
() => submittedSeatKeysFor(
step2.selectedAgents.map((agent) => ({ agent_id: agent.agent_id, name: agent.name })),
submittedRecords ?? [],
step2.selectedAgents,
step2.threadIds,
),
[submittedRecords, step2.selectedAgents, step2.threadIds],
);
const handleChainSeatClick = useCallback((seat: ChainSeat) => {
const record = findSeatSubmission(
seat,
submittedRecords ?? [],
step2.selectedAgents,
step2.threadIds,
);
if (!record) {
triggerToast("该节点暂无可预览产物");
return;
}
setSeatDeliverable({
id: record.id,
agentName: seat.name || record.artifact.sourceKey,
productName: record.artifact.name,
positionName: seat.description?.trim() || autoChainResult?.chain?.title || "业务链席位",
delivered: true,
artifactPath: record.artifact.path,
artifactMimeType: record.artifact.mimeType,
artifactThreadId: record.artifact.threadId,
updatedAt: record.submittedAt ?? undefined,
});
}, [
autoChainResult?.chain?.title,
step2.selectedAgents,
step2.threadIds,
submittedRecords,
triggerToast,
]);
const renderStep1LeftSidebarWithDeliveries = useMemo(
() => (renderStep1LeftSidebar
? (context: { intentReady: object | null; onCollapse?: () => void }) =>
renderStep1LeftSidebar({
...context,
submittedSeatKeys,
onSeatClick: taskArtifactSubmission ? handleChainSeatClick : undefined,
})
: undefined),
[handleChainSeatClick, renderStep1LeftSidebar, submittedSeatKeys, taskArtifactSubmission],
);
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
useEffect(() => {
step1StateRef.current = {
@@ -1287,11 +1356,11 @@ export default function RoundtablePlanningPage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// C1.5(无界回显):带 taskId 时,挂载即按任务取最新草稿回显。命中 → 抑制 autoStart
// C1.5(无界 / 8BF 回显):带存储键时,挂载即取最新草稿回显。命中 → 抑制 autoStart
// (避免重复发首条消息);未命中 → 放行 autoStart 走全新任务。只跑一次。
useEffect(() => {
// 回显聊天记录按存储键(xdfx-A=行动id),与 useDraftPersistence 的 taskId 保持一致。
const tid = recordTaskIdOf(prefillRef.current);
// 回显聊天记录按存储键:8BF 用独立 `storageTaskId`;xdfx-A 按行动id;其它深链按真实 taskId。
const tid = draftStorageTaskId;
if (!tid) return;
let cancelled = false;
void (async () => {
@@ -2218,6 +2287,15 @@ export default function RoundtablePlanningPage({
}}
/>
{taskArtifactSubmission && (
<AnalysisResultDetailDialog
item={seatDeliverable}
onOpenChange={(open) => {
if (!open) setSeatDeliverable(null);
}}
/>
)}
{showToast && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
<Sparkles className="h-4 w-4 shrink-0" />
@@ -2371,7 +2449,9 @@ export default function RoundtablePlanningPage({
setSidebarExpanded={setStep2SidebarExpanded}
/>
<main
data-step2-sidebar-expanded={currentStep === 2 ? String(step2SidebarExpanded) : undefined}
data-step2-sidebar-expanded={
currentStep === 2 || currentStep === 3 ? String(step2SidebarExpanded) : undefined
}
className="relative z-0 flex-1 overflow-hidden p-4 flex gap-4 min-h-0"
>
{/* LEFT SIDEBAR */}
@@ -2399,7 +2479,7 @@ export default function RoundtablePlanningPage({
lockBusinessChain={lockBusinessChain}
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
entryRailTaskCardSlot={entryRailTaskCardSlot}
renderStep1LeftSidebar={renderStep1LeftSidebar}
renderStep1LeftSidebar={renderStep1LeftSidebarWithDeliveries}
renderStep2LeftSidebarHeader={renderStep2LeftSidebarHeader}
/>
@@ -2496,6 +2576,8 @@ export default function RoundtablePlanningPage({
{currentStep === 3 && (
<Step3Panel
useStep1Chrome={rootClassName?.includes("eightbf-roundtable") ?? false}
headerTitle={step3HeaderTitle}
threadIds={step2.threadIds}
coordinatorName={step2.coordinatorName}
consensusText={step2.lastLeaderContent}
@@ -2933,7 +3015,12 @@ interface LeftSidebarProps {
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
entryRailTaskCardSlot?: ReactNode;
/** 独立业务页替换第一步左侧工作区;仅提供已解析的意图状态。 */
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode;
renderStep1LeftSidebar?: (context: {
intentReady: object | null;
onCollapse?: () => void;
submittedSeatKeys?: string[];
onSeatClick?: (seat: ChainSeat) => void;
}) => ReactNode;
/** 独立业务页在第二步左栏顶部复用任务与意图区。 */
renderStep2LeftSidebarHeader?: (context: {
intentReady: object | null;
@@ -2952,13 +3039,21 @@ function Step2SandboxSidebarSync({
}) {
const { open } = useArtifacts();
const prevOpenRef = useRef(open);
const prevStepRef = useRef(currentStep);
useEffect(() => {
if (currentStep !== 2) {
const enteredCollapsibleStep =
(currentStep === 2 || currentStep === 3) &&
prevStepRef.current !== currentStep;
prevStepRef.current = currentStep;
if (currentStep !== 2 && currentStep !== 3) {
prevOpenRef.current = open;
return;
}
if (open && !prevOpenRef.current && sidebarExpanded) {
const openedNow = open && !prevOpenRef.current;
const landedWithSandbox = enteredCollapsibleStep && open;
if ((openedNow || landedWithSandbox) && sidebarExpanded) {
setSidebarExpanded(false);
}
prevOpenRef.current = open;
@@ -3002,9 +3097,38 @@ function LeftSidebar({
const toggleSeatFilter = (filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter));
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
if (currentStep === 3) return null;
// Step 3:通用会商隐藏左栏以让方案展示撑满;8BF 复用第一步左栏,并和第二步一样可收起。
if (currentStep === 3) {
if (!renderStep1LeftSidebar) return null;
return (
<section
ref={dzRef}
id="left-sidebar"
data-sidebar-expanded={String(sidebarExpanded)}
className={cn(
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out overflow-y-auto",
sidebarExpanded ? "w-[280px]" : "w-[80px]",
)}
>
{!sidebarExpanded ? (
<button
type="button"
onClick={() => setSidebarExpanded(true)}
title="展开侧栏"
aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
) : (
renderStep1LeftSidebar({
intentReady,
onCollapse: () => setSidebarExpanded(false),
})
)}
</section>
);
}
return (
<section
@@ -36,6 +36,27 @@ EDITABLE_TEXT_SUFFIXES = {
".log",
}
_VIRTUAL_USER_DATA_PREFIX = "mnt/user-data"
def coerce_artifact_virtual_path(path: str) -> str:
"""Normalize an artifacts URL path to a sandbox virtual path.
Streaming write previews may request a bare filename such as
``未命名文件.md`` before the model has emitted a real
``/mnt/user-data/outputs/...`` path. Mapping a single path segment into
the thread outputs directory turns that into a normal 404 (file not written
yet) instead of HTTP 400 ``Path must start with /mnt/user-data``.
"""
stripped = path.strip().lstrip("/").replace("\\", "/")
if not stripped:
raise HTTPException(status_code=400, detail="Path must start with /mnt/user-data")
if stripped == _VIRTUAL_USER_DATA_PREFIX or stripped.startswith(_VIRTUAL_USER_DATA_PREFIX + "/"):
return stripped
if "/" in stripped or stripped in {".", ".."}:
raise HTTPException(status_code=400, detail="Path must start with /mnt/user-data")
return f"{_VIRTUAL_USER_DATA_PREFIX}/outputs/{stripped}"
class ArtifactUpdateRequest(BaseModel):
content: str
@@ -198,6 +219,7 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
- Download file: `/api/threads/abc123/artifacts/mnt/user-data/outputs/data.csv?download=true`
- Active web content such as `.html`, `.xhtml`, and `.svg` artifacts is always downloaded
"""
path = coerce_artifact_virtual_path(path)
# Check if this is a request for a file inside a .skill archive (e.g., xxx.skill/SKILL.md)
if ".skill/" in path:
# Split the path at ".skill/" to get the ZIP file path and internal path
@@ -285,6 +307,7 @@ async def update_artifact(
if ".skill/" in path:
raise HTTPException(status_code=400, detail="Skill archive contents are read-only.")
path = coerce_artifact_virtual_path(path)
actual_path = await aresolve_thread_virtual_path(thread_id, path)
logger.info(f"Updating artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")