feat: 完善会商产物提交匹配与流式写文件预览
席位流程图按已提交 Markdown 对齐;流式未命名产物不再误请求 artifacts API,并兼容临时文件名路径。同时补强会话流中断识别与 8BF/岗位会商展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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) => (
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
Reference in new issue
Block a user