增强文档改写配图与深度研究流式稳定性,并完善 8BF/会商工作区。

全文改写支持可选配图与思考过程展示,深度研究接入 DashScope 原生出图并剥离内联 think 标签;8BF 会商补齐第一步工作区与任务卡回退,同时限制热重载扫描范围。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-18 17:46:11 +08:00
1 parent 819e537aa3
commit 15a46b903a
48 files changed
+3724 -616

No files matched your search

@@ -19,8 +19,9 @@ import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import { toast } from "sonner";
import { defaultUrlTransform } from "react-markdown";
import { Streamdown } from "streamdown";
import {
Artifact,
@@ -42,6 +43,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
import { useCapabilities as useResearchCapabilities } from "@/strategy-components/api/deep-research";
import {
listDurableDocumentRewrites,
streamDurableDocumentRewriteJob,
@@ -149,9 +151,11 @@ export function ArtifactFileDetail({
onClose,
allowVirtualMarkdownEdit = false,
disableDesensitize = false,
hideFileName = false,
onDownloadClick,
}: {
hideFileName = false,
onDownloadClick,
toolbarActions,
researchSessionId,
}: {
className?: string;
filepath: string;
threadId: string;
@@ -177,8 +181,15 @@ export function ArtifactFileDetail({
* (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的
* 「另存为 Word」。默认不传 —— 其它调用方行为不变。
*/
onDownloadClick?: () => void;
}) {
onDownloadClick?: () => void;
/** Deep-research reports use protected `deep-research://` image references. */
researchSessionId?: string;
/**
* 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它
* 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。
*/
toolbarActions?: ReactNode;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
@@ -291,13 +302,20 @@ export function ArtifactFileDetail({
const updateArtifact = useUpdateArtifactContent();
const queryClient = useQueryClient();
const documentRewrite = useDocumentRewrite();
const imageCapabilities = useResearchCapabilities();
const rewritePresentation = useDocumentRewritePresentation();
const hydratedRewriteScopeRef = useRef<string | null>(null);
const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({
presentationId: null,
plan: "",
});
const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({
presentationId: null,
thinking: "",
});
const documentRewriteActive = documentRewrite.isActive;
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
@@ -405,6 +423,29 @@ export function ArtifactFileDetail({
writtenCharCount: event.charCount,
});
break;
case "illustration_started":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_generated":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
generatedImageCount: event.generatedCount ?? event.position,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_completed":
rewritePresentation.update(presentationId, {
stage: "illustrating",
generatedImageCount: event.generatedCount,
maxGeneratedImages: event.requestedCount || 1,
warnings: event.warnings,
});
break;
case "validation_started":
rewritePresentation.update(presentationId, { stage: "validation" });
break;
@@ -463,6 +504,8 @@ export function ArtifactFileDetail({
instruction: historical.request.instruction,
style: historical.request.style,
modelName: historical.request.modelName,
generateImages: historical.request.generateImages,
maxGeneratedImages: historical.request.maxGeneratedImages,
originalContent: historical.request.originalContent,
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
@@ -497,6 +540,8 @@ export function ArtifactFileDetail({
instruction: latest.request.instruction,
style: latest.request.style,
modelName: latest.request.modelName,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
originalContent,
rewrittenContent,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
@@ -537,6 +582,8 @@ export function ArtifactFileDetail({
instruction: latest.request.instruction,
style: latest.request.style,
modelName: latest.request.modelName ?? undefined,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
},
{
jobId: latest.id,
@@ -605,6 +652,19 @@ export function ArtifactFileDetail({
rewritePresentation,
]);
// Thoughts use the same rAF coalescing as the draft, then are mirrored once
// per browser frame into the regular message-list card. They never touch
// the artifact Markdown or the durable rewrite checkpoint.
useEffect(() => {
const presentationId = documentRewritePresentationId;
const thinking = documentRewrite.run.thinkingText;
if (!presentationId || !rewritePresentation || !thinking) return;
const previous = mirroredThinkingTextRef.current;
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
mirroredThinkingTextRef.current = { presentationId, thinking };
rewritePresentation.update(presentationId, { thinkingText: thinking });
}, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]);
useEffect(() => {
const handleRewriteUndo = (event: Event) => {
const detail = (event as CustomEvent<{
@@ -851,6 +911,8 @@ export function ArtifactFileDetail({
instruction: string;
style: "formal_analysis" | "professional" | "concise" | "readable";
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
}) => {
if (!canUseFullDocumentRewrite) {
toast.error(
@@ -872,6 +934,8 @@ export function ArtifactFileDetail({
instruction: input.instruction,
style: input.style,
modelName: input.modelName,
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
originalContent: draftContent,
});
setDocumentRewritePresentationId(presentationId ?? null);
@@ -882,6 +946,8 @@ export function ArtifactFileDetail({
instruction: input.instruction,
style: input.style,
modelName: input.modelName ?? undefined,
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
expectedHash,
},
{
@@ -921,6 +987,29 @@ export function ArtifactFileDetail({
writtenCharCount: event.charCount,
});
break;
case "illustration_started":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_generated":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
generatedImageCount: event.generatedCount ?? event.position,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_completed":
rewritePresentation.update(presentationId, {
stage: "illustrating",
generatedImageCount: event.generatedCount,
maxGeneratedImages: event.requestedCount || 1,
warnings: event.warnings,
});
break;
case "validation_started":
rewritePresentation.update(presentationId, { stage: "validation" });
break;
@@ -1217,6 +1306,7 @@ export function ArtifactFileDetail({
onClick={handleSaveArtifact}
/>
)}
{toolbarActions}
{canEditArtifact && (
<ArtifactAction
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
@@ -1394,9 +1484,10 @@ export function ArtifactFileDetail({
<ArtifactFilePreview
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
language={language ?? "text"}
threadId={threadId}
publicShareCode={publicShareCode}
animated={showPreviewSwitchPulse}
threadId={threadId}
publicShareCode={publicShareCode}
researchSessionId={researchSessionId}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
@@ -1454,25 +1545,40 @@ export function ArtifactFileDetail({
onOpenChange={setDocumentRewriteDialogOpen}
fileName={getFileName(filepath)}
charCount={(displayContent ?? "").replace(/\s/g, "").length}
imageGenerationAvailable={imageGenerationAvailable}
maxGeneratedImages={maxGeneratedImages}
onStart={handleStartDocumentRewrite}
/>
</Artifact>
);
}
function SandboxPreviewImage({
src,
alt,
threadId,
publicShareCode,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
publicShareCode?: string;
}) {
if (!src || typeof src !== "string") {
return null;
}
function SandboxPreviewImage({
src,
alt,
threadId,
publicShareCode,
researchSessionId,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
publicShareCode?: string;
researchSessionId?: string;
}) {
if (!src || typeof src !== "string") {
return null;
}
if (src.startsWith("deep-research://")) {
return researchSessionId ? (
<ProtectedResearchReportImage
{...props}
alt={alt}
sessionId={researchSessionId}
src={src}
/>
) : null;
}
let url = src;
if (src.startsWith("/mnt/")) {
@@ -1496,21 +1602,108 @@ function SandboxPreviewImage({
src={url}
alt={alt}
/>
);
}
);
}
/** Fetches a private deep-research image with the normal auth client. */
function ProtectedResearchReportImage({
sessionId,
src,
alt,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
sessionId: string;
}) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
setObjectUrl(null);
setLoadFailed(false);
if (!src?.startsWith("deep-research://")) {
return;
}
const name = src.slice("deep-research://".length);
if (!name) {
setLoadFailed(true);
return;
}
const controller = new AbortController();
let nextUrl: string | null = null;
let disposed = false;
void (async () => {
try {
const response = await apiFetch(
`${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`,
{ method: "GET", signal: controller.signal },
);
if (!response.ok) {
throw new Error(`Unable to load report image (${response.status})`);
}
const blob = await response.blob();
if (!blob.size) {
throw new Error("Report image is empty");
}
nextUrl = URL.createObjectURL(blob);
if (disposed) return;
setObjectUrl(nextUrl);
} catch (error) {
if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) {
// Never let an optional illustration break Markdown rendering, but don't
// leave the user with a misleading infinite loading label either.
setLoadFailed(true);
}
}
})();
return () => {
disposed = true;
controller.abort();
if (nextUrl) URL.revokeObjectURL(nextUrl);
};
}, [retryKey, sessionId, src]);
if (!objectUrl) {
if (loadFailed) {
return (
<span className="my-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-300">
报告配图加载失败
<button
type="button"
className="font-medium underline underline-offset-2 hover:opacity-80"
onClick={() => setRetryKey((value) => value + 1)}
>
重试
</button>
</span>
);
}
return <span className="block text-xs text-muted-foreground">正在加载报告配图…</span>;
}
return (
<img
{...props}
alt={alt || "研究报告配图"}
className={cn("my-3 max-w-full overflow-hidden rounded-lg border", props.className)}
src={objectUrl}
/>
);
}
export function ArtifactFilePreview({
content,
language,
threadId,
publicShareCode,
animated = false,
threadId,
publicShareCode,
researchSessionId,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
threadId: string;
publicShareCode?: string;
threadId: string;
publicShareCode?: string;
researchSessionId?: string;
animated?: boolean;
activeCursor?: boolean;
}) {
@@ -1574,7 +1767,7 @@ export function ArtifactFilePreview({
);
},
};
}, [content]);
}, [content, publicShareCode, researchSessionId, threadId]);
useEffect(() => {
if (language !== "html") {
@@ -1639,16 +1832,26 @@ export function ArtifactFilePreview({
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={{
<Streamdown
className="size-full"
{...streamdownPlugins}
// react-markdown's safe default strips unknown protocols before the
// custom `img` renderer sees them. Deep-research images use this
// private pseudo URL and are then fetched through the authenticated
// artifact endpoint in `SandboxPreviewImage`.
urlTransform={(url) =>
url.startsWith("deep-research://")
? url
: defaultUrlTransform(url)
}
components={{
...markdownComponents,
img: (props) => (
<SandboxPreviewImage
{...props}
threadId={threadId}
publicShareCode={publicShareCode}
threadId={threadId}
publicShareCode={publicShareCode}
researchSessionId={researchSessionId}
/>
),
}}
@@ -1,6 +1,6 @@
"use client";
import { SparklesIcon } from "lucide-react";
import { ImagePlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
@@ -51,16 +52,22 @@ export function DocumentRewriteDialog({
onOpenChange,
fileName,
charCount,
imageGenerationAvailable = false,
maxGeneratedImages = 4,
onStart,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
charCount: number;
imageGenerationAvailable?: boolean;
maxGeneratedImages?: number;
onStart: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
}) => void;
}) {
const [instruction, setInstruction] = useState(
@@ -68,11 +75,15 @@ export function DocumentRewriteDialog({
);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
useEffect(() => {
if (!open) return;
setStyle("formal_analysis");
setModelName(null);
setGenerateImages(false);
setImageCount("2");
}, [open]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
@@ -136,6 +147,48 @@ export function DocumentRewriteDialog({
</label>
</div>
{imageGenerationAvailable && (
<div className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-4">
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<ImagePlusIcon className="size-4" />
</span>
<span className="grid gap-0.5">
<span className="text-sm font-medium">改写后补充配图</span>
<span className="text-muted-foreground text-xs leading-5">
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
</span>
</span>
</label>
<Switch
aria-label="改写后补充配图"
checked={generateImages}
id="document-rewrite-images"
onCheckedChange={setGenerateImages}
/>
</div>
{generateImages && (
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
<label className="flex items-center gap-2 text-sm">
配图数量
<Select value={imageCount} onValueChange={setImageCount}>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
@@ -144,7 +197,16 @@ export function DocumentRewriteDialog({
disabled={!canStart}
onClick={() => {
if (!canStart) return;
onStart({ instruction: instruction.trim(), style, modelName });
onStart({
instruction: instruction.trim(),
style,
modelName,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
Math.max(Math.min(maxGeneratedImages, 4), 1),
),
});
}}
>
<SparklesIcon />
@@ -2,15 +2,22 @@
import {
CheckCircle2Icon,
ChevronDownIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
Undo2Icon,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { apiFetch } from "@/core/api/fetch-client";
import {
DOCUMENT_REWRITE_UNDONE_EVENT,
@@ -26,6 +33,7 @@ const STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
] as const;
@@ -38,15 +46,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
return 1;
case "writing":
return 2;
case "validation":
case "illustrating":
return 3;
case "validation":
return 4;
case "committing":
case "completed":
return 5;
return 6;
case "conflict":
case "error":
case "cancelled":
return 4;
return 5;
default:
return -1;
}
@@ -56,6 +66,42 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
return ["completed", "conflict", "error", "cancelled"].includes(stage);
}
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
const [open, setOpen] = useState(active);
useEffect(() => {
if (active && text) setOpen(true);
}, [active, text]);
if (!text.trim()) return null;
return (
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
<CollapsibleTrigger asChild>
<Button
aria-label={open ? "收起模型思考" : "展开模型思考"}
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", open && "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">
{text}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
export function DocumentRewriteTimeline({
item,
}: {
@@ -64,6 +110,9 @@ export function DocumentRewriteTimeline({
const current = stepPosition(item.stage);
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
const completed = item.stage === "completed";
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
? "requirements"
: "writing";
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
@@ -148,7 +197,13 @@ export function DocumentRewriteTimeline({
<div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => {
const active = !isTerminal(item.stage) && index === current;
const done = completed || index < current;
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
const illustrationFailed =
step.key === "illustrations" &&
item.generateImages &&
completed &&
item.generatedImageCount === 0;
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
return (
<div className="flex items-start gap-3" key={step.key}>
<div
@@ -200,6 +255,30 @@ export function DocumentRewriteTimeline({
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
</p>
)}
{step.key === "illustrations" && (
illustrationSkipped ? (
<p className="text-muted-foreground mt-0.5 text-xs">
本次未启用配图,保留原文已有图片与链接。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{item.illustrationTarget
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
</p>
) : item.generatedImageCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
已生成并保存 {item.generatedImageCount} 张本地配图。
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
未成功生成配图,文字改写结果仍已保留。
</p>
)
)}
{step.key === thinkingStep && (
<RewriteThinkingTrace active={active} text={item.thinkingText} />
)}
{step.key === "validation" && item.warnings.length > 0 && (
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
{item.warnings.join(";")}
@@ -228,6 +307,11 @@ export function DocumentRewriteTimeline({
本次优化:{item.summary.requestedOptimizations.join("、")}。
</p>
) : null}
{item.summary.requestedImageCount ? (
<p className="text-muted-foreground">
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
</p>
) : null}
{item.summary.headingChanges && (
item.summary.headingChanges.reordered ||
item.summary.headingChanges.added.length > 0 ||
@@ -523,7 +523,13 @@ function DeepResearchProgressStep({
? 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
@@ -546,6 +552,32 @@ function DeepResearchProgressStep({
{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>
);
}
@@ -20,11 +20,17 @@ export type DocumentRewritePresentation = {
instruction: string;
style: string;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
generatedImageCount: number;
illustrationTarget: string;
stage: DocumentRewriteStage;
originalCharCount: number;
writtenCharCount: number;
/** Short model-generated plan, streamed while it understands the request. */
requirementsPlan: string;
/** Ephemeral thought trace. Never included in the Markdown draft or persisted job state. */
thinkingText: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
@@ -43,9 +49,14 @@ type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "generateImages"
| "maxGeneratedImages"
| "generatedImageCount"
| "illustrationTarget"
| "originalCharCount"
| "writtenCharCount"
| "requirementsPlan"
| "thinkingText"
| "warnings"
| "summary"
| "originalContent"
@@ -60,6 +71,8 @@ type CreatePresentationInput = Omit<
originalContent?: string;
rewrittenContent?: string | null;
requirementsPlan?: string;
generateImages?: boolean;
maxGeneratedImages?: number;
};
type DocumentRewritePresentationContextValue = {
@@ -94,9 +107,14 @@ export function DocumentRewritePresentationProvider({
...input,
id,
stage: "snapshot",
generateImages: input.generateImages === true,
maxGeneratedImages: Math.min(Math.max(input.maxGeneratedImages ?? 2, 1), 4),
generatedImageCount: 0,
illustrationTarget: "",
originalCharCount: 0,
writtenCharCount: 0,
requirementsPlan: input.requirementsPlan ?? "",
thinkingText: "",
warnings: [],
summary: null,
originalContent: input.originalContent ?? "",
@@ -0,0 +1,90 @@
/**
* Some OpenAI-compatible reasoning models write ``<think>...</think>`` into
* the ordinary text stream rather than a dedicated reasoning field. Markdown
* sandboxes must never render those fragments as document content. Callers
* can separately render the returned thinking part in a labelled UI trace.
*
* The filter is stateful because either tag can be split across arbitrary SSE
* deltas. Hold only a possible tag suffix, so confirmed Markdown continues
* to appear with the normal token-streaming cadence.
*/
export class InlineThinkTagFilter {
private static readonly OPEN_TAG = "<think>";
private static readonly CLOSE_TAG = "</think>";
private insideThink = false;
private pending = "";
private possibleTagPrefixSize(content: string, tag: string) {
const lowered = content.toLowerCase();
for (let size = Math.min(content.length, tag.length - 1); size > 0; size -= 1) {
if (lowered.slice(-size) === tag.slice(0, size)) return size;
}
return 0;
}
pushParts(chunk: string): { text: string; thinking: string } {
if (!chunk) return { text: "", thinking: "" };
let remaining = this.pending + chunk;
this.pending = "";
let visible = "";
let thinking = "";
while (remaining) {
const tag = this.insideThink
? InlineThinkTagFilter.CLOSE_TAG
: InlineThinkTagFilter.OPEN_TAG;
const tagIndex = remaining.toLowerCase().indexOf(tag);
if (tagIndex >= 0) {
if (this.insideThink) thinking += remaining.slice(0, tagIndex);
else visible += remaining.slice(0, tagIndex);
remaining = remaining.slice(tagIndex + tag.length);
this.insideThink = !this.insideThink;
continue;
}
const suffixSize = this.possibleTagPrefixSize(remaining, tag);
const confirmed = suffixSize ? remaining.slice(0, -suffixSize) : remaining;
if (this.insideThink) thinking += confirmed;
else visible += confirmed;
this.pending = suffixSize ? remaining.slice(-suffixSize) : "";
break;
}
return { text: visible, thinking };
}
push(chunk: string) {
return this.pushParts(chunk).text;
}
/** Flush a completed stream into its visible/thinking channels. */
finishParts(): { text: string; thinking: string } {
if (this.insideThink) {
const thinking = this.pending;
this.insideThink = false;
this.pending = "";
return { text: "", thinking };
}
const tail = this.pending;
this.pending = "";
return { text: tail, thinking: "" };
}
finish() {
return this.finishParts().text;
}
/** Replace a rendered checkpoint while retaining the correct tag state. */
replace(content: string) {
this.insideThink = false;
this.pending = "";
return this.push(content);
}
}
export function stripInlineThinkTags(content: string) {
const filter = new InlineThinkTagFilter();
return filter.push(content) + filter.finish();
}
@@ -51,6 +51,27 @@ export interface EightBfTaskContextPayload {
};
}
/**
* 外部任务系统在离线部署或本地联调时可能不可用。此回退仅用于页面展示与
* 意图上下文,保持与岗位会商示例一致,避免左上任务卡退化成接口错误文本。
*/
export function createEightBfFallbackTask(taskId: string): EightBfTask {
return {
id: taskId,
externalTaskId: taskId,
title: "暑期活动",
description: "围绕暑期活动的游泳夏令营开展宣传,结合线上线下渠道吸引目标人群参与,持续提升活动影响力。",
direction: "暑期活动",
overview: "面向家长群体传播暑期游泳夏令营活动信息,提升品牌曝光与触达声量。",
priority: "urgent",
taskPriority: "urgent",
targetDirection: "summer_activity",
taskStatus: "system_created",
source: "本系统创建",
raw: null,
};
}
const TASK_DETAIL_PATH = "/task/{taskId}";
const DICTIONARY_TYPES = {
priority: "task_priority",
@@ -0,0 +1,451 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
Bot,
ChevronDown,
ChevronLeft,
Loader2,
Search,
Settings2,
UserRoundCog,
} from "lucide-react";
import { toast } from "sonner";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { useAgent, useAgents, type Agent } from "@/core/agents";
import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { cn } from "@/lib/utils";
type IntentRecord = Record<string, unknown>;
export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
}
export function EightBfStep1Panel({
chain,
canConfigureChain,
onConfigureChain,
selectedModel,
modelOptions,
onSelectedModelChange,
intentReady,
...step1Props
}: EightBfStep1PanelProps) {
const [rightPanelOpen, setRightPanelOpen] = useState(true);
const [rightTab, setRightTab] = useState<"agents" | "results">("agents");
const openPanel = (tab: "agents" | "results") => {
if (rightPanelOpen && rightTab === tab) {
setRightPanelOpen(false);
return;
}
setRightTab(tab);
setRightPanelOpen(true);
};
return (
<div className="position-roundtable-intent-page eightbf-roundtable-intent-page contents">
<Step1Panel
{...step1Props}
rootClassName="position-roundtable-intent-panel eightbf-roundtable-intent-panel [&_#save-draft-btn]:hidden [&_#skip-question-btn]:hidden [&_#suggest-followup-btn]:hidden"
headerLeftSlot={
<div className="position-roundtable-intent-header-title">
<span aria-hidden="true" />
<h2>{chain?.title || "8BF 业务链"} · 意图识别</h2>
</div>
}
headerRightSlot={
<div className="position-roundtable-intent-header-actions">
<button
type="button"
className={cn(rightPanelOpen && rightTab === "agents" && "is-selected")}
onClick={() => openPanel("agents")}
>
智能体配置
</button>
<button
type="button"
className={cn(rightPanelOpen && rightTab === "results" && "is-selected")}
onClick={() => openPanel("results")}
>
分析结果
</button>
</div>
}
rightSidebar={
<aside
aria-hidden={!rightPanelOpen || undefined}
className={cn(
"position-roundtable-intent-config-pane eightbf-roundtable-intent-config-pane",
rightPanelOpen ? "is-open" : "is-closed",
)}
>
<div className="position-roundtable-intent-config-pane-inner">
<EightBfAgentWorkspace
tab={rightTab}
chain={chain}
intentReady={intentReady}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onCollapse={() => setRightPanelOpen(false)}
/>
</div>
</aside>
}
hideQuestionCount
inputArea={(inputProps) => (
<PositionIntentInput
{...inputProps}
modelOptions={modelOptions}
selectedModel={selectedModel}
onSelectedModelChange={onSelectedModelChange}
/>
)}
intentReady={intentReady}
/>
</div>
);
}
export function EightBfStep1Sidebar({
taskCard,
businessChain,
intentReady,
}: {
taskCard?: ReactNode;
businessChain?: ReactNode;
intentReady: object | null;
}) {
return (
<div className="eightbf-roundtable__step1-sidebar position-roundtable-entry-sidebar">
{taskCard}
<EightBfIntentDelivery intentReady={intentReady} businessChain={businessChain} />
</div>
);
}
/** 第一、二步共用的意图结果面板,保持完全相同的展示结构和样式。 */
export function EightBfIntentDelivery({
intentReady,
businessChain,
}: {
intentReady: object | null;
businessChain?: ReactNode;
}) {
const sections = intentSections(intentReady);
return (
<section className="eightbf-roundtable__intent-delivery-sidebar position-roundtable-intent-delivery-sidebar" aria-labelledby="eightbf-intent-sidebar-title">
{sections.length > 0 && (
<div className="position-roundtable-intent-delivery-result">
<header className="position-roundtable-intent-delivery-header">
<span aria-hidden="true" />
<h2 id="eightbf-intent-sidebar-title">意图理解结果</h2>
</header>
<div className="position-roundtable-intent-delivery-sections">
{sections.map((section) => (
<article
key={section.label}
className={cn(
"position-roundtable-intent-delivery-section",
intentSectionTone(section.label),
)}
>
<h3>{section.label}</h3>
<ul>
{section.items.map((item) => <li key={item}>{item}</li>)}
</ul>
</article>
))}
</div>
</div>
)}
{businessChain}
</section>
);
}
/** 第二步不需要第一步的整列占位,只复用其任务卡和意图面板。 */
export function EightBfStep2SidebarHeader({
taskCard,
intentReady,
}: {
taskCard?: ReactNode;
intentReady: object | null;
}) {
return (
<div className="eightbf-roundtable__step2-sidebar-header">
{taskCard}
<EightBfIntentDelivery intentReady={intentReady} />
</div>
);
}
export function EightBfHeaderBrand({ chainTitle }: { chainTitle?: string }) {
return (
<div className="eightbf-roundtable__header-brand">
<span className="eightbf-roundtable__header-brand-icon" aria-hidden="true">
<UserRoundCog />
</span>
<div className="min-w-0">
<h1>8BF 多智能体会商</h1>
<p>按业务链承接任务、对话与分析产物</p>
</div>
<span className="eightbf-roundtable__header-status">
<span aria-hidden="true">⌁</span>
{chainTitle ? "业务链已启动" : "等待业务链配置"}
</span>
</div>
);
}
export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) {
return (
<span className="eightbf-roundtable__header-chain" title={chainTitle || "尚未配置业务链"}>
<span>当前业务链</span>
<strong>{chainTitle || "暂未配置"}</strong>
<ChevronDown aria-hidden="true" />
</span>
);
}
function EightBfAgentWorkspace({
tab,
chain,
intentReady,
canConfigureChain,
onConfigureChain,
onCollapse,
}: {
tab: "agents" | "results";
chain: BusinessChain | null;
intentReady: object | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
onCollapse: () => void;
}) {
const { agents, isLoading } = useAgents();
const seats = chain?.seats ?? [];
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { agent: selectedAgent, isLoading: selectedAgentLoading } = useAgent(selectedAgentId);
useEffect(() => {
if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) {
setSelectedAgentId(null);
}
}, [seats, selectedAgentId]);
const seatAgents = useMemo(() => {
const agentById = new Map(agents.map((agent) => [agent.id, agent]));
return seats.map((seat) => ({ seat, agent: agentById.get(seat.agent_id) ?? null }));
}, [agents, seats]);
const selectedSeat = seats.find((seat) => seat.agent_id === selectedAgentId) ?? null;
if (tab === "results") {
return (
<section className="position-roundtable-intent-analysis-results eightbf-roundtable-intent-analysis-results">
<div className="position-roundtable-intent-analysis-header">
<span>分析结果</span>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起分析结果"
title="收起分析结果"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
<div className="position-roundtable-intent-analysis-results-scroll eightbf-roundtable-intent-results-scroll">
<EightBfIntentResults intentReady={intentReady} />
</div>
</section>
);
}
if (selectedAgentId) {
return (
<div className="position-roundtable-intent-config-workspace">
<div className="eightbf-roundtable__agent-detail-topbar">
<button type="button" onClick={() => setSelectedAgentId(null)}>
<ChevronLeft className="size-4" />
智能体列表
</button>
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起智能体配置"
title="收起智能体配置"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
<div className="position-roundtable-agent-detail position-roundtable-agent-detail--special h-[calc(100%-48px)] min-h-0">
{selectedAgentLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#aaceff]">
<Loader2 className="size-4 animate-spin" /> 正在读取智能体配置…
</div>
) : (
<AgentDetailPanel
agent={selectedAgent}
agentName={selectedAgent?.name ?? selectedSeat?.name ?? ""}
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
showRecentChats={false}
avatarSrc={agentConfigLogo}
positionName={chain?.title || "8BF 业务链"}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
showCommonQuestions
panelTitle="智能体配置"
embedded
hideStatusBadges
/>
)}
</div>
</div>
);
}
return (
<section className="eightbf-roundtable__agent-list-workspace">
<header>
<div>
<h3>智能体配置</h3>
<p>{chain?.title ? `业务链「${chain.title}」已配置的智能体` : "请先配置 8BF 业务链"}</p>
</div>
<div className="flex items-center gap-1.5">
{canConfigureChain && (
<button type="button" className="eightbf-roundtable__configure-chain-button" onClick={onConfigureChain}>
<Settings2 className="size-3.5" /> 配置业务链
</button>
)}
<button
type="button"
className="position-roundtable-intent-config-collapse-btn"
onClick={onCollapse}
aria-label="收起智能体配置"
title="收起智能体配置"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
</div>
</header>
<div className="eightbf-roundtable__agent-list-search">
<Search className="size-3.5" />
<span>业务链智能体</span>
<span>{seatAgents.length} 个</span>
</div>
<div className="eightbf-roundtable__agent-list-scroll">
{isLoading && seats.length > 0 ? (
<div className="eightbf-roundtable__agent-list-loading"><Loader2 className="size-4 animate-spin" /> 正在加载智能体…</div>
) : seats.length === 0 ? (
<div className="eightbf-roundtable__agent-list-empty">当前业务链未配置智能体</div>
) : (
seatAgents.map(({ seat, agent }, index) => (
<EightBfAgentRow
key={seat.agent_id}
seat={seat}
agent={agent}
index={index}
onSelect={() => setSelectedAgentId(seat.agent_id)}
/>
))
)}
</div>
</section>
);
}
function EightBfAgentRow({
seat,
agent,
index,
onSelect,
}: {
seat: ChainSeat;
agent: Agent | null;
index: number;
onSelect: () => void;
}) {
const name = agent?.name || seat.name || "未命名智能体";
const description = agent?.description || seat.description || "查看该智能体的模型、技能与常用问题配置";
return (
<button type="button" className="eightbf-roundtable__agent-list-row" onClick={onSelect}>
<span className="eightbf-roundtable__agent-list-avatar" aria-hidden="true"><Bot /></span>
<span className="min-w-0 flex-1 text-left">
<strong>{name}</strong>
<small>{description}</small>
</span>
<span className="eightbf-roundtable__agent-list-index">{String(index + 1).padStart(2, "0")}</span>
</button>
);
}
function EightBfIntentResults({ intentReady }: { intentReady: object | null }) {
const sections = intentSections(intentReady);
if (sections.length === 0) {
return <p className="eightbf-roundtable__intent-results-empty">完成意图识别后,将在这里展示任务意图与关键约束。</p>;
}
return (
<div className="eightbf-roundtable__intent-results-list">
{sections.map((section) => (
<section key={section.label}>
<h3>{section.label}</h3>
<ul>{section.items.map((item) => <li key={item}>{item}</li>)}</ul>
</section>
))}
</div>
);
}
function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> {
const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null;
if (!source) return [];
const entries = [
["核心目标", arrayValue(source.coreGoals, source.objective)],
["风险提示", arrayValue(source.riskWarnings, source.constraints)],
["关键要点", arrayValue(source.keyPoints, source.assumptions)],
["战略意义", arrayValue(source.strategicSignificance)],
] as const;
return entries
.filter(([, items]) => items.length > 0)
.map(([label, items]) => ({ label, items }));
}
function intentSectionTone(label: string) {
switch (label) {
case "核心目标":
return "position-roundtable-intent-delivery-section--goals";
case "风险提示":
return "position-roundtable-intent-delivery-section--constraints";
case "关键要点":
return "position-roundtable-intent-delivery-section--purpose";
case "战略意义":
return "position-roundtable-intent-delivery-section--significance";
default:
return "position-roundtable-intent-delivery-section--significance";
}
}
function arrayValue(...values: unknown[]): string[] {
for (const value of values) {
if (Array.isArray(value)) {
const items = value.map((item) => String(item).trim()).filter(Boolean);
if (items.length > 0) return items;
}
if (typeof value === "string" && value.trim()) return [value.trim()];
}
return [];
}
@@ -1,6 +1,5 @@
import { Clipboard, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import {
dictionaryText,
type EightBfDictionaryOption,
@@ -16,6 +15,8 @@ interface EightBfTaskCardProps {
};
loading?: boolean;
error?: string | null;
/** 第二步左栏使用,与“智能体配置”同款图标收起整列。 */
onCollapse?: () => void;
}
export function EightBfTaskCard({
@@ -23,36 +24,74 @@ export function EightBfTaskCard({
dictionaries,
loading = false,
error,
onCollapse,
}: EightBfTaskCardProps) {
const priority = dictionaryText(dictionaries.priority, task?.taskPriority);
const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias");
const status = dictionaryText(dictionaries.status, task?.taskStatus);
const tags = [priority, direction, status].filter(Boolean);
// 外部任务接口不可用时,卡片使用与岗位会商相同的示例任务;标签也必须
// 保持可读,而不能暴露内部状态码。
const tags = task?.raw === null
? ["紧急", "本系统创建", "暑期活动"]
: [priority, status, direction].filter(Boolean);
return (
<section className="eightbf-roundtable__task-card" aria-labelledby="eightbf-task-card-title">
<div className="eightbf-roundtable__task-card-heading">
<span className="eightbf-roundtable__task-card-icon" aria-hidden="true">
<Clipboard className="h-3.5 w-3.5" strokeWidth={2.5} />
</span>
<h2 id="eightbf-task-card-title">当前任务</h2>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-label="正在加载任务" />}
</div>
<section
className="position-roundtable-task-card eightbf-roundtable__task-card group relative shrink-0 overflow-hidden rounded-[4px] border border-[#ECE7E4] bg-white p-5 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[rgba(0,110,254,0.5)] dark:bg-[#032650] dark:shadow-none"
aria-labelledby="eightbf-task-card-title"
>
<div className="position-roundtable-task-card__content relative z-10">
{onCollapse && (
<button
type="button"
className="position-roundtable-intent-config-collapse-btn absolute right-0 top-0"
onClick={onCollapse}
aria-label="收起左侧面板"
title="收起左侧面板"
>
<img src={collapsePanelIcon} alt="" aria-hidden="true" />
</button>
)}
<h2
id="eightbf-task-card-title"
title={task?.title || task?.id || "等待任务信息"}
className="pr-8 text-[18px] font-medium leading-7 text-slate-900 dark:text-white"
>
<span className="line-clamp-1">{task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")}</span>
</h2>
<div className="eightbf-roundtable__task-card-body">
<p className="eightbf-roundtable__task-card-title">
{task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")}
</p>
{tags.length > 0 && (
<div className="eightbf-roundtable__task-card-tags" aria-label="任务标签">
{tags.map((tag) => <span key={tag}>{tag}</span>)}
<div
data-task-tags
className="mt-3 flex flex-wrap gap-2 [&>span]:inline-flex [&>span]:min-h-7 [&>span]:items-center [&>span]:!leading-5 [&>span:first-child]:!border-[#cf1322] [&>span:nth-child(2)]:!shadow-none"
aria-label="任务标签"
>
{tags.map((tag, index) => (
<span
key={tag}
className={[
"rounded-[2px] border px-2 py-0.5 text-center text-[16px] font-light",
index === 0 && "border-[#cf1322] bg-[rgba(207,19,34,0.5)] leading-4 text-white",
index === 1 && "border-[#4bcdff] bg-[rgba(75,205,255,0.3)] leading-7 text-[#4bcdff] shadow-[0_0_9px_rgba(252,131,131,0.27)]",
index > 1 && "border-[#a2e1f9] bg-[rgba(162,225,249,0.3)] leading-[18px] text-[#a2e1f9]",
].filter(Boolean).join(" ")}
>
{tag}
</span>
))}
</div>
)}
<p className={cn("eightbf-roundtable__task-card-description", !task?.description && "is-empty")}>
<p
data-task-description
className={cn(
"mt-3 line-clamp-3 whitespace-pre-wrap text-sm leading-[22px] text-slate-600 dark:text-[#96bbf8]",
!task?.description && "is-empty",
)}
>
{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}
</p>
{error && <p className="eightbf-roundtable__task-card-error" role="status">{error}</p>}
</div>
</div>
</section>
);
}
@@ -1,11 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Loader2, Save, Settings2, X } from "lucide-react";
import { Loader2, Save, X } from "lucide-react";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
import {
EightBfStep1Panel,
EightBfStep1Sidebar,
EightBfStep2SidebarHeader,
} from "@/eightbf-roundtable/components/EightBfStep1Workspace";
import {
buildEightBfTaskContext,
createEightBfFallbackTask,
fetchEightBfTask,
fetchEightBfTaskDictionaries,
normalizeEightBfTask,
@@ -100,7 +106,9 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
})
.catch((error) => {
if (cancelled) return;
setTaskError(error instanceof Error ? error.message : "任务详情加载失败");
console.warn("[8bf] 任务详情请求失败,使用页面示例任务", error);
setTask(createEightBfFallbackTask(taskId));
setTaskError(null);
})
.finally(markRequestFinished);
void fetchEightBfTaskDictionaries()
@@ -169,18 +177,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
}
}, [canConfigureChain, chains, selectedChainId]);
const openChainSettings = useCallback(() => {
if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]);
const toolbarSlot = (
<>
{canConfigureChain && (
<button
type="button"
className="eightbf-roundtable__chain-settings-button"
onClick={() => setIsSettingsOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
配置业务链
</button>
)}
{canConfigureChain && isSettingsOpen && (
<div className="eightbf-roundtable__settings-mask" role="presentation">
<section
@@ -248,6 +250,53 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
/>
) : undefined}
toolbarSlot={toolbarSlot}
hideHeaderSaveDraft
hideHeaderSettings
newTaskLabel="新建会话"
hideDefaultStep1RightSidebar
renderStep1={(props) => (
<EightBfStep1Panel
{...props}
chain={configuredChain}
canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings}
/>
)}
renderStep1LeftSidebar={({ intentReady }) => (
<EightBfStep1Sidebar
intentReady={intentReady}
taskCard={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
/>
) : undefined}
businessChain={
<PositionBusinessChainFlow
chain={configuredChain}
loading={isLoading}
embedded
className="position-roundtable-intent-delivery-flow eightbf-roundtable__business-chain-flow"
/>
}
/>
)}
renderStep2LeftSidebarHeader={({ intentReady, onCollapse }) => (
<EightBfStep2SidebarHeader
intentReady={intentReady}
taskCard={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
onCollapse={onCollapse}
/>
) : undefined}
/>
)}
entryRailBusinessChainSlot={
<PositionBusinessChainFlow
chain={configuredChain}
@@ -23,104 +23,265 @@
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
height: 500px;
min-height: 460px;
height: 430px;
min-height: 430px;
}
/* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
.eightbf-roundtable .eightbf-roundtable__task-card {
flex: 0 0 auto;
border: 1px solid #d9e5ef;
border-radius: 16px;
background: linear-gradient(145deg, #ffffff 0%, #f8fbff 100%);
padding: 14px;
box-shadow: 0 4px 20px rgb(15 23 42 / 3%);
min-width: 0;
}
.eightbf-roundtable .eightbf-roundtable__task-card-heading {
.eightbf-roundtable .eightbf-roundtable__task-card-title-row {
display: flex;
align-items: center;
gap: 8px;
color: #1e293b;
font-size: 13px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child {
margin-left: auto;
color: #0f766e;
}
.eightbf-roundtable .eightbf-roundtable__task-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 7px;
background: #0f766e;
color: #fff;
}
.eightbf-roundtable .eightbf-roundtable__task-card-body {
min-width: 0;
margin-top: 11px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-title {
margin: 0;
color: #0f172a;
font-size: 14px;
font-weight: 700;
line-height: 1.55;
overflow-wrap: anywhere;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
margin-top: 12px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
border: 1px solid #c7e6df;
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
padding: 2px 7px;
font-size: 10px;
line-height: 1.4;
border-radius: 2px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description {
display: -webkit-box;
margin: 9px 0 0;
color: #475569;
font-size: 11px;
line-height: 1.65;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow-wrap: anywhere;
white-space: pre-wrap;
margin: 12px 0 0;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; }
.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #b91c1c; font-size: 10px; line-height: 1.5; }
.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #fca5a5; font-size: 12px; line-height: 1.5; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card {
border-color: rgb(92 156 219 / 45%);
background: linear-gradient(145deg, #0b315d 0%, #08264b 100%);
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
/*
* 8BF 第一阶段直接采用「岗位多智能体会商」意图工作台的三栏尺寸和深蓝表面。
* 专属类只改变 8BF;岗位会商原有页面继续由 intent-understanding.css 管理。
*/
.eightbf-roundtable .eightbf-roundtable__header-brand {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 12px;
}
.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading,
.dark .eightbf-roundtable .eightbf-roundtable__task-card-title { color: #f1f7ff; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child { color: #65d6c6; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-tags span { border-color: rgb(101 214 198 / 40%); background: rgb(15 118 110 / 24%); color: #9ef0df; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description { color: #c3d4e8; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-error { color: #fca5a5; }
.eightbf-roundtable .eightbf-roundtable__header-brand-icon {
display: inline-flex;
width: 42px;
height: 42px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 14px;
background: rgb(43 143 254 / 16%);
color: #2b8ffe;
}
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
.eightbf-roundtable .eightbf-roundtable__header-brand-icon svg { width: 23px; height: 23px; }
.eightbf-roundtable .eightbf-roundtable__header-brand h1 { margin: 0; color: #1e293b; font-size: 16px; font-weight: 700; }
.eightbf-roundtable .eightbf-roundtable__header-brand p { margin: 2px 0 0; color: #64748b; font-size: 12px; }
.eightbf-roundtable .eightbf-roundtable__header-status {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: 8px;
border: 1px solid rgb(16 185 129 / 30%);
border-radius: 999px;
background: rgb(16 185 129 / 8%);
color: #047857;
padding: 5px 10px;
font-size: 12px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__header-chain {
display: inline-flex;
min-width: 230px;
max-width: 360px;
align-items: center;
gap: 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #64748b;
padding: 8px 10px;
font-size: 12px;
}
.eightbf-roundtable .eightbf-roundtable__header-chain strong { min-width: 0; flex: 1; overflow: hidden; color: #334155; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.eightbf-roundtable .eightbf-roundtable__header-chain svg { width: 16px; height: 16px; flex: 0 0 auto; }
/* 参考工作台的直角圆角层级:内容面、消息区与输入框均为 4px。 */
.eightbf-roundtable #middle-viewport { border-radius: 4px; }
.eightbf-roundtable .position-roundtable-intent-panel > div:first-child,
.eightbf-roundtable .position-roundtable-intent-composer-frame,
.eightbf-roundtable .position-roundtable-intent-config-pane { border-radius: 4px; }
.eightbf-roundtable .position-roundtable-intent-panel > div:first-child { border-radius: 4px 4px 0 0; }
.eightbf-roundtable .eightbf-roundtable__step1-sidebar {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 12px;
overflow: hidden;
padding-right: 0;
}
/* 8BF 首步左栏不允许横向滚动;流程图的横向超出由画布自身裁切。 */
.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar,
.eightbf-roundtable .eightbf-roundtable__step1-sidebar,
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
overflow-x: hidden !important;
}
.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar::-webkit-scrollbar:horizontal,
.eightbf-roundtable .eightbf-roundtable__step1-sidebar::-webkit-scrollbar:horizontal,
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar::-webkit-scrollbar:horizontal {
display: none;
height: 0;
}
.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
min-height: 0;
overflow-y: auto;
}
.eightbf-roundtable .eightbf-roundtable__intent-sidebar-empty {
margin: 0;
padding: 12px 16px 18px;
color: #aaceff;
font-size: 14px;
line-height: 22px;
}
/* The flow is embedded into the intent surface: only its own header line and
dotted canvas are visible. The surrounding intent panel owns the border. */
.eightbf-roundtable .position-roundtable-intent-delivery-flow {
/* Reference flow has horizontal gutters. Reduce the width by those gutters
so the right-side magnifier is never clipped by the left-rail crop. */
width: calc(100% - 24px) !important;
height: 580px !important;
min-height: 580px !important;
flex-basis: 580px !important;
}
.eightbf-roundtable .position-roundtable-intent-delivery-flow > div:first-child {
margin: 0 0 12px !important;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
background: #032650;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace > header {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgb(43 143 254 / 35%);
}
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace h3 { margin: 0; color: #fff; font-size: 17px; font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__agent-list-workspace p { margin: 4px 0 0; color: #aaceff; font-size: 11px; line-height: 1.45; }
.eightbf-roundtable .eightbf-roundtable__configure-chain-button {
display: inline-flex;
height: 28px;
align-items: center;
gap: 4px;
border: 1px solid rgb(115 200 255 / 52%);
border-radius: 4px;
background: rgb(0 98 217 / 22%);
color: #dceaff;
padding: 0 8px;
font-size: 11px;
}
.eightbf-roundtable .eightbf-roundtable__configure-chain-button:hover { background: rgb(0 98 217 / 40%); }
.eightbf-roundtable .eightbf-roundtable__agent-list-search {
display: flex;
align-items: center;
gap: 7px;
margin: 12px;
border: 1px solid rgb(43 143 254 / 36%);
border-radius: 4px;
background: rgb(0 12 28 / 20%);
color: #aaceff;
padding: 8px 9px;
font-size: 12px;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-search span:last-child { margin-left: auto; border-radius: 999px; background: #0b386d; padding: 1px 6px; color: #83c1ff; font-size: 10px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 12px 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-loading,
.eightbf-roundtable .eightbf-roundtable__agent-list-empty { display: flex; min-height: 100px; align-items: center; justify-content: center; gap: 7px; color: #8aa0c4; font-size: 12px; text-align: center; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgb(43 143 254 / 21%);
padding: 11px 2px;
color: inherit;
text-align: left;
transition: background-color .16s ease, transform .16s ease;
}
.eightbf-roundtable .eightbf-roundtable__agent-list-row:hover { background: rgb(0 98 217 / 13%); transform: translateX(2px); }
.eightbf-roundtable .eightbf-roundtable__agent-list-avatar { display: inline-flex; width: 34px; height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; border: 1px solid #007e82; border-radius: 7px; background: #00306a; color: #73c8ff; }
.eightbf-roundtable .eightbf-roundtable__agent-list-avatar svg { width: 18px; height: 18px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row strong,
.eightbf-roundtable .eightbf-roundtable__agent-list-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row strong { color: #fff; font-size: 13px; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__agent-list-row small { margin-top: 3px; color: #aaceff; font-size: 11px; }
.eightbf-roundtable .eightbf-roundtable__agent-list-index { color: #75b7f5; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar { display: flex; height: 48px; align-items: center; justify-content: space-between; border-bottom: 1px solid rgb(43 143 254 / 35%); padding: 0 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child { display: inline-flex; align-items: center; gap: 2px; color: #aaceff; font-size: 12px; }
.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child:hover { color: #fff; }
.eightbf-roundtable .eightbf-roundtable__intent-results-scroll { padding: 12px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-empty { margin: 0; color: #aaceff; font-size: 13px; line-height: 1.8; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list { display: grid; gap: 10px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list section { border: 1px solid rgb(43 143 254 / 32%); border-radius: 4px; background: rgb(0 12 28 / 20%); padding: 12px; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list h3 { margin: 0 0 8px; color: #a2e1f9; font-size: 14px; font-weight: 500; }
.eightbf-roundtable .eightbf-roundtable__intent-results-list ul { margin: 0; padding-left: 17px; color: #dceaff; font-size: 12px; line-height: 1.8; }
/* Screenshot-equivalent dark workbench shell. */
.dark .eightbf-roundtable { background: #061b3b; color: #e7f0ff; }
.dark .eightbf-roundtable > header { min-height: 64px; border-color: rgb(43 143 254 / 45%); background: #032650; box-shadow: none; }
.dark .eightbf-roundtable .eightbf-roundtable__header-brand h1 { color: #e7f0ff; }
.dark .eightbf-roundtable .eightbf-roundtable__header-brand p { color: #8aa0c4; }
.dark .eightbf-roundtable .eightbf-roundtable__header-status { border-color: rgb(101 214 198 / 45%); background: rgb(15 118 110 / 24%); color: #9ef0df; }
.dark .eightbf-roundtable .eightbf-roundtable__header-chain { border-color: rgb(143 196 255 / 44%); background: #082b57; color: #aaceff; }
.dark .eightbf-roundtable .eightbf-roundtable__header-chain strong { color: #e7f0ff; }
.dark .eightbf-roundtable #new-task-btn { border: 1px solid rgb(115 200 255 / 45%); border-radius: 4px; background: transparent; color: #e7f0ff; box-shadow: none; }
.dark .eightbf-roundtable #new-task-btn:hover { background: rgb(0 98 217 / 24%); }
.dark .eightbf-roundtable[data-roundtable-step="1"] > main { gap: 0; padding: 12px 0; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #middle-viewport { border-color: rgb(43 143 254 / 50%); border-radius: 4px; background: #032650; box-shadow: none; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar {
width: 374px;
overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
padding: 0 12px;
}
.dark .eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar {
border-color: rgb(0 110 254 / 50%);
background: #032650;
}
@media (max-width: 1280px) {
.eightbf-roundtable .eightbf-roundtable__header-chain { min-width: 180px; max-width: 220px; }
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 326px; }
.eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: 320px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 294px; }
.eightbf-roundtable .eightbf-roundtable__header-status,
.eightbf-roundtable .eightbf-roundtable__header-brand p { display: none; }
.eightbf-roundtable .eightbf-roundtable__header-chain { display: none; }
.eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: min(360px, 42vw); }
}
@@ -14,4 +14,67 @@
.eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; }
/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */
/* 展开时工作区紧贴左栏;收起时恢复通用的 16px 间距,避免窄栏贴着内容区。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="true"] {
gap: 0;
}
.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="false"] {
gap: 10px;
}
/* 第二步左栏顶部直接复用第一步任务卡和意图理解区,但不复用第一步整列的
flex: 1 行为;内容高度随意图实际内容收拢,剩余内容交给左栏统一滚动。 */
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: visible;
}
.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header
.eightbf-roundtable__intent-delivery-sidebar {
flex: 0 0 auto;
overflow: visible;
}
/* 角色面板与列表均依内容撑开,再交由左栏的单一滚动容器承载;否则取消
内层滚动后,角色项会从固定高度的父面板溢出并覆盖下方内容。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .eightbf-roundtable__step2-sidebar-header + div {
flex: 0 0 auto !important;
min-height: 0 !important;
}
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .custom-scrollbar {
flex: 0 0 auto;
overflow: visible;
padding-right: 0;
}
/* 8BF 第二步沿用第一步的 4px 面板语言,避免通用会商的 rounded-2xl 混入。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl {
border-radius: 4px !important;
}
/* 展开态与第一步左栏保持同一宽度和内边距。注意不能无条件锁定宽度,否则会
覆盖收起态的 w-[56px],使顶部图标按钮看起来无效。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] {
width: 374px;
padding: 0 12px;
}
/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其
#enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input {
flex: 0 0 auto;
}
@media (max-width: 1280px) {
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; }
}
@media (max-width: 1024px) {
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; }
}
@@ -119,6 +119,10 @@ export interface DocumentRewriteRequest {
instruction: string;
style?: DocumentRewriteStyle;
modelName?: string;
/** Generate locally saved illustration assets after the prose rewrite. */
generateImages?: boolean;
/** Bounded server-side to 1–4. Ignored unless generateImages is true. */
maxGeneratedImages?: number;
expectedHash?: string;
}
@@ -140,6 +144,8 @@ export interface DocumentRewriteSummary {
};
requestedOptimizations?: string[];
validationWarnings?: string[];
generatedImageCount?: number;
requestedImageCount?: number;
}
export type DocumentRewriteStreamEvent =
@@ -156,6 +162,15 @@ export type DocumentRewriteStreamEvent =
| { type: "rewrite_delta"; delta: string; charCount: number }
/** Durable full-draft recovery point used after an SSE reconnect. */
| { type: "rewrite_checkpoint"; content: string; charCount: number }
| { type: "illustration_started"; section: string; position: number; total: number }
| {
type: "illustration_generated";
section: string;
position: number;
total: number;
generatedCount?: number;
}
| { type: "illustration_completed"; generatedCount: number; requestedCount: number; warnings: string[] }
| { type: "validation_started" }
| { type: "validation_completed"; passed: boolean; warnings: string[] }
| { type: "comparison_ready"; summary: DocumentRewriteSummary }
@@ -177,6 +192,8 @@ export interface DurableDocumentRewriteJob {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
originalContent: string;
};
}
@@ -229,6 +246,30 @@ async function readDocumentRewriteEvent(
content: String(parsed.content ?? ""),
charCount: Number(parsed.charCount ?? 0),
};
case "illustration_started":
return {
type: "illustration_started",
section: String(parsed.section ?? ""),
position: Number(parsed.position ?? 0),
total: Number(parsed.total ?? 0),
};
case "illustration_generated":
return {
type: "illustration_generated",
section: String(parsed.section ?? ""),
position: Number(parsed.position ?? 0),
total: Number(parsed.total ?? 0),
generatedCount: Number(parsed.generatedCount ?? parsed.position ?? 0),
};
case "illustration_completed":
return {
type: "illustration_completed",
generatedCount: Number(parsed.generatedCount ?? 0),
requestedCount: Number(parsed.requestedCount ?? 0),
warnings: Array.isArray(parsed.warnings)
? parsed.warnings.filter((item): item is string => typeof item === "string")
: [],
};
case "validation_started":
return { type: "validation_started" };
case "validation_completed":
@@ -362,6 +403,8 @@ export async function listDurableDocumentRewrites(
instruction: String(request.instruction ?? ""),
style: (request.style ?? "professional") as DocumentRewriteStyle,
modelName: typeof request.modelName === "string" ? request.modelName : null,
generateImages: request.generateImages === true,
maxGeneratedImages: Number(request.maxGeneratedImages ?? 2),
originalContent: String(request.originalContent ?? ""),
},
}];
@@ -9,12 +9,14 @@ import {
cancelDurableDocumentRewrite,
streamDocumentRewrite,
} from "../api/rewrite";
import { InlineThinkTagFilter, stripInlineThinkTags } from "@/core/streamdown/inline-think";
export type DocumentRewriteStage =
| "idle"
| "snapshot"
| "requirements"
| "writing"
| "illustrating"
| "validation"
| "committing"
| "completed"
@@ -27,6 +29,8 @@ export interface DocumentRewriteRun {
draft: string;
/** Live, compact execution plan produced before Markdown generation starts. */
requirementsPlan: string;
/** Ephemeral model reasoning, deliberately kept out of the Markdown draft. */
thinkingText: string;
originalCharCount: number;
writtenCharCount: number;
fileName: string;
@@ -41,6 +45,7 @@ const INITIAL_RUN: DocumentRewriteRun = {
stage: "idle",
draft: "",
requirementsPlan: "",
thinkingText: "",
originalCharCount: 0,
writtenCharCount: 0,
fileName: "",
@@ -67,6 +72,10 @@ export function useDocumentRewrite() {
const frameRef = useRef<number | null>(null);
const pendingRequirementsDeltaRef = useRef("");
const requirementsFrameRef = useRef<number | null>(null);
const pendingThinkingRef = useRef("");
const thinkingFrameRef = useRef<number | null>(null);
const draftThinkFilterRef = useRef(new InlineThinkTagFilter());
const requirementsThinkFilterRef = useRef(new InlineThinkTagFilter());
const flushDelta = useCallback(() => {
if (frameRef.current !== null) {
@@ -86,27 +95,6 @@ export function useDocumentRewrite() {
}));
}, []);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
if (!delta) return;
pendingDeltaRef.current += delta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, []);
const flushRequirements = useCallback(() => {
if (requirementsFrameRef.current !== null) {
cancelAnimationFrame(requirementsFrameRef.current);
@@ -124,9 +112,35 @@ export function useDocumentRewrite() {
}));
}, []);
const enqueueRequirementsDelta = useCallback((delta: string) => {
const flushThinking = useCallback(() => {
if (thinkingFrameRef.current !== null) {
cancelAnimationFrame(thinkingFrameRef.current);
thinkingFrameRef.current = null;
}
const delta = pendingThinkingRef.current;
pendingThinkingRef.current = "";
if (!delta) return;
pendingRequirementsDeltaRef.current += delta;
setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + delta }));
}, []);
const enqueueThinking = useCallback((delta: string) => {
if (!delta) return;
pendingThinkingRef.current += delta;
if (thinkingFrameRef.current !== null) return;
thinkingFrameRef.current = requestAnimationFrame(() => {
thinkingFrameRef.current = null;
const pending = pendingThinkingRef.current;
pendingThinkingRef.current = "";
if (!pending) return;
setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + pending }));
});
}, []);
const enqueueRequirementsDelta = useCallback((delta: string) => {
const { text: visibleDelta, thinking } = requirementsThinkFilterRef.current.pushParts(delta);
enqueueThinking(thinking);
if (!visibleDelta) return;
pendingRequirementsDeltaRef.current += visibleDelta;
if (requirementsFrameRef.current !== null) return;
requirementsFrameRef.current = requestAnimationFrame(() => {
requirementsFrameRef.current = null;
@@ -141,7 +155,30 @@ export function useDocumentRewrite() {
requirementsPlan: previous.requirementsPlan + pending,
}));
});
}, []);
}, [enqueueThinking]);
const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => {
const { text: visibleDelta, thinking } = draftThinkFilterRef.current.pushParts(delta);
enqueueThinking(thinking);
if (!visibleDelta) return;
pendingDeltaRef.current += visibleDelta;
pendingWrittenCharCountRef.current = writtenCharCount;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const pending = pendingDeltaRef.current;
const pendingWrittenCharCount = pendingWrittenCharCountRef.current;
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
if (!pending) return;
setRun((previous) => ({
...previous,
stage: "writing",
draft: previous.draft + pending,
...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}),
}));
});
}, [enqueueThinking]);
const cancel = useCallback(() => {
controllerRef.current?.abort();
@@ -155,12 +192,13 @@ export function useDocumentRewrite() {
}
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) =>
previous.stage === "completed"
? previous
: { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" },
);
}, [flushDelta, flushRequirements]);
}, [flushDelta, flushRequirements, flushThinking]);
const reset = useCallback(() => {
controllerRef.current?.abort();
@@ -169,6 +207,9 @@ export function useDocumentRewrite() {
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
pendingThinkingRef.current = "";
draftThinkFilterRef.current = new InlineThinkTagFilter();
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
@@ -177,6 +218,10 @@ export function useDocumentRewrite() {
cancelAnimationFrame(requirementsFrameRef.current);
requirementsFrameRef.current = null;
}
if (thinkingFrameRef.current !== null) {
cancelAnimationFrame(thinkingFrameRef.current);
thinkingFrameRef.current = null;
}
setRun(INITIAL_RUN);
}, []);
@@ -202,6 +247,9 @@ export function useDocumentRewrite() {
pendingDeltaRef.current = "";
pendingWrittenCharCountRef.current = null;
pendingRequirementsDeltaRef.current = "";
pendingThinkingRef.current = "";
draftThinkFilterRef.current = new InlineThinkTagFilter();
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
setRun({
...INITIAL_RUN,
stage: "snapshot",
@@ -236,28 +284,36 @@ export function useDocumentRewrite() {
case "requirements_delta":
enqueueRequirementsDelta(event.delta);
break;
case "thinking":
enqueueThinking(event.chunk);
break;
case "requirements_checkpoint":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
requirementsPlan: requirementsThinkFilterRef.current.replace(event.content),
}));
break;
case "requirements_completed":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan: event.content,
}));
{
const requirementsPlan =
requirementsThinkFilterRef.current.replace(event.content) +
requirementsThinkFilterRef.current.finish();
setRun((previous) => ({
...previous,
stage: "requirements",
requirementsPlan,
}));
}
break;
case "requirements_resolved":
flushRequirements();
setRun((previous) => ({
...previous,
stage: "writing",
...(event.plan ? { requirementsPlan: event.plan } : {}),
...(event.plan ? { requirementsPlan: stripInlineThinkTags(event.plan) } : {}),
}));
break;
case "rewrite_delta":
@@ -276,13 +332,20 @@ export function useDocumentRewrite() {
setRun((previous) => ({
...previous,
stage: "writing",
draft: event.content,
writtenCharCount: event.charCount,
draft: draftThinkFilterRef.current.replace(event.content),
writtenCharCount: stripInlineThinkTags(event.content).replace(/\s/g, "").length,
}));
break;
case "illustration_started":
case "illustration_generated":
case "illustration_completed":
flushDelta();
setRun((previous) => ({ ...previous, stage: "illustrating" }));
break;
case "validation_started":
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) => ({ ...previous, stage: "validation" }));
break;
case "validation_completed":
@@ -300,17 +363,23 @@ export function useDocumentRewrite() {
case "committed":
flushDelta();
flushRequirements();
setRun((previous) => ({
...previous,
draft: event.content,
writtenCharCount: event.content.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(event.content);
flushThinking();
{
const committedContent =
draftThinkFilterRef.current.replace(event.content) + draftThinkFilterRef.current.finish();
setRun((previous) => ({
...previous,
draft: committedContent,
writtenCharCount: committedContent.replace(/\s/g, "").length,
versionId: event.versionId ?? previous.versionId,
}));
options?.onCommitted?.(committedContent);
}
break;
case "done":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -321,6 +390,7 @@ export function useDocumentRewrite() {
case "conflict":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -331,12 +401,14 @@ export function useDocumentRewrite() {
case "cancelled":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message }));
return;
case "error":
flushDelta();
flushRequirements();
flushThinking();
receivedTerminalEvent = true;
setRun((previous) => ({
...previous,
@@ -351,6 +423,7 @@ export function useDocumentRewrite() {
if (!controller.signal.aborted && !receivedTerminalEvent) {
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) => ({
...previous,
stage: "error",
@@ -361,6 +434,7 @@ export function useDocumentRewrite() {
if (controller.signal.aborted) return;
flushDelta();
flushRequirements();
flushThinking();
setRun((previous) => ({
...previous,
stage: "error",
@@ -372,7 +446,7 @@ export function useDocumentRewrite() {
}
}
},
[enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements],
[enqueueDelta, enqueueRequirementsDelta, enqueueThinking, flushDelta, flushRequirements, flushThinking],
);
useEffect(
@@ -380,13 +454,14 @@ export function useDocumentRewrite() {
controllerRef.current?.abort();
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current);
if (thinkingFrameRef.current !== null) cancelAnimationFrame(thinkingFrameRef.current);
},
[],
);
return {
run,
isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage),
isActive: ["snapshot", "requirements", "writing", "illustrating", "validation", "committing"].includes(run.stage),
start,
cancel,
reset,
@@ -123,6 +123,7 @@ import {
advancedOptionsFromConfig,
buildReportCardConfig,
ReportConfigCard,
ReportOutlineEditor,
} from "./ReportConfigCard";
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
@@ -183,6 +184,8 @@ type WorkbenchRun = {
phase: string;
planText: string;
planReasoning: string;
/** Live-only model thinking while report Markdown is being streamed. */
reportThinking: string;
report: string;
reportTargets: Record<string, { content: string; version: number }>;
reportVersion: number;
@@ -201,6 +204,7 @@ const EMPTY_RUN: WorkbenchRun = {
phase: "initializing",
planText: "",
planReasoning: "",
reportThinking: "",
report: "",
reportTargets: {},
reportVersion: 1,
@@ -418,6 +422,10 @@ function DeepResearchWorkbenchInner() {
presentationId: null,
plan: "",
});
const mirroredReportRewriteThinkingRef = useRef<{ presentationId: string | null; thinking: string }>({
presentationId: null,
thinking: "",
});
const sessions = useResearchSessions();
const sessionQuery = useResearchSession(selectedId);
@@ -428,6 +436,10 @@ function DeepResearchWorkbenchInner() {
const [localSettings] = useLocalSettings();
const { models, isLoading: modelsLoading } = useModels();
const seenEventKeys = useRef(new Set<string>());
// Live provider deltas can legitimately repeat the same text (including a
// single space). They have no durable sequence, so never deduplicate them
// by payload or a thought token could disappear from the visible trace.
const liveEventSerial = useRef(0);
const replayedTraceJobs = useRef(new Set<string>());
const pendingReportEvents = useRef<ProgressEvent[]>([]);
const reportAnimationFrame = useRef<number | null>(null);
@@ -679,6 +691,16 @@ function DeepResearchWorkbenchInner() {
});
}, [reportDocumentRewrite.run.requirementsPlan, rewritePresentation]);
useEffect(() => {
const presentationId = reportRewritePresentationId.current;
const thinking = reportDocumentRewrite.run.thinkingText;
if (!presentationId || !rewritePresentation || !thinking) return;
const previous = mirroredReportRewriteThinkingRef.current;
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
mirroredReportRewriteThinkingRef.current = { presentationId, thinking };
rewritePresentation.update(presentationId, { thinkingText: thinking });
}, [reportDocumentRewrite.run.thinkingText, rewritePresentation]);
useEffect(() => {
const handleRewriteUndo = (event: Event) => {
const detail = (event as CustomEvent<{
@@ -854,6 +876,9 @@ function DeepResearchWorkbenchInner() {
phase: session?.status === "completed" ? "done" : run.phase,
materialCount,
error: run.error,
thinking: run.reportThinking,
events: run.events,
requestedImageCount: requestedReportImageCount(session?.config),
}),
} as Injected);
}
@@ -870,6 +895,7 @@ function DeepResearchWorkbenchInner() {
? buildCompletedReportMessage({
sourceCount: materialCount,
summaryText: run.summaryText,
imageCount: countEmbeddedReportImages(session?.reportMarkdown),
})
: buildStreamingSummaryMessage(run.summaryText),
// MessageList 会将该标记转为既有的 assistant 流式渲染状态,避免摘要只在结束后才显示。
@@ -950,7 +976,9 @@ function DeepResearchWorkbenchInner() {
pendingFollowUp,
followUpStream,
run.error,
run.events,
run.phase,
run.reportThinking,
run.summaryText,
session,
streamJobId,
@@ -980,6 +1008,7 @@ function DeepResearchWorkbenchInner() {
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
seenEventKeys.current = new Set();
liveEventSerial.current = 0;
pendingReportEvents.current = [];
setRun({ ...EMPTY_RUN, jobId: streamJobId });
// 进行中的写作仍自动打开沙箱;历史完成记录只回放步骤与摘要,等待用户点击
@@ -997,6 +1026,7 @@ function DeepResearchWorkbenchInner() {
let reportTargets = current.reportTargets;
let planText = current.planText;
let planReasoning = current.planReasoning;
let reportThinking = current.reportThinking;
let summaryText = current.summaryText;
let phase = current.phase;
for (const event of queued) {
@@ -1009,6 +1039,10 @@ function DeepResearchWorkbenchInner() {
planReasoning += typeof event.payload.delta === "string" ? event.payload.delta : "";
continue;
}
if (event.type === "report_thinking") {
reportThinking += typeof event.payload.delta === "string" ? event.payload.delta : "";
continue;
}
if (event.type === "summary_delta") {
summaryText += typeof event.payload.delta === "string" ? event.payload.delta : "";
continue;
@@ -1020,6 +1054,7 @@ function DeepResearchWorkbenchInner() {
if (event.type === "report_reset") {
report = "";
reportTargets = {};
reportThinking = "";
reportVersion = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion + 1;
continue;
}
@@ -1035,7 +1070,7 @@ function DeepResearchWorkbenchInner() {
reportVersion = Math.max(reportVersion, version);
}
}
return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, summaryText };
return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, reportThinking, summaryText };
});
};
@@ -1043,12 +1078,15 @@ function DeepResearchWorkbenchInner() {
streamJobId,
{
onEvent: (event) => {
const key = eventKey(event);
const key = event.seq === 0 && !event.liveId
? `live-arrival:${++liveEventSerial.current}`
: eventKey(event);
if (seenEventKeys.current.has(key)) return;
seenEventKeys.current.add(key);
if (
event.type === "plan_delta" ||
event.type === "plan_reasoning" ||
event.type === "report_thinking" ||
event.type === "report_delta" ||
event.type === "report_chunk" ||
event.type === "report_reset" ||
@@ -1521,6 +1559,10 @@ function DeepResearchWorkbenchInner() {
case "config":
return (
<ReportConfigCard
// A new durable config-card message is a new editing session. Do
// not remount for ordinary collector/event refreshes: that would
// discard the user's in-progress “生成配图” toggle.
key={`${session.id}:${payload.messageId ?? "report-config"}`}
availableModes={availableModes}
defaultMode={session.mode}
defaultOptions={advancedOptionsFromConfig(session.config)}
@@ -1842,6 +1884,7 @@ type WritingProgressToolCall = {
label: string;
detail: string;
status: "complete" | "active";
thinking?: string;
};
};
@@ -1854,12 +1897,23 @@ function buildWritingProgressToolCalls({
phase,
materialCount,
error,
thinking,
events,
requestedImageCount,
}: {
phase: string;
materialCount: number;
error: string | null;
thinking: string;
events: ProgressEvent[];
requestedImageCount: number;
}): WritingProgressToolCall[] {
const rank = chatPhaseRank(phase);
const imageSnapshot = buildTraceSnapshot(events);
const imageStageVisible = requestedImageCount > 0 || imageSnapshot.imageTotal > 0 || imageSnapshot.images.length > 0;
const imageTotal = imageSnapshot.imageTotal || requestedImageCount;
const generatedImages = imageSnapshot.images.filter((image) => image.status === "completed").length;
const failedImages = imageSnapshot.images.filter((image) => image.status === "failed").length;
const stages = [
{
id: "harvest",
@@ -1879,6 +1933,18 @@ function buildWritingProgressToolCalls({
boundary: chatPhaseRank("reviewing"),
detail: "正在逐字写入右侧沙箱中的 report.md",
},
...(imageStageVisible ? [{
id: "illustrate",
label: "生成报告配图",
boundary: chatPhaseRank("exporting"),
detail: generatedImages > 0
? `已生成 ${generatedImages} 张报告配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}`
: failedImages > 0
? `配图未生成,已保留完整文字报告${imageTotal ? `(已处理 ${failedImages}/${imageTotal} 张)` : ""}`
: imageTotal > 0
? `正在生成报告配图,已处理 ${generatedImages + failedImages}/${imageTotal} 张`
: "正在为报告关键章节生成配图",
}] : []),
{
id: "summary",
label: "生成报告摘要",
@@ -1901,6 +1967,7 @@ function buildWritingProgressToolCalls({
label: stage.label,
detail,
status: complete ? "complete" : "active",
...(stage.id === "write" && thinking.trim() ? { thinking } : {}),
},
};
});
@@ -1910,14 +1977,19 @@ function buildWritingProgressToolCalls({
function buildCompletedReportMessage({
sourceCount,
summaryText,
imageCount,
}: {
sourceCount: number;
summaryText: string;
imageCount: number;
}): string {
const parts = [
"## 研究报告已完成",
`已基于 ${sourceCount} 条资料完成报告,正文已在右侧沙箱中打开;可通过沙箱工具栏下载 Markdown 或 Word。`,
];
if (imageCount > 0) {
parts.push(`已生成 ${imageCount} 张报告配图,并嵌入正文对应章节。`);
}
if (summaryText.trim()) {
parts.push(`### 报告摘要\n\n${summaryText.trim()}`);
}
@@ -2104,6 +2176,7 @@ function ResearchConversation({
const hasTrace = active || events.length > 0;
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
const requestedImageCount = requestedReportImageCount(session.config);
return (
<div className="space-y-6">
<UserBubble content={session.query} />
@@ -2120,6 +2193,7 @@ function ResearchConversation({
error={streamError}
onOpenSandbox={onOpenSandbox}
active={active}
requestedImageCount={requestedImageCount}
/>
</AssistantTurn>
)}
@@ -2169,6 +2243,15 @@ type TraceSource = {
snippet?: string;
};
type TraceImage = {
position: number;
section: string;
status: "generating" | "completed" | "failed";
name?: string;
alt?: string;
error?: string;
};
type TraceSnapshot = {
groups: { title: string; queries: string[] }[];
subtopics: string[];
@@ -2179,6 +2262,9 @@ type TraceSnapshot = {
curationReason?: string;
compressedSourceCount?: number;
reportCompleted: boolean;
imageTotal: number;
images: TraceImage[];
imagePhaseFinished: boolean;
warnings: string[];
};
@@ -2198,17 +2284,30 @@ function numberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function countEmbeddedReportImages(markdown?: string): number {
return (markdown?.match(/!\[[^\]]*\]\(deep-research:\/\/[^)\s]+\)/g) ?? []).length;
}
function requestedReportImageCount(config?: Record<string, unknown>): number {
if (config?.generate_images !== true) return 0;
const configured = numberValue(config.max_generated_images);
return Math.max(1, Math.min(4, Math.floor(configured ?? 2)));
}
function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
const groupQueries = new Map<string, string[]>();
const subtopics: string[] = [];
const queryMap = new Map<string, TraceQuery>();
const sourceMap = new Map<string, TraceSource>();
const imageMap = new Map<number, TraceImage>();
const selectedIds = new Set<string>();
const removedIds = new Set<string>();
const warnings: string[] = [];
let curationReason: string | undefined;
let compressedSourceCount: number | undefined;
let reportCompleted = false;
let imageTotal = 0;
let imagePhaseFinished = false;
const addQuery = (query: string, group = "研究课题") => {
const normalized = query.trim();
@@ -2223,6 +2322,11 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
for (const event of events) {
const payload = event.payload ?? {};
if (event.type === "phase_changed") {
const from = traceString(payload.from);
const to = traceString(payload.to);
if (from === "illustrating" || to === "exporting") imagePhaseFinished = true;
}
if (event.type === "plan_created") {
const plan = traceRecord(payload.plan);
for (const subtopic of traceStrings(payload.subtopics).concat(traceStrings(plan.subtopics))) {
@@ -2292,9 +2396,39 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
reportCompleted = true;
continue;
}
if (event.type === "image_generation_started" || event.type === "image_generated") {
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
const total = numberValue(payload.total);
if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total));
imageTotal = Math.max(imageTotal, position);
const prior = imageMap.get(position);
imageMap.set(position, {
position,
section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`,
status: event.type === "image_generated" ? "completed" : prior?.status === "completed" ? "completed" : "generating",
name: traceString(payload.name) ?? prior?.name,
alt: traceString(payload.alt) ?? prior?.alt,
});
continue;
}
if (event.type === "warning" || event.type === "error") {
const message = traceString(payload.message) ?? traceString(payload.detail);
if (message && !warnings.includes(message)) warnings.push(message);
if (traceString(payload.code) === "image_generation_failed") {
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
const total = numberValue(payload.total);
if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total));
imageTotal = Math.max(imageTotal, position);
const prior = imageMap.get(position);
imageMap.set(position, {
position,
section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`,
status: "failed",
name: prior?.name,
alt: prior?.alt,
error: message ?? "报告配图生成失败",
});
}
}
}
@@ -2308,12 +2442,15 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
curationReason,
compressedSourceCount,
reportCompleted,
imageTotal,
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
imagePhaseFinished,
warnings,
};
}
function phaseAtOrAfter(phase: string, boundary: string): boolean {
const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "reviewing", "illustrating", "exporting", "done"];
const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "summarizing", "reviewing", "illustrating", "exporting", "done"];
return Math.max(0, order.indexOf(phase)) >= Math.max(0, order.indexOf(boundary));
}
@@ -2345,7 +2482,7 @@ function ReasoningStream({ text }: { text: string }) {
);
}
const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean }) {
const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active, requestedImageCount }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean; requestedImageCount: number }) {
const [expanded, setExpanded] = useState(true);
const snapshot = useMemo(() => buildTraceSnapshot(events), [events]);
const sourcesById = useMemo(() => new Map(snapshot.sources.map((source) => [source.id, source])), [snapshot.sources]);
@@ -2358,6 +2495,14 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
const curatingDone = phaseAtOrAfter(phase, "writing") || snapshot.selectedIds.size > 0;
const writingDone = snapshot.reportCompleted || phase === "done";
const streamedQueries = planningDone ? [] : parseStreamedQueries(planText);
const imageStageVisible = requestedImageCount > 0 || snapshot.imageTotal > 0 || snapshot.images.length > 0;
const imageTotal = snapshot.imageTotal || requestedImageCount;
const completedImages = snapshot.images.filter((image) => image.status === "completed").length;
const failedImages = snapshot.images.filter((image) => image.status === "failed").length;
const processedImages = completedImages + failedImages;
const imageDone = imageStageVisible && (snapshot.imagePhaseFinished || phaseAtOrAfter(phase, "exporting") || (!active && phase === "done"));
const imageCurrent = imageStageVisible && !imageDone && (phase === "illustrating" || snapshot.images.some((image) => image.status === "generating"));
const imageProgress = imageTotal ? Math.min(100, Math.round((processedImages / imageTotal) * 100)) : 0;
return (
<div className="overflow-hidden rounded-2xl rounded-tl-md border border-border bg-card shadow-sm">
@@ -2390,6 +2535,12 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
<button type="button" onClick={onOpenSandbox} className="inline-flex items-center gap-1.5 rounded-md bg-primary/8 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/12"><FileText className="h-3.5 w-3.5" />{writingDone ? "查看已保存的 report.md" : "打开正在写入的 report.md"}<ChevronRight className="h-3.5 w-3.5" /></button>
</TraceStage>
{imageStageVisible && <TraceStage number={5} title="生成报告配图" detail={imageDone ? completedImages > 0 ? `已生成 ${completedImages} 张配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}。` : "配图未生成,已保留完整文字报告。" : imageCurrent ? `正在生成报告配图,已处理 ${processedImages}/${imageTotal || "?"} 张。` : "报告写完后会为关键章节生成并嵌入配图。"} done={imageDone} current={imageCurrent}>
{imageTotal > 0 && <div className="mb-3 h-2 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-primary transition-[width] duration-300" style={{ width: `${imageDone ? 100 : imageProgress}%` }} /></div>}
{snapshot.images.length > 0 ? <div className="space-y-2">{snapshot.images.map((image) => <div key={image.position} className="flex min-w-0 items-center gap-2 rounded-lg border border-border/70 bg-muted/[0.12] px-3 py-2"><ImagePlus className={`h-3.5 w-3.5 shrink-0 ${image.status === "completed" ? "text-emerald-600" : image.status === "failed" ? "text-amber-600" : "animate-pulse text-primary"}`} /><div className="min-w-0 flex-1"><p className="truncate text-xs font-medium">第 {image.position} 张 · {image.section}</p><p className="mt-0.5 text-[11px] text-muted-foreground">{image.status === "completed" ? `${image.name ?? "已保存并嵌入报告"}` : image.status === "failed" ? image.error ?? "生成失败,已保留文字报告" : "正在调用图片模型…"}</p></div><span className={`shrink-0 text-[11px] ${image.status === "completed" ? "text-emerald-700 dark:text-emerald-300" : image.status === "failed" ? "text-amber-700 dark:text-amber-300" : "text-primary"}`}>{image.status === "completed" ? "已生成" : image.status === "failed" ? "未生成" : "生成中"}</span></div>)}</div> : <div className="rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">{imageCurrent ? "正在准备第一张报告配图…" : `最多生成 ${imageTotal || requestedImageCount} 张,并在完成后插入对应章节。`}</div>}
{completedImages > 0 && <button type="button" onClick={onOpenSandbox} className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-primary/8 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/12"><ImagePlus className="h-3.5 w-3.5" />查看已嵌入报告的配图<ChevronRight className="h-3.5 w-3.5" /></button>}
</TraceStage>}
{snapshot.warnings.length > 0 && <div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-200"><p className="font-medium">过程提示</p>{snapshot.warnings.map((warning) => <p key={warning} className="mt-1">{warning}</p>)}</div>}
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。</p>}
</div>}
@@ -2398,7 +2549,8 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
});
function CompletionSummary({ session, sourceCount, onOpenSandbox, onExport }: { session: DeepResearchSession; sourceCount: number; onOpenSandbox: () => void; onExport: (format: "word" | "markdown" | "html") => void }) {
return <div className="rounded-2xl rounded-tl-md border border-emerald-500/20 bg-card p-4 shadow-sm"><div className="flex items-start gap-2"><div className="mt-0.5 rounded-full bg-emerald-500/12 p-1 text-emerald-600"><CheckCircle2 className="h-3.5 w-3.5" /></div><div><p className="text-sm font-medium">研究报告已完成</p><p className="mt-1 text-sm leading-6 text-muted-foreground">已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。</p></div></div><div className="mt-4 flex flex-wrap gap-2"><Button size="sm" onClick={onOpenSandbox}><FolderOpen className="mr-1.5 h-3.5 w-3.5" />查看报告</Button><Button size="sm" variant="outline" onClick={() => onExport("word")}><FileDown className="mr-1.5 h-3.5 w-3.5" />Word</Button><Button size="sm" variant="outline" onClick={() => onExport("markdown")}><FileCode2 className="mr-1.5 h-3.5 w-3.5" />Markdown</Button><Button size="sm" variant="outline" onClick={() => onExport("html")}><FileType2 className="mr-1.5 h-3.5 w-3.5" />HTML</Button></div></div>;
const imageCount = countEmbeddedReportImages(session.reportMarkdown);
return <div className="rounded-2xl rounded-tl-md border border-emerald-500/20 bg-card p-4 shadow-sm"><div className="flex items-start gap-2"><div className="mt-0.5 rounded-full bg-emerald-500/12 p-1 text-emerald-600"><CheckCircle2 className="h-3.5 w-3.5" /></div><div><p className="text-sm font-medium">研究报告已完成</p><p className="mt-1 text-sm leading-6 text-muted-foreground">已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。</p>{imageCount > 0 && <p className="mt-1.5 flex items-center gap-1.5 text-sm leading-6 text-emerald-700 dark:text-emerald-300"><ImagePlus className="h-3.5 w-3.5" />已生成 {imageCount} 张报告配图,已嵌入正文对应章节。</p>}</div></div><div className="mt-4 flex flex-wrap gap-2"><Button size="sm" onClick={onOpenSandbox}><FolderOpen className="mr-1.5 h-3.5 w-3.5" />查看报告</Button><Button size="sm" variant="outline" onClick={() => onExport("word")}><FileDown className="mr-1.5 h-3.5 w-3.5" />Word</Button><Button size="sm" variant="outline" onClick={() => onExport("markdown")}><FileCode2 className="mr-1.5 h-3.5 w-3.5" />Markdown</Button><Button size="sm" variant="outline" onClick={() => onExport("html")}><FileType2 className="mr-1.5 h-3.5 w-3.5" />HTML</Button></div></div>;
}
function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record<string, unknown> | undefined; submitting: boolean; onSubmit: (action: "approve" | "revise", feedback?: string) => void }) {
@@ -2409,8 +2561,132 @@ function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record<string, u
// ── advanced settings dialog ─────────────────────────────────────────────────
function AdvancedResearchSettingsDialog({ open, onOpenChange, mode, options, classicMode, onClassicModeChange, imageGenerationAvailable, imageLimit, onChange }: { open: boolean; onOpenChange: (open: boolean) => void; mode: ResearchMode; options: AdvancedResearchOptions; classicMode: boolean; onClassicModeChange: (value: boolean) => void; imageGenerationAvailable: boolean; imageLimit: number; onChange: <K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => void }) {
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto"><DialogHeader><DialogTitle>高级研究配置</DialogTitle><DialogDescription>本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。</DialogDescription></DialogHeader><div className="rounded-xl border border-border p-3"><label className="flex cursor-pointer gap-3"><input type="checkbox" checked={classicMode} onChange={(event) => onClassicModeChange(event.target.checked)} className="mt-0.5 accent-primary" /><span><span className="block text-sm font-medium">经典模式</span><span className="mt-1 block text-xs leading-5 text-muted-foreground">回退到旧全流程:输入框直接选模式并自动开始研究,不经过收集对话与报告配置卡片。仅影响下一次新建的研究。</span></span></label></div><div className="grid gap-5 py-2 sm:grid-cols-2"><SettingSelect label="输出语言" value={options.language} onValueChange={(value) => onChange("language", value as AdvancedResearchOptions["language"])} options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]} /><SettingSelect label="报告语气" value={options.tone} onValueChange={(value) => onChange("tone", value as ResearchTone)} options={[{ value: "objective", label: "客观" }, { value: "formal", label: "正式" }, { value: "analytical", label: "分析" }, { value: "persuasive", label: "说服" }]} /><NumberSetting label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(value) => onChange("maxSearchResults", value)} /><NumberSetting label="子问题轮数" hint="1–10 轮" value={options.maxIterations} onChange={(value) => onChange("maxIterations", value)} /><NumberSetting label="最大并发" hint="1–8 路" value={options.maxConcurrency} onChange={(value) => onChange("maxConcurrency", value)} /><NumberSetting label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(value) => onChange("maxContextWords", value)} />{(mode === "detailed" || mode === "multi_agent") && <NumberSetting label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(value) => onChange("maxSubtopics", value)} />}{mode === "deep" && <><NumberSetting label="深挖广度" hint="1–8" value={options.deepBreadth} onChange={(value) => onChange("deepBreadth", value)} /><NumberSetting label="深挖层级" hint="1–4" value={options.deepDepth} onChange={(value) => onChange("deepDepth", value)} /></>}{mode === "multi_agent" && <NumberSetting label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(value) => onChange("maxRevisionLoops", value)} />}</div><div className="space-y-2"><label htmlFor="research-outline" className="text-sm font-medium">自定义报告结构</label><Textarea id="research-outline" value={options.customOutline} onChange={(event) => onChange("customOutline", event.target.value)} className="min-h-28 resize-y" placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险"} /><p className="text-xs text-muted-foreground">支持 Markdown 标题或编号列表,会作为报告写作结构要求。</p></div><div className="grid gap-3 sm:grid-cols-2"><label className="flex cursor-pointer gap-3 rounded-xl border p-3"><input type="checkbox" checked={options.thinkingEnabled} onChange={(event) => onChange("thinkingEnabled", event.target.checked)} className="mt-0.5 accent-primary" /><span><span className="block text-sm font-medium">启用模型深度思考</span><span className="mt-1 block text-xs leading-5 text-muted-foreground">仅在当前系统默认模型支持时生效,会增加响应时延与模型消耗。</span></span></label>{imageGenerationAvailable && <div className="rounded-xl border p-3"><NumberSetting label="最多报告配图" hint={`1–${imageLimit} 张`} value={options.maxGeneratedImages} onChange={(value) => onChange("maxGeneratedImages", value)} /></div>}</div><DialogFooter><Button onClick={() => onOpenChange(false)}>保存配置</Button></DialogFooter></DialogContent></Dialog>;
function AdvancedResearchSettingsDialog({
open,
onOpenChange,
mode,
options,
classicMode,
onClassicModeChange,
imageGenerationAvailable,
imageLimit,
onChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: ResearchMode;
options: AdvancedResearchOptions;
classicMode: boolean;
onClassicModeChange: (value: boolean) => void;
imageGenerationAvailable: boolean;
imageLimit: number;
onChange: <K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] w-[calc(100vw-2rem)] max-w-none overflow-y-auto sm:w-[600px] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>高级研究配置</DialogTitle>
<DialogDescription>
本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border border-border p-3">
<label className="flex cursor-pointer gap-3">
<input
type="checkbox"
checked={classicMode}
onChange={(event) => onClassicModeChange(event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block text-sm font-medium">经典模式</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
回退到旧全流程:输入框直接选模式并自动开始研究,不经过收集对话与报告配置卡片。仅影响下一次新建的研究。
</span>
</span>
</label>
</div>
<div className="grid gap-5 py-2 sm:grid-cols-2">
<SettingSelect
label="输出语言"
value={options.language}
onValueChange={(value) => onChange("language", value as AdvancedResearchOptions["language"])}
options={[
{ value: "zh-CN", label: "中文" },
{ value: "en-US", label: "English" },
]}
/>
<SettingSelect
label="报告语气"
value={options.tone}
onValueChange={(value) => onChange("tone", value as ResearchTone)}
options={[
{ value: "objective", label: "客观" },
{ value: "formal", label: "正式" },
{ value: "analytical", label: "分析" },
{ value: "persuasive", label: "说服" },
]}
/>
<NumberSetting label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(value) => onChange("maxSearchResults", value)} />
<NumberSetting label="子问题轮数" hint="1–10 轮" value={options.maxIterations} onChange={(value) => onChange("maxIterations", value)} />
<NumberSetting label="最大并发" hint="1–8 路" value={options.maxConcurrency} onChange={(value) => onChange("maxConcurrency", value)} />
<NumberSetting label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(value) => onChange("maxContextWords", value)} />
{(mode === "detailed" || mode === "multi_agent") && (
<NumberSetting label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(value) => onChange("maxSubtopics", value)} />
)}
{mode === "deep" && (
<>
<NumberSetting label="深挖广度" hint="1–8" value={options.deepBreadth} onChange={(value) => onChange("deepBreadth", value)} />
<NumberSetting label="深挖层级" hint="1–4" value={options.deepDepth} onChange={(value) => onChange("deepDepth", value)} />
</>
)}
{mode === "multi_agent" && (
<NumberSetting label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(value) => onChange("maxRevisionLoops", value)} />
)}
</div>
<ReportOutlineEditor
id="research-outline"
value={options.customOutline}
onChange={(value) => onChange("customOutline", value)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex cursor-pointer gap-3 rounded-xl border p-3">
<input
type="checkbox"
checked={options.thinkingEnabled}
onChange={(event) => onChange("thinkingEnabled", event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block text-sm font-medium">启用模型深度思考</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
仅在当前系统默认模型支持时生效,会增加响应时延与模型消耗。
</span>
</span>
</label>
{imageGenerationAvailable && (
<div className="rounded-xl border p-3">
<NumberSetting
label="最多报告配图"
hint={`1–${imageLimit} 张`}
value={options.maxGeneratedImages}
onChange={(value) => onChange("maxGeneratedImages", value)}
/>
</div>
)}
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>保存配置</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function NumberSetting({ label, hint, value, onChange }: { label: string; hint: string; value: string; onChange: (value: string) => void }) { return <label className="block"><span className="mb-2 block text-sm font-medium">{label}</span><Input value={value} inputMode="numeric" onChange={(event) => onChange(event.target.value)} /><span className="mt-1 block text-xs text-muted-foreground">{hint}</span></label>; }
@@ -8,7 +8,7 @@
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
*/
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
ChevronRight,
ImagePlus,
@@ -65,6 +65,198 @@ export type AdvancedResearchOptions = {
customOutline: string;
};
/**
* A deliberately domain-neutral structure which gives lightweight models a
* dependable writing scaffold without forcing market-analysis terminology on
* every research topic. Users can edit or replace it in the advanced dialog.
*/
export const DEFAULT_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
# 一、研究对象与事实基础
## 1.1 概念、范围与背景
界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
## 1.2 已有事实与证据
按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
## 1.3 当前状态与主要特征
归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
# 二、问题形成与作用机制
## 2.1 直接原因与触发条件
分析近期事件、条件变化或关键节点如何推动问题出现或演变。
## 2.2 深层影响因素
从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
## 2.3 因果关系与相互作用
说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
# 三、影响、问题与不同观点
## 3.1 对研究对象的直接影响
从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
## 3.2 对相关主体与外部环境的影响
说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
## 3.3 主要争议与解释差异
并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
# 四、比较、趋势与不确定性
## 4.1 可比案例或横向比较
在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
## 4.2 发展趋势与关键变量
识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
## 4.3 风险情景与不确定性
说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
# 五、结论、建议与后续关注
## 5.1 核心结论
用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
## 5.2 可行建议或应对方向
仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
## 5.3 资料局限与后续关注
列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。`;
/**
* A causal, chapter-to-chapter alternative for topics where the report should
* read as one continuous argument rather than a set of parallel dimensions.
* Each chapter explicitly takes the previous chapter's conclusion as input.
*/
export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
# 一、事实基线:问题是什么
## 1.1 概念界定与研究范围
明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
## 1.2 关键事实、数据与证据
按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
## 1.3 基线判断与待解问题
基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
# 二、形成机制:为什么会这样
## 2.1 直接原因与触发条件
从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
## 2.2 深层条件与关键约束
结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
## 2.3 因果链条与关键变量
把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
# 三、结果与影响:会带来什么
## 3.1 直接结果与短期影响
依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
## 3.2 传导效应与中长期影响
继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
## 3.3 权衡、分歧与边界
比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
# 四、趋势与应对:接下来怎么办
## 4.1 未来走势与观察指标
从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
## 4.2 可选方案与适用条件
针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
## 4.3 风险情景与验证重点
说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
# 五、综合结论与后续关注
## 5.1 核心结论
按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
## 5.2 局限与后续研究
列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
export type ReportOutlineTemplate = "general" | "progressive" | "custom";
const REPORT_OUTLINE_TEMPLATE_OPTIONS: Array<{ value: ReportOutlineTemplate; label: string }> = [
{ value: "general", label: "通用分析结构" },
{ value: "progressive", label: "递进式论证结构" },
{ value: "custom", label: "自定义结构" },
];
function matchingOutlineTemplate(outline: string): ReportOutlineTemplate {
const normalized = outline.trim();
if (normalized === DEFAULT_REPORT_OUTLINE.trim()) return "general";
if (normalized === PROGRESSIVE_REPORT_OUTLINE.trim()) return "progressive";
return "custom";
}
/** Shared by the initial settings dialog and the write-confirmation card. */
export function ReportOutlineEditor({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
const [customSelected, setCustomSelected] = useState(false);
const template = customSelected ? "custom" : matchingOutlineTemplate(value);
const selectTemplate = (next: ReportOutlineTemplate) => {
if (next === "custom") {
setCustomSelected(true);
// “自定义” is an intentional blank starting point. The two presets are
// still available as one-click scaffolds whenever the user wants them.
onChange("");
return;
}
setCustomSelected(false);
onChange(next === "general" ? DEFAULT_REPORT_OUTLINE : PROGRESSIVE_REPORT_OUTLINE);
};
return (
<div className="space-y-2">
<label htmlFor={`${id}-template`} className="block text-xs font-medium">
报告结构模板
</label>
<Select value={template} onValueChange={(next) => selectTemplate(next as ReportOutlineTemplate)}>
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_OUTLINE_TEMPLATE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<label htmlFor={id} className="block pt-1 text-xs font-medium">
报告结构(可编辑)
</label>
<Textarea
id={id}
value={value}
onChange={(event) => {
setCustomSelected(true);
onChange(event.target.value);
}}
maxLength={4000}
className="min-h-28 resize-y text-xs"
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据"}
/>
<p className="text-[10px] leading-4 text-muted-foreground">
选择预设模板会替换下方内容;选择“自定义”会清空后从头编辑。支持 Markdown 标题或编号列表。
</p>
</div>
);
}
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
language: "zh-CN",
tone: "objective",
@@ -78,7 +270,7 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
maxRevisionLoops: "2",
maxGeneratedImages: "2",
thinkingEnabled: false,
customOutline: "",
customOutline: DEFAULT_REPORT_OUTLINE,
};
/** Seed the card's editable options from the frozen session config snapshot. */
@@ -100,7 +292,9 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
thinkingEnabled: config?.thinking_enabled === true,
customOutline: typeof config?.custom_outline === "string" ? config.custom_outline : "",
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
? config.custom_outline
: DEFAULT_REPORT_OUTLINE,
};
}
@@ -174,11 +368,6 @@ export function ReportConfigCard({
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
setOptions(defaultOptions);
setGenerateImages(defaultGenerateImages);
}, [defaultOptions, defaultGenerateImages]);
const modes = useMemo(
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
[availableModes],
@@ -278,15 +467,11 @@ export function ReportConfigCard({
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
)}
<div className="sm:col-span-2">
<span className="mb-1.5 block text-xs font-medium">自定义报告结构</span>
<Textarea
<ReportOutlineEditor
id="report-card-outline"
value={options.customOutline}
onChange={(event) => update("customOutline", event.target.value)}
maxLength={4000}
className="min-h-20 resize-y text-xs"
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"}
onChange={(value) => update("customOutline", value)}
/>
<p className="mt-1 text-[10px] text-muted-foreground">支持 Markdown 标题或编号列表,作为报告写作结构要求。</p>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
<input
@@ -10,11 +10,12 @@
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
*/
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { FileTextIcon, XIcon } from "lucide-react";
import { FileTextIcon, Loader2Icon, SparklesIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { ArtifactAction } from "@/components/ai-elements/artifact";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
@@ -136,18 +137,6 @@ export function ResearchSandboxPanel({
<div className="relative h-full bg-[#fffdfa] p-4">
{selectedArtifact && previewThreadId ? (
<>
{onDocumentRewriteStart && (
<div className="absolute top-7 right-36 z-30">
<Button
size="sm"
variant="outline"
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
>
{documentRewriteActive ? "正在改写" : "AI 全文改写"}
</Button>
</div>
)}
{documentRewriteActive && onCancelDocumentRewrite && (
<div className="absolute right-36 bottom-7 z-30">
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
@@ -159,8 +148,24 @@ export function ResearchSandboxPanel({
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
researchSessionId={previewThreadId}
onClose={handleArtifactsClose}
onDownloadClick={onDownload}
toolbarActions={
onDocumentRewriteStart ? (
<ArtifactAction
icon={documentRewriteActive ? Loader2Icon : SparklesIcon}
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成临时草稿,原报告尚未被覆盖"
: "使用 AI 重写完整 report.md"
}
disabled={documentRewriteActive}
onClick={() => setRewriteDialogOpen(true)}
/>
) : null
}
/>
</>
) : (
@@ -20,6 +20,7 @@ import {
artifactPreviewKey,
useArtifacts,
} from "@/components/workspace/artifacts";
import { stripInlineThinkTags } from "@/core/streamdown/inline-think";
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
@@ -72,7 +73,12 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
setStubMessage((prev) => {
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
const content = report.trim() ? report : prevContent;
// Backstop the report sandbox against providers that expose reasoning
// through raw `<think>` tags in ordinary text chunks. The backend
// filters them at the source; this also protects historical/replayed
// streams generated by an older service.
const visibleReport = stripInlineThinkTags(report);
const content = visibleReport.trim() ? visibleReport : prevContent;
return {
id: messageId,
type: "ai",
@@ -29,6 +29,11 @@ export interface PositionBusinessChainFlowProps {
chain: BusinessChain | null;
loading?: boolean;
className?: string;
/**
* Render inside the Step 1 intent-delivery surface. The host owns the
* border and background; this flow keeps only its title row and canvas.
*/
embedded?: boolean;
}
function getFlowStages(chain: BusinessChain): FlowStage[] {
@@ -50,6 +55,7 @@ export function PositionBusinessChainFlow({
chain,
loading = false,
className,
embedded = false,
}: PositionBusinessChainFlowProps) {
const [dialogOpen, setDialogOpen] = useState(false);
@@ -57,26 +63,40 @@ export function PositionBusinessChainFlow({
<section
className={cn(
"position-business-chain-flow flex h-full min-h-[460px] w-full flex-col overflow-hidden rounded-2xl border border-[#ece7e4] bg-white p-3 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none",
embedded && "min-h-0 rounded-none border-0 bg-transparent p-0 shadow-none",
className,
)}
>
<div className="mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40">
<div
className={cn(
"mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40",
embedded && "relative mb-4 h-10 border-slate-200 pb-0 dark:border-[#2b8ffe]/60",
)}
>
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]"
className={cn(
"h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]",
embedded && "h-6",
)}
/>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-slate-900 dark:text-white">业务链条</h2>
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
{!embedded && (
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
)}
</div>
</div>
{chain && (
<button
type="button"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]"
className={cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]",
embedded && "h-7 w-7 rounded-[4px]",
)}
title="查看完整流程图"
aria-label="查看完整流程图"
onClick={() => setDialogOpen(true)}
@@ -92,7 +112,7 @@ export function PositionBusinessChainFlow({
</div>
) : (
<>
<BusinessChainCanvas chain={chain} className="min-h-0 flex-1" />
<BusinessChainCanvas chain={chain} embedded={embedded} 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">
@@ -112,18 +132,20 @@ export function PositionBusinessChainFlow({
function BusinessChainCanvas({
chain,
expanded = false,
embedded = false,
className,
}: {
chain: BusinessChain;
expanded?: boolean;
embedded?: boolean;
className?: string;
}) {
const [view, setView] = useState({ tx: 0, ty: 0 });
const [panning, setPanning] = useState(false);
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const stages = getFlowStages(chain);
const nodeWidth = expanded ? 250 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 18 : FLOW_NODE_GAP;
const nodeWidth = expanded ? 250 : embedded ? 158 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 18 : embedded ? 10 : FLOW_NODE_GAP;
let colorIndex = 0;
const endPan = (event: React.PointerEvent<HTMLDivElement>) => {
@@ -141,6 +163,7 @@ function BusinessChainCanvas({
<div
className={cn(
"relative overflow-hidden rounded-lg border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75",
embedded && "rounded-[4px]",
panning ? "cursor-grabbing" : "cursor-grab",
className,
)}
@@ -193,6 +216,7 @@ function BusinessChainCanvas({
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
accent={accent}
width={nodeWidth}
compact={embedded}
/>
);
})}
@@ -252,17 +276,22 @@ function FlowNode({
accent,
completed = false,
width,
compact = false,
}: {
title: string;
description: string;
accent: string;
completed?: boolean;
width: number;
compact?: boolean;
}) {
const StatusIcon = completed ? CheckCircle2 : Circle;
return (
<article
className="relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]"
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 }}
>
<span
@@ -459,7 +459,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
// Clarifications collected live from `type:"tool"` frames, deduped by
// tool_call_id, then reconciled against the complete terminal list.
const seenClarificationIds = new Set<string>();
// A clarification tool call is a hard pause in the Step 1 conversation.
// Some model providers keep emitting text (and can even produce an
// INTENT_READY terminal frame) after the tool result. That text belongs to
// a continuation the user has not approved yet, so never let it replace the
// assistance card in the current turn.
let awaitingUserClarification = false;
const collectClarification = (payload: ClarificationPayload, toolCallId: string) => {
awaitingUserClarification = true;
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
if (seenClarificationIds.has(dedupeKey)) return;
seenClarificationIds.add(dedupeKey);
@@ -501,6 +508,25 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
}
if (isFinalIntentFrame(parsed)) {
// Reconcile any terminal clarification cards, but preserve the first
// tool-call pause as the authoritative state for this turn. In
// particular, do not accept a later `done` frame / summary before the
// user has answered the displayed questions.
if (awaitingUserClarification) {
if (parsed.status === "clarification") {
const terminalCards = Array.isArray(parsed.clarifications)
? parsed.clarifications
.map(parseTerminalClarification)
.filter((item): item is { payload: ClarificationPayload; toolCallId: string } => item !== null)
: [];
if (terminalCards.length > 0) {
terminalCards.forEach((card, index) => {
collectClarification(card.payload, card.toolCallId || `terminal-${index}`);
});
}
}
continue;
}
result.status = parsed.status;
if (typeof parsed.content === "string") result.content = parsed.content;
if (parsed.summary && typeof parsed.summary === "object") {
@@ -548,6 +574,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
continue;
}
// Do not show follow-up thinking / generated summary text while the
// user-assistance card is waiting for an answer. This also prevents a
// later AI delta from hiding the card through an [INTENT_READY] block.
if (awaitingUserClarification) continue;
// Phase inference from LangGraph messages-tuple frames. Tool calls
// (typically ask_clarification) flip phase to tool_calling; AI text
// chunks flip to streaming. Everything else leaves phase alone.
@@ -592,5 +623,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
}
}
if (awaitingUserClarification && result.clarifications.length > 0) {
result.status = "clarification";
result.summary = null;
result.clarification = result.clarifications[0] ?? null;
}
return result;
}
@@ -41,6 +41,7 @@ function formatFileSize(bytes: number): string {
const SHOW_BACKGROUND_SUSPEND = true;
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
@@ -93,6 +94,8 @@ interface ModelOption {
}
export interface Step2PanelProps {
/** 独立业务页需要与第一步完全相同的输入组件(8BF 专用,默认不改变原会商)。 */
useStep1InputAppearance?: boolean;
// ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean;
@@ -161,6 +164,7 @@ export interface Step2PanelProps {
export function Step2Panel(props: Step2PanelProps) {
const {
useStep1InputAppearance = false,
step2RoundtableDialogues,
isInitializing,
isRoundTableRunning,
@@ -377,18 +381,15 @@ export function Step2Panel(props: Step2PanelProps) {
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
const [injectFiles, setInjectFiles] = useState<File[]>([]);
const injectFileInputRef = useRef<HTMLInputElement>(null);
const handlePickInjectFiles = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
const addInjectFiles = useCallback(
(picked: File[]) => {
if (picked.length > 0) {
setInjectFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
e.target.value = "";
},
[],
);
@@ -730,92 +731,71 @@ export function Step2Panel(props: Step2PanelProps) {
</div>
)}
{/* Console + intervention bar */}
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
{/* 已选附件预览(点「干预并发送」时随指令上传) */}
{injectFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{injectFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
{useStep1InputAppearance ? (
/* 8BF 直接复用第一步 PositionIntentInput 的 DOM、图标和深蓝框体;仅替换成会商干预的状态与动作。 */
<div className="position-roundtable-intent-page eightbf-roundtable-step2-input [&_#suggest-followup-btn]:hidden [&_#skip-question-btn]:hidden [&_#save-draft-btn]:hidden">
<PositionIntentInput
value={customAgentInput}
onValueChange={setCustomAgentInput}
attachedFiles={injectFiles}
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
charactersLeft={Math.max(0, 500 - customAgentInput.length)}
isSending={false}
canSend
pendingClarification={false}
onSend={handleInject}
onStop={() => undefined}
onFollowUp={() => undefined}
onSkip={() => undefined}
onEnterRoundtable={() => undefined}
enterButtonLabel=""
intentReady={null}
hideEnterRoundtable
onManualSave={() => undefined}
onTriggerToast={() => undefined}
modelOptions={availableModels.map((model) => ({
label: model.display_name && model.display_name !== model.name
? `${model.display_name} (${model.name})`
: model.name,
value: model.name,
}))}
selectedModel={selectedModel}
onSelectedModelChange={setSelectedModel}
footerAction={(
/* 与第一步「确认意图并启动业务链」复用相同的稳定 id 和样式规则。 */
<button
id="enter-roundtable-btn"
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={cn(
"flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] px-3 text-xs font-black transition active:scale-[0.98] shadow-md shadow-emerald-500/10",
hasConsensus
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
: "cursor-not-allowed bg-emerald-600/40 text-white",
)}
>
<FileIcon className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">
{formatFileSize(file.size)}
</span>
<button
type="button"
onClick={() => removeInjectFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-3">
<AutoGrowTextarea
value={customAgentInput}
onChange={(e) => setCustomAgentInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleInject();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs leading-relaxed focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
maxHeight={280}
/>
<input
ref={injectFileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickInjectFiles}
/>
<button
type="button"
onClick={() => injectFileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
>
<Paperclip className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleInject}
title="记录干预指令并立即恢复会商执行"
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预并发送
</button>
<button
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
hasConsensus
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
<Sparkles className="size-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
)}
/>
</div>
</div>
) : (
<Step2LegacyComposer
value={customAgentInput}
onValueChange={setCustomAgentInput}
files={injectFiles}
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
onSend={handleInject}
hasConsensus={hasConsensus}
onProceed={onProceedToStep3}
/>
)}
</>
);
@@ -834,6 +814,113 @@ export function Step2Panel(props: Step2PanelProps) {
);
}
/** 原通用会商的输入区。8BF 使用第一步复用组件时,其他会商页面保持此既有结构。 */
function Step2LegacyComposer({
value,
onValueChange,
files,
onAddFiles,
onRemoveFile,
onSend,
hasConsensus,
onProceed,
}: {
value: string;
onValueChange: React.Dispatch<React.SetStateAction<string>>;
files: File[];
onAddFiles: (files: File[]) => void;
onRemoveFile: (index: number) => void;
onSend: () => void;
hasConsensus: boolean;
onProceed: () => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<div className="shrink-0 flex flex-col gap-2 border-t border-slate-100 bg-slate-50 p-4">
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{files.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-lg border border-slate-200 bg-white py-1 pl-2 pr-1 text-[11px] text-slate-600"
>
<FileIcon className="size-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>{file.name}</span>
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 rounded p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-3">
<AutoGrowTextarea
value={value}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="min-h-[48px] flex-1 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-xs font-medium leading-relaxed focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
maxHeight={280}
/>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
onAddFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="shrink-0 cursor-pointer rounded-xl border border-slate-200 bg-white p-2.5 text-slate-500 transition hover:bg-slate-50 hover:text-primary active:scale-95"
>
<Paperclip className="size-4" />
</button>
<button
type="button"
onClick={onSend}
title="记录干预指令并立即恢复会商执行"
className="shrink-0 cursor-pointer rounded-xl bg-primary px-4 py-2.5 text-xs font-bold text-white shadow-md shadow-primary/10 transition hover:bg-[var(--brand-900)] active:scale-95"
>
干预并发送
</button>
<button
type="button"
onClick={onProceed}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={cn(
"flex shrink-0 items-center gap-1 rounded-xl px-4 py-2.5 text-xs font-black shadow-md shadow-emerald-500/10 transition active:scale-95",
hasConsensus
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
: "cursor-not-allowed bg-emerald-600/40 text-white",
)}
>
<Sparkles className="size-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
);
}
/**
* 单条 Step 2 对话气泡。包含:
* - ChainOfThought 折叠卡(若 msg.steps 非空)
@@ -94,7 +94,7 @@ import { flowNodeMeta, type FlowNodeType } from "@/roundtable-planning/lib/flow-
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step1Panel, type Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
import { readActionDetail, readCopTaskDetail } from "@/core/auth/task-deeplink";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
@@ -306,6 +306,24 @@ export default function RoundtablePlanningPage({
entryRailTaskId,
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
rootClassName,
/** 独立业务页替换第一步消息工作区的纯展示组件;状态和行为仍由本页统一拥有。 */
renderStep1,
/** 独立业务页替换第一步左侧入口栏。 */
renderStep1LeftSidebar,
/** 独立业务页在第二步左栏顶部复用自己的任务/意图展示。 */
renderStep2LeftSidebarHeader,
/** 第一阶段使用内嵌右栏时,隐藏通用的模型/摘要/澄清记录栏。 */
hideDefaultStep1RightSidebar = false,
/** 独立业务页的工作区品牌区,取代通用步骤导航。 */
headerLeftSlot,
/** 放在历史记录后面的独立业务操作或状态。 */
headerActionAfterHistory,
/** 独立业务页可隐藏通用的“保存草稿”按钮。 */
hideHeaderSaveDraft = false,
/** 独立业务页可调整新建动作的展示文案,行为不变。 */
newTaskLabel,
/** 独立业务页可隐藏通用的设置入口。 */
hideHeaderSettings = false,
}: {
compactChrome?: boolean;
lockedChainId?: string | null;
@@ -317,6 +335,22 @@ export default function RoundtablePlanningPage({
taskContext?: Record<string, unknown> | null;
entryRailTaskId?: string | null;
rootClassName?: string;
renderStep1?: (props: Step1PanelProps & {
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
}) => ReactNode;
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode;
renderStep2LeftSidebarHeader?: (context: {
intentReady: object | null;
onCollapse: () => void;
}) => ReactNode;
hideDefaultStep1RightSidebar?: boolean;
headerLeftSlot?: ReactNode;
headerActionAfterHistory?: ReactNode;
hideHeaderSaveDraft?: boolean;
newTaskLabel?: string;
hideHeaderSettings?: boolean;
}) {
const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
@@ -344,6 +378,13 @@ export default function RoundtablePlanningPage({
const draftStorageTaskId = storageTaskId ?? recordTaskIdOf(prefillRef.current);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
// 固定业务链(8BF 等独立页面)的解析状态。不能只以 autoChainResult 是否为空判断:
// 它为空既可能是「还在拉取」,也可能是「业务链已配置但可用席位不足」。
// 若混为一谈,用户刚保存配置后立刻点击进入研讨时会被错误提示为「未配置业务链」。
const [lockedChainResolution, setLockedChainResolution] = useState<
"idle" | "loading" | "ready" | "unavailable"
>("idle");
const [lockedChainUnavailableReason, setLockedChainUnavailableReason] = useState<string | null>(null);
/** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */
const [autoChainLoading, setAutoChainLoading] = useState(
() =>
@@ -355,21 +396,57 @@ export default function RoundtablePlanningPage({
useEffect(() => {
if (!lockedChainId) {
setAutoChainResult(null);
setLockedChainResolution("idle");
setLockedChainUnavailableReason(null);
return;
}
let cancelled = false;
// 映射切换时先清掉旧链结果,避免异步请求尚未返回时误用上一条业务链进入研讨。
setAutoChainResult(null);
setLockedChainResolution("loading");
setLockedChainUnavailableReason(null);
setAutoChainLoading(true);
void Promise.all([getChain(lockedChainId), listAgents().catch(() => [])])
void Promise.all([getChain(lockedChainId), listAgents()])
.then(([chain, agents]) => {
if (cancelled || !chain) return;
const result = buildChainSeatSelection(chain, filterRecommendCandidates(agents));
if (!cancelled) setAutoChainResult(result);
if (cancelled) return;
if (!chain) {
setAutoChainResult(null);
setLockedChainResolution("unavailable");
setLockedChainUnavailableReason("已配置的业务链不存在或未发布,请重新选择一条已发布的业务链");
return;
}
const candidates = filterRecommendCandidates(agents);
const result = buildChainSeatSelection(chain, candidates);
if (!cancelled) {
setAutoChainResult(result);
setLockedChainResolution(result ? "ready" : "unavailable");
if (!result) {
const availableAgentIds = new Set(candidates.map((agent) => agent.agent_id));
const unavailableSeats = chain.seats
.filter((seat) => !availableAgentIds.has(seat.agent_id))
.map((seat) => seat.name || seat.agent_id);
setLockedChainUnavailableReason(
unavailableSeats.length > 0
? `业务链中的智能体「${unavailableSeats.join("、")}」尚未发布、已删除或当前账号无访问权限;请先将它们发布,并保证至少有 2 个可用智能体`
: "业务链至少需要 2 个可用智能体,请检查业务链席位配置",
);
}
}
})
.catch(() => {
if (!cancelled) setAutoChainResult(null);
if (!cancelled) {
setAutoChainResult(null);
setLockedChainResolution("unavailable");
setLockedChainUnavailableReason("读取已配置业务链或智能体失败,请刷新后重试");
}
})
.finally(() => {
if (!cancelled) setAutoChainLoading(false);
if (!cancelled) {
setAutoChainLoading(false);
// getChain 返回 null 时不会进入 then 内的 result 分支,同样标记为不可用。
setLockedChainResolution((current) => current === "loading" ? "unavailable" : current);
setLockedChainUnavailableReason((current) => current ?? "已配置的业务链暂不可用,请重新选择后重试");
}
});
return () => {
cancelled = true;
@@ -1963,9 +2040,66 @@ export default function RoundtablePlanningPage({
triggerToast("追问生成就绪!");
}, [step1, triggerToast]);
// 独立业务页可只替换 Step 1 的外观;这里将通用状态/动作收敛为一份受控 props,
// 避免它们复制或分叉意图识别、附件上传、澄清和进入研讨的业务逻辑。
const step1PanelProps: Step1PanelProps = {
interactiveMessages: step1.interactiveMessages,
intentThreadId: step1.intentThreadId,
intentReady: step1.intentReady,
isIntentSending: step1.isIntentSending,
pendingIntentClarificationMsgId: step1.pendingIntentClarificationMsgId,
intentClarificationSelections: step1.intentClarificationSelections,
setIntentClarificationSelections: step1.setIntentClarificationSelections,
onSendMessage: (text, files) => void step1.sendIntentMessage(text, files),
onStopIntent: step1.stopIntentStream,
onSkipQuestion: handleSkipQuestion,
onFollowUp: handleAgentFollowUp,
onEnterRoundtable: () => {
if (!step1.intentReady) {
triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
if (autoChainResult) {
const result = autoChainResult;
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
if (lockBusinessChain) {
triggerToast(
autoChainLoading || lockedChainResolution === "loading"
? "正在加载管理员配置的业务链,请稍候…"
: lockedChainResolution === "unavailable"
? lockedChainUnavailableReason ?? "已配置的业务链暂不可用,请确认至少保留 2 个可用智能体后重试"
: "管理员尚未配置可用的业务链,请联系管理员处理",
);
return;
}
if (
inWujieEmbed &&
autoChainLoading &&
(prefillRef.current?.chainId || prefillRef.current?.businessCode)
) {
triggerToast("正在加载业务链条,请稍候…");
return;
}
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
},
onManualSave: drafts.handleManualSave,
onTriggerToast: triggerToast,
submitIntentClarifications: step1.submitIntentClarifications,
};
// 定制 chrome 仅服务第一阶段;第二、三阶段继续保留原有步骤导航与操作,
// 避免独立业务页为了复刻第一步视觉而收窄后续流程的可达性。
const useCustomStep1Chrome = currentStep === 1 && Boolean(headerLeftSlot);
// ─── Render ──────────────────────────────────────────────
return (
<div
data-roundtable-step={currentStep}
className={cn(
"roundtable-planning theme-transition relative flex h-full min-h-0 flex-col bg-[#fef6f6] font-sans text-slate-800 selection:bg-red-200",
isDark && "roundtable-planning--dark",
@@ -2068,13 +2202,15 @@ export default function RoundtablePlanningPage({
{/* HEADER */}
<header ref={headerDzRef} className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
<StepNavigation
currentStep={currentStep}
intentReady={step1.intentReady}
hasConsensus={step2.hasConsensus}
onStepClick={(s) => setCurrentStep(s)}
triggerToast={triggerToast}
/>
{useCustomStep1Chrome ? headerLeftSlot : (
<StepNavigation
currentStep={currentStep}
intentReady={step1.intentReady}
hasConsensus={step2.hasConsensus}
onStepClick={(s) => setCurrentStep(s)}
triggerToast={triggerToast}
/>
)}
<div className="flex items-center gap-2">
{toolbarSlot}
@@ -2123,18 +2259,20 @@ export default function RoundtablePlanningPage({
className="bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-emerald-500/10 active:scale-95"
>
<Sparkles className="w-3.5 h-3.5" />
新建任务
{useCustomStep1Chrome ? (newTaskLabel ?? "新建任务") : "新建任务"}
</button>
)}
<button
id="save-draft-header-btn"
onClick={drafts.handleManualSave}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Save className="w-3.5 h-3.5 text-slate-500" />
保存草稿
</button>
{(!hideHeaderSaveDraft || !useCustomStep1Chrome) && (
<button
id="save-draft-header-btn"
onClick={drafts.handleManualSave}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Save className="w-3.5 h-3.5 text-slate-500" />
保存草稿
</button>
)}
{/* 「导入会商」按钮先隐藏(保留代码 + 弹窗逻辑,便于以后恢复)。 */}
{false && (
@@ -2172,22 +2310,26 @@ export default function RoundtablePlanningPage({
onCancelRename={drafts.handleCancelRename}
/>
<div className="h-6 w-[1px] bg-slate-200 mx-1" />
{useCustomStep1Chrome && headerActionAfterHistory}
<button
id="settings-btn"
onClick={handleSettingsClick}
title={
inWujieEmbed
? "回传各智能体交付结果给宿主"
: getStoredAuth()?.system_role === "admin"
? "会商诊断日志"
: "系统设置"
}
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
>
<Settings className="w-4 h-4" />
</button>
{(!hideHeaderSettings || !useCustomStep1Chrome) && <div className="h-6 w-[1px] bg-slate-200 mx-1" />}
{(!hideHeaderSettings || !useCustomStep1Chrome) && (
<button
id="settings-btn"
onClick={handleSettingsClick}
title={
inWujieEmbed
? "回传各智能体交付结果给宿主"
: getStoredAuth()?.system_role === "admin"
? "会商诊断日志"
: "系统设置"
}
className="p-1.5 text-slate-400 hover:text-slate-600 rounded-lg hover:bg-slate-100 transition-colors dark:text-muted-foreground dark:hover:bg-accent dark:hover:text-foreground"
>
<Settings className="w-4 h-4" />
</button>
)}
{compactChrome && (
<>
<ThemeSwitcher tone="content" />
@@ -2203,7 +2345,10 @@ export default function RoundtablePlanningPage({
sidebarExpanded={step2SidebarExpanded}
setSidebarExpanded={setStep2SidebarExpanded}
/>
<main className="relative z-0 flex-1 overflow-hidden p-4 flex gap-4 min-h-0">
<main
data-step2-sidebar-expanded={currentStep === 2 ? String(step2SidebarExpanded) : undefined}
className="relative z-0 flex-1 overflow-hidden p-4 flex gap-4 min-h-0"
>
{/* LEFT SIDEBAR */}
<LeftSidebar
currentStep={currentStep}
@@ -2229,6 +2374,8 @@ export default function RoundtablePlanningPage({
lockBusinessChain={lockBusinessChain}
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
entryRailTaskCardSlot={entryRailTaskCardSlot}
renderStep1LeftSidebar={renderStep1LeftSidebar}
renderStep2LeftSidebarHeader={renderStep2LeftSidebarHeader}
/>
{/* MIDDLE VIEWPORT */}
@@ -2237,7 +2384,13 @@ export default function RoundtablePlanningPage({
className="flex-1 flex flex-col min-w-0 bg-white border border-slate-200/50 rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.015)] overflow-hidden"
>
{currentStep === 1 && (
<Step1Panel
renderStep1 ? renderStep1({
...step1PanelProps,
selectedModel,
modelOptions: availableModels.map((model) => ({ label: model.name, value: model.name })),
onSelectedModelChange: setSelectedModel,
}) : <Step1Panel
{...step1PanelProps}
headerLeftSlot={
// 「原任务已有结果」(目的树预览)只在 rwfx-A(6BF)模式显示;xdfx/3qfx 不显示。
prefillRef.current?.taskId &&
@@ -2250,57 +2403,12 @@ export default function RoundtablePlanningPage({
/>
) : undefined
}
interactiveMessages={step1.interactiveMessages}
intentThreadId={step1.intentThreadId}
intentReady={step1.intentReady}
isIntentSending={step1.isIntentSending}
pendingIntentClarificationMsgId={step1.pendingIntentClarificationMsgId}
intentClarificationSelections={step1.intentClarificationSelections}
setIntentClarificationSelections={step1.setIntentClarificationSelections}
onSendMessage={(text, files) => void step1.sendIntentMessage(text, files)}
onStopIntent={step1.stopIntentStream}
onSkipQuestion={handleSkipQuestion}
onFollowUp={handleAgentFollowUp}
onEnterRoundtable={() => {
if (!step1.intentReady) {
triggerToast("请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
// 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗,
// 直接据该业务链进入 Step 2 研讨(用户无需再手选)。
if (autoChainResult) {
const result = autoChainResult;
void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result));
return;
}
if (lockBusinessChain) {
triggerToast(
autoChainLoading
? "正在加载管理员配置的业务链,请稍候…"
: "管理员尚未配置可用的业务链,请联系管理员处理",
);
return;
}
if (
inWujieEmbed &&
autoChainLoading &&
(prefillRef.current?.chainId || prefillRef.current?.businessCode)
) {
triggerToast("正在加载业务链条,请稍候…");
return;
}
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
}}
onManualSave={drafts.handleManualSave}
onTriggerToast={triggerToast}
submitIntentClarifications={step1.submitIntentClarifications}
/>
)}
{currentStep === 2 && (
<Step2Panel
useStep1InputAppearance={rootClassName?.includes("eightbf-roundtable") ?? false}
step2RoundtableDialogues={step2.step2RoundtableDialogues}
isInitializing={step2.isInitializing}
isRoundTableRunning={step2.isRoundTableRunning}
@@ -2386,7 +2494,7 @@ export default function RoundtablePlanningPage({
</section>
{/* RIGHT SIDEBAR */}
<RightSidebar
{!hideDefaultStep1RightSidebar && <RightSidebar
currentStep={currentStep}
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
@@ -2396,7 +2504,7 @@ export default function RoundtablePlanningPage({
timelineEntries={step1.timelineEntries}
triggerToast={triggerToast}
onSaveIntent={handleSaveIntent}
/>
/>}
</main>
{/* 深链 taskId 模式:底部「快捷跳转」栏(与单智能体问答共用,按业务取按钮管理配置)。 */}
@@ -2792,6 +2900,13 @@ interface LeftSidebarProps {
entryRailBusinessChainSlot?: ReactNode;
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
entryRailTaskCardSlot?: ReactNode;
/** 独立业务页替换第一步左侧工作区;仅提供已解析的意图状态。 */
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode;
/** 独立业务页在第二步左栏顶部复用任务与意图区。 */
renderStep2LeftSidebarHeader?: (context: {
intentReady: object | null;
onCollapse: () => void;
}) => ReactNode;
}
function Step2SandboxSidebarSync({
@@ -2844,6 +2959,8 @@ function LeftSidebar({
lockedChainId,
entryRailBusinessChainSlot,
entryRailTaskCardSlot,
renderStep1LeftSidebar,
renderStep2LeftSidebarHeader,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2861,6 +2978,7 @@ function LeftSidebar({
<section
ref={dzRef}
id="left-sidebar"
data-sidebar-expanded={isStep2 ? String(sidebarExpanded) : undefined}
className={cn(
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
isStep2 ? "overflow-y-auto" : "overflow-hidden",
@@ -2882,15 +3000,20 @@ function LeftSidebar({
{currentStep === 2 ? (
sidebarExpanded ? (
<>
<IntentSummaryCard
intentReady={intentReady}
title="意图理解"
collapsible
sidebarToggle={{
expanded: sidebarExpanded,
onToggle: () => setSidebarExpanded((v) => !v),
}}
/>
{renderStep2LeftSidebarHeader?.({
intentReady,
onCollapse: () => setSidebarExpanded(false),
}) ?? (
<IntentSummaryCard
intentReady={intentReady}
title="意图理解"
collapsible
sidebarToggle={{
expanded: sidebarExpanded,
onToggle: () => setSidebarExpanded((v) => !v),
}}
/>
)}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[240px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
@@ -2976,19 +3099,21 @@ function LeftSidebar({
)
) : (
// Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
<RoundtableEntryRail
selectedChainId={selectedChainId}
onSelectChain={onSelectChain}
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
historyTaskId={historyTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
businessChainSlot={entryRailBusinessChainSlot}
taskCardSlot={entryRailTaskCardSlot}
/>
renderStep1LeftSidebar?.({ intentReady }) ?? (
<RoundtableEntryRail
selectedChainId={selectedChainId}
onSelectChain={onSelectChain}
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
historyTaskId={historyTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
businessChainSlot={entryRailBusinessChainSlot}
taskCardSlot={entryRailTaskCardSlot}
/>
)
)}
</section>
);
+7 -2
View File
@@ -1269,8 +1269,13 @@ the terminal `lastJobId` in `usage_snapshot` so a refreshed conversation page
can replay its trace from the event log. Detailed mode uses stable targets (`introduction`,
`section:N`, `conclusion`) because section writers are concurrent;
multi-agent revisions must emit `report_reset` before their next report
version. Do not emit raw model reasoning or one persisted event per provider
token. The Vite page owns Word export through
version. Do not persist raw model reasoning or one persisted event per provider
token. Models that expose thinking inside ordinary `<think>...</think>` text
must be split statefully before a report delta, checkpoint, or Markdown
projection is created; an optional live-only `report_thinking` frame can drive
the message-list thought trace, while the sandbox receives answer Markdown
only. The Vite
page owns Word export through
`frontend-web/src/core/scheduled-tasks/markdown-docx.ts`; do not add Python
document-conversion dependencies merely to export text.
All non-streaming Deep Research control-plane calls (`complete()` for query /
+2 -2
View File
@@ -1,8 +1,8 @@
install:
uv sync
dev:
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
dev:
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload --reload-exclude ".deer-flow/*" --reload-exclude ".runtime/*"
gateway:
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
+15 -6
View File
@@ -180,6 +180,11 @@ the right-side `report.md` sandbox can write smoothly, while bounded,
persisted `report_chunk` events remain reconnect checkpoints. The durable log
is still authoritative: a browser reconnect, slow consumer, or another worker
falls back to `?after=<seq>` replay rather than starting a second model call.
Reasoning exposed by providers either through structured reasoning fields or
inline `<think>...</think>` text is separated from report deltas, checkpoints,
and the persisted Markdown projection. It may be forwarded as an ephemeral
live `report_thinking` frame for the message-list thought trace, but is never
persisted or written to the sandbox; only answer Markdown reaches the sandbox.
The short non-streaming control-plane calls used to plan queries, sections, and
reviews are independently capped at 45 seconds (maximum 120 seconds). If query
or section planning times out, the runner emits a visible recoverable warning
@@ -231,12 +236,16 @@ only applies to sessions with a `running` or `awaiting_input` durable job.
DeerFlow's stock configuration includes image recognition but no image-generation
provider. Deep Research therefore keeps illustrations disabled until an operator
fills `config.yaml -> deep_research.image_generation` with an
OpenAI-compatible image endpoint, API key, and model. When configured, the
capabilities endpoint enables the page's “生成报告配图” switch. A selected job
requests at most four base64 image results, writes them as protected
`research-image-*.png|jpg|webp` artifacts, injects `deep-research://` links into
the Markdown report, and emits image SSE events. A failed/unconfigured image
provider only emits a recoverable warning; the text report still completes.
image endpoint, API key, and model. `provider: openai_compatible` uses the
standard `/images/generations` protocol and requires `b64_json`; `provider:
dashscope_native` supports Qwen Image 3.0 and Wan 2.7 on DashScope / Token Plan
with the native multimodal-generation protocol. Native provider image URLs are
downloaded immediately, so both providers produce protected local artifacts.
When configured, the capabilities endpoint enables the page's “生成报告配图” switch.
A selected job writes at most four `research-image-*.png|jpg|webp` artifacts,
injects `deep-research://` links into the Markdown report, and emits image SSE
events. A failed/unconfigured image provider only emits a recoverable warning;
the text report still completes.
Completed reports also expose `GET /api/deep-research/sessions/{id}/messages`
and `POST /api/deep-research/sessions/{id}/chat`. Follow-up answers are grounded
@@ -44,6 +44,7 @@ from deerflow.agents.deep_research.types import (
DeepResearchResult,
)
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.stream_text import InlineThinkTagFilter
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
@@ -157,9 +158,7 @@ class RecordingEventSink:
elif type == "sources_curated" and self._source_store is not None:
for sid in (payload or {}).get("selectedIds", []):
try:
await self._source_store.set_selected(
sid, selected=True, reason="curated", user_id=self._user_id
)
await self._source_store.set_selected(sid, selected=True, reason="curated", user_id=self._user_id)
except Exception: # noqa: BLE001
pass
@@ -265,14 +264,8 @@ class DeepResearchJobExecutor:
cancel_token = CancellationToken()
main_task = asyncio.current_task()
heartbeat = (
asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task))
if lease_owner
else None
)
watcher = asyncio.create_task(
self._watch_cancellation(job_id, main_task, lease_owner, cancel_token)
)
heartbeat = asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task)) if lease_owner else None
watcher = asyncio.create_task(self._watch_cancellation(job_id, main_task, lease_owner, cancel_token))
try:
# Normal sandbox Markdown rewrites share the same durable lease,
@@ -390,9 +383,7 @@ class DeepResearchJobExecutor:
session_for_model.setdefault("config_snapshot", snapshot.get("config") or {})
if not report.strip() or not instruction:
await self._mark_document_rewrite_failed(
job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。"
)
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。")
return
events = PersistingEventSink(
@@ -421,9 +412,7 @@ class DeepResearchJobExecutor:
phase="planning",
payload={"type": "requirements_started"},
)
await self._job_store.update_progress(
job_id, lease_owner=lease_owner, phase="planning", progress=20
)
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="planning", progress=20)
requirement_parts: list[str] = []
# Persist the first fragment too: the HTTP client opens its SSE stream
# just after queuing the job, so this closes the tiny subscribe race
@@ -449,6 +438,14 @@ class DeepResearchJobExecutor:
)
last_requirements_checkpoint = time.monotonic()
async def on_requirement_thinking(delta: str) -> None:
if delta:
await events.publish_live(
"document_rewrite",
phase="planning",
payload={"type": "thinking", "chunk": delta},
)
requirements_fallback = False
try:
requirements_plan = await stream_full_report_rewrite_requirements(
@@ -458,6 +455,7 @@ class DeepResearchJobExecutor:
style=style_instruction,
model_name=model_name,
on_delta=on_requirement_delta,
on_reasoning=on_requirement_thinking,
)
except asyncio.CancelledError:
raise
@@ -492,9 +490,7 @@ class DeepResearchJobExecutor:
"plan": requirements_plan,
},
)
await self._job_store.update_progress(
job_id, lease_owner=lease_owner, phase="writing", progress=50
)
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="writing", progress=50)
draft_parts: list[str] = []
last_checkpoint = time.monotonic()
@@ -529,6 +525,14 @@ class DeepResearchJobExecutor:
)
last_checkpoint = time.monotonic()
async def on_thinking(delta: str) -> None:
if delta:
await events.publish_live(
"document_rewrite",
phase="writing",
payload={"type": "thinking", "chunk": delta},
)
try:
rewritten, citation_ids, _usage = await stream_full_report_rewrite(
session=session_for_model,
@@ -538,6 +542,7 @@ class DeepResearchJobExecutor:
style=style,
model_name=model_name,
on_delta=on_delta,
on_reasoning=on_thinking,
requirements_plan=requirements_plan,
)
except asyncio.CancelledError:
@@ -560,9 +565,7 @@ class DeepResearchJobExecutor:
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
validation_error = validate_markdown(rewritten)
if validation_error:
await self._mark_document_rewrite_failed(
job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation"
)
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation")
return
before_headings = heading_count(report)
@@ -582,13 +585,9 @@ class DeepResearchJobExecutor:
validation_warnings=warnings,
)
summary["citationCount"] = len(citation_ids)
await events.emit(
"document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary}
)
await events.emit("document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary})
await events.emit("document_rewrite", phase="exporting", payload={"type": "commit_started"})
await self._job_store.update_progress(
job_id, lease_owner=lease_owner, phase="exporting", progress=90
)
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="exporting", progress=90)
committed_hash = __import__("hashlib").sha256(rewritten.encode("utf-8")).hexdigest()
updated = await self._session_store.replace_report_if_unchanged(
@@ -643,9 +642,7 @@ class DeepResearchJobExecutor:
expected_report=rewritten,
report_markdown=report,
)
message = "保存改写版本失败,报告已恢复原文。" if rolled_back is not None else (
"保存改写版本失败,且报告随后被修改,未再覆盖。"
)
message = "保存改写版本失败,报告已恢复原文。" if rolled_back is not None else ("保存改写版本失败,且报告随后被修改,未再覆盖。")
await self._mark_document_rewrite_failed(
job_id,
session_id,
@@ -749,6 +746,11 @@ class DeepResearchJobExecutor:
instruction = str(snapshot.get("instruction") or "").strip()
style = str(snapshot.get("style") or "professional").strip()
model_name = str(snapshot.get("model_name") or "").strip() or None
generate_images = bool(snapshot.get("generate_images"))
try:
max_generated_images = min(max(int(snapshot.get("max_generated_images") or 2), 1), 4)
except (TypeError, ValueError):
max_generated_images = 2
async def fail(code: str, message: str, *, stage: str = "generation") -> None:
await events.emit(
@@ -808,13 +810,20 @@ class DeepResearchJobExecutor:
)
return
rewrite_source = original
if generate_images:
# Do not ask the writer to preserve a previous run's managed image
# blocks. A new illustration pass will create a fresh, local set;
# user-authored image links remain untouched.
from app.gateway.document_rewrite_illustrations import strip_managed_document_illustrations
rewrite_source = strip_managed_document_illustrations(original)
style_instruction = DOCUMENT_STYLE_INSTRUCTIONS.get(
style,
DOCUMENT_STYLE_INSTRUCTIONS["professional"],
)
resolved_name = model_name or (
self._app_config.models[0].name if self._app_config.models else None
)
resolved_name = model_name or (self._app_config.models[0].name if self._app_config.models else None)
model_cfg = self._app_config.get_model_config(resolved_name) if resolved_name else None
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
try:
@@ -849,12 +858,21 @@ class DeepResearchJobExecutor:
last_requirements_checkpoint = 0.0
requirements_fallback = False
try:
async def on_requirement_thinking(delta: str) -> None:
if delta:
await events.publish_live(
"document_rewrite",
phase="planning",
payload={"type": "thinking", "chunk": delta},
)
async for delta in astream_rewrite_requirements(
model,
file_name=actual_path.name,
instruction=instruction,
style_instruction=style_instruction,
original=original,
original=rewrite_source,
on_thinking=on_requirement_thinking,
):
requirement_parts.append(delta)
requirement_draft = "".join(requirement_parts)
@@ -909,7 +927,7 @@ class DeepResearchJobExecutor:
f"{requirements_plan}\n"
"【已确认的改写执行计划结束】\n\n"
"【原始 Markdown 开始】\n"
f"{original}\n"
f"{rewrite_source}\n"
"【原始 Markdown 结束】"
)
),
@@ -934,6 +952,7 @@ class DeepResearchJobExecutor:
draft_parts: list[str] = []
last_checkpoint = time.monotonic()
inline_think_filter = InlineThinkTagFilter()
try:
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
thinking_delta, text_delta = extract_stream_chunk_parts(chunk)
@@ -943,6 +962,13 @@ class DeepResearchJobExecutor:
phase="writing",
payload={"type": "thinking", "chunk": thinking_delta},
)
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
if inline_thinking:
await events.publish_live(
"document_rewrite",
phase="writing",
payload={"type": "thinking", "chunk": inline_thinking},
)
if not text_delta:
continue
draft_parts.append(text_delta)
@@ -974,6 +1000,26 @@ class DeepResearchJobExecutor:
await fail(type(exc).__name__, str(exc))
return
tail, inline_thinking_tail = inline_think_filter.finish_parts()
if inline_thinking_tail:
await events.publish_live(
"document_rewrite",
phase="writing",
payload={"type": "thinking", "chunk": inline_thinking_tail},
)
if tail:
draft_parts.append(tail)
draft = "".join(draft_parts)
await events.publish_live(
"document_rewrite",
phase="writing",
payload={
"type": "rewrite_delta",
"delta": tail,
"charCount": visible_document_char_count(draft),
},
)
rewritten = "".join(draft_parts).strip()
await events.emit(
"document_rewrite",
@@ -984,6 +1030,148 @@ class DeepResearchJobExecutor:
"charCount": visible_document_char_count(rewritten),
},
)
# Reject an invalid text draft before spending image-generation quota.
validation_error = validate_markdown_document(rewritten)
if validation_error:
await fail("VALIDATION_FAILED", validation_error, stage="validation")
return
generated_illustrations = []
persisted_illustrations = []
illustration_warnings: list[str] = []
if generate_images:
from app.gateway.document_rewrite_illustrations import generate_document_illustrations
from deerflow.agents.deep_research.adapters.image import ImageGenerationSettings, build_image_provider
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
phase="illustrating",
progress=88,
)
# Make the phase visible even when the configured provider is
# unavailable or every provider request fails before a result.
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_started",
"section": "准备生成文档配图",
"position": 0,
"total": max_generated_images,
},
)
image_settings = ImageGenerationSettings.from_app_config()
if not image_settings.is_configured:
illustration_warnings.append("当前部署未配置图片生成服务,已跳过文档配图。")
else:
provider = build_image_provider()
async def on_image_started(section: str, position: int, total: int) -> None:
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_started",
"section": section,
"position": position,
"total": total,
},
)
async def on_image_generated(image, total: int, generated_count: int) -> None:
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_generated",
"section": image.section,
"position": image.position,
"total": total,
"generatedCount": generated_count,
},
)
generated_illustrations, generated_warnings = await generate_document_illustrations(
provider,
markdown=rewritten,
document_name=actual_path.name,
maximum=max_generated_images,
on_started=on_image_started,
on_generated=on_image_generated,
)
illustration_warnings.extend(generated_warnings)
# Do not save images if a manual edit has invalidated the frozen
# source version while image calls were in flight.
current_before_images = await asyncio.to_thread(actual_path.read_text, encoding="utf-8")
if content_hash(current_before_images) != base_hash:
message = "文件在生成配图期间已被修改,已保留原文件。"
await events.emit(
"document_rewrite",
phase="done",
payload={"type": "conflict", "message": message},
)
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
status="completed",
phase="done",
progress=100,
result_snapshot={
"kind": "artifact_document_rewrite",
"status": "conflict",
"content": rewritten,
"originalContent": original,
"requirementsPlan": requirements_plan,
},
)
return
if generated_illustrations:
from app.gateway.document_rewrite_illustrations import (
insert_document_illustrations,
persist_document_illustrations,
)
try:
persisted_illustrations, persistence_warnings = await asyncio.to_thread(
persist_document_illustrations,
generated_illustrations,
markdown_path=actual_path,
virtual_markdown_path=virtual_path,
job_id=job_id,
)
illustration_warnings.extend(persistence_warnings)
rewritten = insert_document_illustrations(rewritten, persisted_illustrations)
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "rewrite_checkpoint",
"content": rewritten,
"charCount": visible_document_char_count(rewritten),
},
)
except Exception: # noqa: BLE001
logger.exception("could not save artifact rewrite illustrations for %s", job_id)
illustration_warnings.append("配图保存失败,已完成文字改写但未插入图片。")
persisted_illustrations = []
else:
persisted_illustrations = []
await events.emit(
"document_rewrite",
phase="illustrating",
payload={
"type": "illustration_completed",
"generatedCount": len(persisted_illustrations),
"requestedCount": max_generated_images,
"warnings": illustration_warnings,
},
)
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
validation_error = validate_markdown_document(rewritten)
if validation_error:
@@ -993,6 +1181,7 @@ class DeepResearchJobExecutor:
heading_count_before = sum(1 for line in original.splitlines() if line.lstrip().startswith("#"))
heading_count_after = sum(1 for line in rewritten.splitlines() if line.lstrip().startswith("#"))
warnings = ["标题数量发生变化,请在前后对比中确认结构。"] if heading_count_before != heading_count_after else []
warnings.extend(illustration_warnings)
summary = build_document_rewrite_summary(
source_display_name=actual_path.name,
instruction=instruction,
@@ -1001,6 +1190,8 @@ class DeepResearchJobExecutor:
rewritten=rewritten,
validation_warnings=warnings,
)
summary["generatedImageCount"] = len(persisted_illustrations)
summary["requestedImageCount"] = max_generated_images if generate_images else 0
await events.emit(
"document_rewrite",
phase="reviewing",
@@ -1126,9 +1317,7 @@ class DeepResearchJobExecutor:
job_id=job_id,
on_event=self._live_publisher,
)
await events.emit(
"document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage}
)
await events.emit("document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage})
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
@@ -1334,11 +1523,7 @@ class DeepResearchJobExecutor:
},
{
"role": "user",
"content": (
"请为以下研究报告写一段 150-250 字的摘要,使用"
f"{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n"
f"{report_markdown[:24_000]}"
),
"content": (f"请为以下研究报告写一段 150-250 字的摘要,使用{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n{report_markdown[:24_000]}"),
},
],
on_delta=on_delta,
@@ -1371,9 +1556,7 @@ class DeepResearchJobExecutor:
# ── terminal-state writers ──────────────────────────────────────────────
async def _mark_completed(
self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult
) -> None:
async def _mark_completed(self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult) -> None:
# Persist the report projection before exposing a terminal completed
# job. The session row is the durable report itself; marking the job
# complete first and suppressing a session-write failure can otherwise
@@ -1449,13 +1632,9 @@ class DeepResearchJobExecutor:
)
await asyncio.sleep(_REPORT_PROJECTION_RETRY_DELAY_SECONDS * attempt)
raise RuntimeError(
f"研究报告未能保存到会话 {session_id},任务不会标记为完成"
) from last_error
raise RuntimeError(f"研究报告未能保存到会话 {session_id},任务不会标记为完成") from last_error
async def _mark_awaiting(
self, job_id: str, session_id: str, pause: ResearchAwaitingInput
) -> None:
async def _mark_awaiting(self, job_id: str, session_id: str, pause: ResearchAwaitingInput) -> None:
"""Project a persisted plan-review interrupt onto the owning session."""
with suppress(Exception): # noqa: BLE001
await self._session_store.update(
@@ -1465,9 +1644,7 @@ class DeepResearchJobExecutor:
active_job_id=job_id,
)
async def _mark_failed(
self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str
) -> None:
async def _mark_failed(self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str) -> None:
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
@@ -85,6 +85,7 @@ async def stream_full_report_rewrite(
style: str,
model_name: str | None,
on_delta: Callable[[str], Awaitable[None]],
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
requirements_plan: str | None = None,
) -> tuple[str, list[str], dict[str, Any]]:
"""Generate a complete Markdown replacement without starting new research."""
@@ -126,6 +127,7 @@ async def stream_full_report_rewrite(
messages=messages,
operation="full_report_rewrite",
on_delta=on_delta,
on_reasoning=on_reasoning,
)
answer = result.text.strip() or report
answer, cited = _normalise_citations(answer, source_ids)
@@ -140,6 +142,7 @@ async def stream_full_report_rewrite_requirements(
style: str,
model_name: str | None,
on_delta: Callable[[str], Awaitable[None]],
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
) -> str:
"""Stream a compact plan before rewriting a research report.
@@ -169,6 +172,7 @@ async def stream_full_report_rewrite_requirements(
],
operation="full_report_rewrite_requirements",
on_delta=on_delta,
on_reasoning=on_reasoning,
)
return result.text.strip()
@@ -0,0 +1,263 @@
"""Safe, optional illustrations for a sandbox Markdown full rewrite.
The writer streams prose first. When requested, this module derives a small
set of heading-scoped illustration prompts, persists the returned bytes inside
the same user-owned artifact tree, then adds only local ``/mnt`` references to
the Markdown. Provider URLs never reach the browser or the saved document.
"""
from __future__ import annotations
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from deerflow.agents.deep_research.adapters.image import ImageGenerationError, ImageProvider
_HEADING_RE = re.compile(r"^##[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
_TITLE_RE = re.compile(r"^#[ \t]+([^\r\n]+?)[ \t]*$", re.MULTILINE)
_START_MARKER = "<!-- deerflow:ai-rewrite-image:start -->"
_END_MARKER = "<!-- deerflow:ai-rewrite-image:end -->"
_MANAGED_IMAGE_BLOCK_RE = re.compile(
rf"\n?{re.escape(_START_MARKER)}.*?{re.escape(_END_MARKER)}\n?",
re.DOTALL,
)
_MIME_EXTENSIONS = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
}
@dataclass(frozen=True)
class DocumentIllustration:
"""A provider result that has not been exposed or persisted yet."""
section: str
position: int
data: bytes
mime_type: str
alt: str
@dataclass(frozen=True)
class PersistedDocumentIllustration:
"""A generated image with a virtual artifact path safe for Markdown."""
section: str
position: int
virtual_path: str
alt: str
def strip_managed_document_illustrations(markdown: str) -> str:
"""Remove only DeerFlow-generated image blocks before a fresh image pass."""
return _MANAGED_IMAGE_BLOCK_RE.sub("\n", markdown).strip()
def select_illustration_sections(markdown: str, maximum: int) -> list[str]:
"""Pick distinct H2 sections; fall back to the title for short documents."""
limit = max(0, min(maximum, 4))
if not limit:
return []
sections: list[str] = []
seen: set[str] = set()
for match in _HEADING_RE.finditer(markdown):
section = _clean_heading(match.group(1))
if section and section not in seen:
seen.add(section)
sections.append(section)
if len(sections) >= limit:
return sections
if sections:
return sections
title_match = _TITLE_RE.search(markdown)
title = _clean_heading(title_match.group(1)) if title_match else "文档主题"
return [title or "文档主题"]
async def generate_document_illustrations(
provider: ImageProvider,
*,
markdown: str,
document_name: str,
maximum: int,
on_started=None,
on_generated=None,
) -> tuple[list[DocumentIllustration], list[str]]:
"""Generate a bounded, sequential image set and keep failures recoverable."""
sections = select_illustration_sections(markdown, maximum)
title_match = _TITLE_RE.search(markdown)
document_title = _clean_heading(title_match.group(1)) if title_match else Path(document_name).stem
generated: list[DocumentIllustration] = []
warnings: list[str] = []
for position, section in enumerate(sections, 1):
if on_started is not None:
await on_started(section, position, len(sections))
try:
result = await provider.generate(
_illustration_prompt(document_title, section),
section_id=section,
)
data = result.get("data") if isinstance(result, dict) else None
if not isinstance(data, bytes) or not data:
raise ImageGenerationError("图片生成服务未返回可保存的图片数据")
mime_type = str(result.get("mime_type") or "image/png").lower()
if mime_type not in _MIME_EXTENSIONS:
mime_type = "image/png"
illustration = DocumentIllustration(
section=section,
position=position,
data=data,
mime_type=mime_type,
alt=_clean_alt(str(result.get("alt") or f"{section}配图")),
)
generated.append(illustration)
if on_generated is not None:
await on_generated(illustration, len(sections), len(generated))
except ImageGenerationError as exc:
warnings.append(f"“{section}”配图未生成:{exc}")
except Exception: # noqa: BLE001
# Generation remains best effort: an unavailable image provider
# must never discard an otherwise complete document rewrite.
warnings.append(f"“{section}”配图生成失败,已保留文字改写结果。")
return generated, warnings
def persist_document_illustrations(
illustrations: list[DocumentIllustration],
*,
markdown_path: Path,
virtual_markdown_path: str,
job_id: str,
) -> tuple[list[PersistedDocumentIllustration], list[str]]:
"""Best-effort persist each asset beside the rewritten Markdown file.
A single failed local write must not discard earlier successful images. In
particular, Windows development reloaders can briefly recreate an output
directory while a report is being written. The caller receives warnings
for the timeline, while any persisted image remains usable in the final
Markdown.
"""
if not illustrations:
return [], []
# Keep this name intentionally short. On Windows the local sandbox path
# already contains user/thread UUIDs; including a long report title plus a
# UUID-based temporary filename exceeded MAX_PATH (and surfaced as a
# misleading FileNotFoundError). Job ids make names unique across files,
# so a single short hidden directory per outputs folder is sufficient.
asset_directory_name = ".ai-images"
asset_directory = markdown_path.parent / asset_directory_name
virtual_parent = PurePosixPath(virtual_markdown_path).parent.as_posix().rstrip("/")
persisted: list[PersistedDocumentIllustration] = []
warnings: list[str] = []
for image in illustrations:
try:
extension = _MIME_EXTENSIONS[image.mime_type]
file_name = f"{job_id}-{image.position:02d}.{extension}"
_atomic_write_bytes(asset_directory / file_name, image.data)
persisted.append(
PersistedDocumentIllustration(
section=image.section,
position=image.position,
virtual_path=f"{virtual_parent}/{asset_directory_name}/{file_name}",
alt=image.alt,
)
)
except OSError:
warnings.append(f"“{image.section}”配图保存失败,已继续保留其他成功配图。")
return persisted, warnings
def insert_document_illustrations(
markdown: str,
illustrations: list[PersistedDocumentIllustration],
) -> str:
"""Insert fenced local-image blocks directly below their selected sections."""
amended = strip_managed_document_illustrations(markdown)
for image in illustrations:
marker = f"## {image.section}"
block = (
f"\n\n{_START_MARKER}\n"
f"![{image.alt}]({image.virtual_path})\n"
f"{_END_MARKER}\n"
)
if marker in amended:
amended = amended.replace(marker, f"{marker}{block}", 1)
else:
amended = f"{amended.rstrip()}{block}"
return amended.strip()
def _atomic_write_bytes(path: Path, content: bytes) -> None:
"""Write one image atomically with Windows-safe temporary path lengths."""
last_error: FileNotFoundError | None = None
for _attempt in range(3):
temporary: Path | None = None
try:
# A runtime cleanup can remove a freshly-created asset folder
# between image calls. Recreate it immediately before writing and
# retry this small local operation. NamedTemporaryFile deliberately
# keeps the temporary basename short: appending a report title,
# job id, and UUID can exceed Windows MAX_PATH before os.replace.
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="wb",
dir=path.parent,
prefix=".w-",
suffix=".tmp",
delete=False,
) as file:
temporary = Path(file.name)
file.write(content)
file.flush()
os.fsync(file.fileno())
os.replace(temporary, path)
return
except FileNotFoundError as exc:
last_error = exc
finally:
try:
if temporary is not None and temporary.exists():
temporary.unlink()
except OSError:
pass
if last_error is not None:
raise last_error
def _illustration_prompt(document_title: str, section: str) -> str:
return (
"为一份专业 Markdown 文档生成一张可嵌入的章节配图。"
f"文档主题:{_clean_heading(document_title)}。章节:{section}。"
"画面应信息性、简洁、与正文内容一致,可使用抽象示意、数据可视化风格或场景插画。"
"不要在图片中放置可读文字、品牌标志、水印或人物肖像;不得虚构具体数据或事实。"
)
def _clean_heading(value: str) -> str:
return " ".join(value.replace("\r", " ").replace("\n", " ").split())[:160]
def _clean_alt(value: str) -> str:
return _clean_heading(value).replace("[", "").replace("]", "") or "文档配图"
__all__ = [
"DocumentIllustration",
"PersistedDocumentIllustration",
"generate_document_illustrations",
"insert_document_illustrations",
"persist_document_illustrations",
"select_illustration_sections",
"strip_managed_document_illustrations",
]
@@ -14,10 +14,12 @@ import logging
import os
import re
import uuid
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Awaitable, Callable
from pathlib import Path
from typing import Any
from deerflow.utils.stream_text import InlineThinkTagFilter
logger = logging.getLogger(__name__)
MAX_DOCUMENT_REWRITE_CHARS = 120_000
@@ -89,9 +91,7 @@ def build_rewrite_requirements_context(content: str) -> str:
if len(headings) >= 30:
break
heading_count = sum(
1 for line in content.splitlines() if re.match(r"^\s{0,3}#{1,6}\s+", line)
)
heading_count = sum(1 for line in content.splitlines() if re.match(r"^\s{0,3}#{1,6}\s+", line))
link_count = len(re.findall(r"!?(?:\[[^\]]*\]\([^)]*\))", content))
table_rows = sum(1 for line in content.splitlines() if line.lstrip().startswith("|"))
return "\n".join(
@@ -136,16 +136,7 @@ def build_rewrite_requirements_messages(
return [
SystemMessage(content=DOCUMENT_REWRITE_REQUIREMENTS_SYSTEM_PROMPT),
HumanMessage(
content=(
f"文件名:{file_name}\n"
f"用户改写要求:{instruction}\n"
f"选定风格:{style_instruction}\n\n"
"【文档结构信息开始】\n"
f"{build_rewrite_requirements_context(original)}\n"
"【文档结构信息结束】"
)
),
HumanMessage(content=(f"文件名:{file_name}\n用户改写要求:{instruction}\n选定风格:{style_instruction}\n\n【文档结构信息开始】\n{build_rewrite_requirements_context(original)}\n【文档结构信息结束】")),
]
@@ -156,18 +147,30 @@ async def astream_rewrite_requirements(
instruction: str,
style_instruction: str,
original: str,
on_thinking: Callable[[str], Awaitable[None]] | None = None,
) -> AsyncIterator[str]:
"""Yield only visible plan text from the short requirements-planning call."""
"""Yield visible plan text and optionally forward live model reasoning."""
messages = build_rewrite_requirements_messages(
file_name=file_name,
instruction=instruction,
style_instruction=style_instruction,
original=original,
)
inline_think_filter = InlineThinkTagFilter()
async for chunk in model.astream(messages, config={"run_name": "document_rewrite_requirements"}):
_, text = extract_stream_chunk_parts(chunk)
if text:
yield text
structured_thinking, text = extract_stream_chunk_parts(chunk)
if structured_thinking and on_thinking is not None:
await on_thinking(structured_thinking)
visible_text, inline_thinking = inline_think_filter.push_parts(text)
if inline_thinking and on_thinking is not None:
await on_thinking(inline_thinking)
if visible_text:
yield visible_text
tail, inline_thinking_tail = inline_think_filter.finish_parts()
if inline_thinking_tail and on_thinking is not None:
await on_thinking(inline_thinking_tail)
if tail:
yield tail
def atomic_write_text(path: Path, content: str) -> None:
@@ -21,6 +21,7 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_agent_store
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -125,6 +126,37 @@ async def _validate_public_chain_mapping(
status_code=400,
detail="Global business mappings require a public business chain",
)
# 公共业务映射会被任意账号启动,链条中的席位也必须对任意账号可见。
# 仅把链条本身发布、却把其中某个自定义智能体保留为未发布/私有,会导致
# 前端只能解析到不足两个席位,最终被误判为“没有可用业务链”。
seats = chain.get("seats")
if isinstance(seats, list):
agent_store = get_agent_store(request)
unavailable_names: list[str] = []
for raw_seat in seats:
if not isinstance(raw_seat, dict):
continue
agent_id = str(raw_seat.get("agent_id") or "").strip()
if not agent_id:
continue
agent = await agent_store.get_any(agent_id)
is_builtin = agent is not None and agent.get("user_id") is None
is_public_agent = bool(agent and agent.get("published")) and not bool(
agent and agent.get("is_private")
)
if is_builtin or is_public_agent:
continue
name = str(raw_seat.get("name") or agent_id).strip()
unavailable_names.append(name)
if unavailable_names:
raise HTTPException(
status_code=400,
detail=(
"Global business mappings require every seat to be published and public; "
f"unavailable agents: {', '.join(unavailable_names)}"
),
)
fields["chain_id"] = chain_id
fields["chain_title"] = str(chain.get("title") or "") or None
@@ -158,20 +158,19 @@ def _clarification_blocks_ready(
*,
is_fresh: bool,
) -> bool:
"""Position intent must wait for a fresh clarification answer.
"""A fresh clarification always pauses intent resolution.
The generic Step-1 route intentionally preserves READY-first behavior for
stale tool calls in historical snapshots. Position roundtable instead has
a four-part hand-off whose missing context is requested interactively, so
a newly dispatched clarification is a strict terminal pause. A historical
tool call from the preceding user-assistance turn must not block the next
user answer from reaching READY.
A tool call and a premature ``[INTENT_READY]`` block may coexist in the
same upstream run. The tool call is an explicit request for user input,
so it wins for every Step-1 workspace (including 8BF): do not consume the
assistance card before the user can answer it. Stale calls from earlier
turns remain harmless because only a fresh call blocks READY.
``intent_agent_id`` stays in the signature for route-call compatibility
and to keep the policy extensible per agent if a future flow needs one.
"""
return (
intent_agent_id == POSITION_INTENT_AGENT_ID
and bool(clarification_calls)
and is_fresh
)
del intent_agent_id
return bool(clarification_calls) and is_fresh
def _intent_thread_metadata(
@@ -696,11 +695,10 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
)
clarification_is_fresh = current_rounds > past_rounds
# A position-roundtable clarification is an explicit pause.
# Some models emit a premature [INTENT_READY] text after the
# tool call in the same upstream run. For this dedicated flow
# the fresh clarification must win so the user can answer it;
# generic roundtable keeps its historical READY-first order.
# A fresh clarification is an explicit pause. Some models emit
# a premature [INTENT_READY] block after its tool call in the
# same upstream run; the user-assistance card must win so the
# user can answer it before any summary is accepted.
dispatch_msg = _find_dispatch_message(messages)
tool_calls = dispatch_msg.get("tool_calls") or []
clarification_calls = [
@@ -710,10 +708,10 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
and (tc.get("name") or "").lower() == "ask_clarification"
]
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
# --- 终态分支 1:结构化意图已就绪(无新追问时)---
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
# 仍残留 ask_clarification 的 tool_calls;必须先扫 READY,否则前端
# 会错误展示澄清卡片且 intentReady 无法解锁 Step 2。
# 仍残留 ask_clarification 的 tool_calls;若它是本轮新产生的追问,
# 必须先等待用户回答,不能让 READY 覆盖可交互的协助卡。
summary: dict[str, Any] | None = None
ready_text: str = ""
for msg in reversed(messages):
@@ -59,6 +59,7 @@ from app.gateway.document_rewrite_summary import build_document_rewrite_summary
from app.gateway.path_utils import aresolve_thread_virtual_path
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.utils.stream_text import InlineThinkTagFilter
logger = logging.getLogger(__name__)
@@ -143,6 +144,8 @@ class DocumentRewriteRequest(BaseModel):
instruction: str = Field(..., min_length=1, max_length=4_000)
style: str | None = Field(default=None)
model_name: str | None = Field(default=None, alias="modelName")
generate_images: bool = Field(default=False, alias="generateImages")
max_generated_images: int = Field(default=2, alias="maxGeneratedImages", ge=1, le=4)
expected_hash: str | None = Field(default=None, alias="expectedHash")
model_config = {"populate_by_name": True}
@@ -193,12 +196,7 @@ def _artifact_rewrite_scope(thread_id: str, virtual_path: str) -> str:
def _artifact_job_matches(job: dict, *, thread_id: str, virtual_path: str) -> bool:
snapshot = job.get("input_snapshot")
return bool(
isinstance(snapshot, dict)
and snapshot.get("kind") == "artifact_document_rewrite"
and snapshot.get("thread_id") == thread_id
and snapshot.get("path") == virtual_path
)
return bool(isinstance(snapshot, dict) and snapshot.get("kind") == "artifact_document_rewrite" and snapshot.get("thread_id") == thread_id and snapshot.get("path") == virtual_path)
def _artifact_job_response(job: dict) -> dict:
@@ -215,6 +213,8 @@ def _artifact_job_response(job: dict) -> dict:
"instruction": str(snapshot.get("instruction") or ""),
"style": str(snapshot.get("style") or "professional"),
"modelName": snapshot.get("model_name"),
"generateImages": bool(snapshot.get("generate_images")),
"maxGeneratedImages": int(snapshot.get("max_generated_images") or 2),
"originalContent": snapshot.get("original_content") or "",
},
}
@@ -266,10 +266,7 @@ async def _stream_durable_artifact_rewrite_job(
yield _sse(
"error",
{
"message": str(
current.get("error_message")
or "全文改写任务未完成,原文件未被修改。"
),
"message": str(current.get("error_message") or "全文改写任务未完成,原文件未被修改。"),
"stage": str(current.get("phase") or "generation"),
},
)
@@ -397,6 +394,8 @@ async def start_durable_document_rewrite_job(
"instruction": body.instruction.strip(),
"style": body.style or "professional",
"model_name": body.model_name,
"generate_images": body.generate_images,
"max_generated_images": body.max_generated_images,
}
request_hash = hashlib.sha256(
json.dumps(
@@ -405,6 +404,8 @@ async def start_durable_document_rewrite_job(
"instruction": body.instruction.strip(),
"style": body.style or "professional",
"model_name": body.model_name,
"generate_images": body.generate_images,
"max_generated_images": body.max_generated_images,
},
ensure_ascii=False,
sort_keys=True,
@@ -468,13 +469,7 @@ async def list_durable_document_rewrite_jobs(
virtual_path = path.strip()
scope = _artifact_rewrite_scope(thread_id, virtual_path)
rows = await job_store.list_by_session(scope, user_id=user_id, limit=limit)
return {
"items": [
_artifact_job_response(row)
for row in rows
if _artifact_job_matches(row, thread_id=thread_id, virtual_path=virtual_path)
]
}
return {"items": [_artifact_job_response(row) for row in rows if _artifact_job_matches(row, thread_id=thread_id, virtual_path=virtual_path)]}
@router.get("/document-rewrite/jobs/{job_id}/stream", response_class=StreamingResponse)
@@ -665,10 +660,14 @@ async def rewrite_document(
"plan": requirements_plan,
},
)
inline_think_filter = InlineThinkTagFilter()
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
thinking_delta, text_delta = _extract_chunk_parts(chunk)
if thinking_delta:
yield _sse("thinking", {"chunk": thinking_delta})
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
if inline_thinking:
yield _sse("thinking", {"chunk": inline_thinking})
if text_delta:
draft_parts.append(text_delta)
yield _sse(
@@ -679,6 +678,19 @@ async def rewrite_document(
},
)
tail, inline_thinking_tail = inline_think_filter.finish_parts()
if inline_thinking_tail:
yield _sse("thinking", {"chunk": inline_thinking_tail})
if tail:
draft_parts.append(tail)
yield _sse(
"rewrite_delta",
{
"delta": tail,
"charCount": _count_document_chars("".join(draft_parts)),
},
)
rewritten = "".join(draft_parts).strip()
yield _sse("validation_started", {})
validation_error = _markdown_validation_error(rewritten)
@@ -688,11 +700,7 @@ async def rewrite_document(
heading_count_before = sum(1 for line in original.splitlines() if line.lstrip().startswith("#"))
heading_count_after = sum(1 for line in rewritten.splitlines() if line.lstrip().startswith("#"))
warnings = (
["标题数量发生变化,请在前后对比中确认结构。"]
if heading_count_before != heading_count_after
else []
)
warnings = ["标题数量发生变化,请在前后对比中确认结构。"] if heading_count_before != heading_count_after else []
yield _sse(
"validation_completed",
{
@@ -891,12 +899,21 @@ async def rewrite_selection(
async def generate():
try:
model = create_chat_model(name=body.model_name, thinking_enabled=thinking_enabled, app_config=config)
inline_think_filter = InlineThinkTagFilter()
async for chunk in model.astream(messages, config={"run_name": "writing_rewrite"}):
thinking_delta, text_delta = _extract_chunk_parts(chunk)
if thinking_delta:
yield _sse("thinking", {"chunk": thinking_delta})
text_delta, inline_thinking = inline_think_filter.push_parts(text_delta)
if inline_thinking:
yield _sse("thinking", {"chunk": inline_thinking})
if text_delta:
yield _sse("text", {"chunk": text_delta})
tail, inline_thinking_tail = inline_think_filter.finish_parts()
if inline_thinking_tail:
yield _sse("thinking", {"chunk": inline_thinking_tail})
if tail:
yield _sse("text", {"chunk": tail})
yield _sse("done", {})
except Exception as e:
logger.exception("writing/rewrite failed: thread=%s action=%s", body.thread_id, action)
+8 -7
View File
@@ -101,7 +101,7 @@ models:
max_retries: 2
stream_usage: true
supports_thinking: true
supports_reasoning_effort: false
supports_reasoning_effort: true
supports_vision: false
- name: qwen3.6
display_name: qwen3.6
@@ -207,17 +207,18 @@ vision:
# max_tokens: 2048
# 深度研究可选的报告配图。默认关闭;仅在部署方配置文生图模型后,前端才会展示开关。
# 使用 OpenAI 兼容的 POST {base_url}/images/generations 协议,并强制要求 b64_json
# 返回,以便将图片保存为当前研究会话的受鉴权产物,而不是引用会过期的公网 URL。
# provider: openai_compatible 使用 POST {base_url}/images/generations 并要求 b64_json。
# provider: dashscope_native 用于千问 Token Plan 的 qwen-image-3.0-pro / wan2.7-image:
# 使用原生多模态 generation 接口,服务返回的临时图片 URL 会立即下载并保存为受鉴权产物。
deep_research:
image_generation:
enabled: true
provider: openai_compatible
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation # 例如 https://api.openai.com/v1
provider: dashscope_native
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg # 生产环境建议填 $DEEP_RESEARCH_IMAGE_API_KEY
model: wan2.7-image # 例如 gpt-image-1
model: wan2.7-image # 也可设为 qwen-image-3.0-pro
size: 1024x1024
# quality: standard
watermark: false
timeout_seconds: 120
# browser_act 相关配置
@@ -31,7 +31,7 @@ path and commit below, and keep the Apache-2.0 notice in the distribution.
| `runners/deep.py` | `gpt_researcher/skills/deep_research.py` | Breadth/depth branching, learnings and follow-up questions are preserved with an explicit global budget, semaphores and cooperative cancellation. |
| `runners/multi_agent.py` | `multi_agents/agents/{orchestrator,editor,writer,fact_checker}.py` | Editor/researcher/writer/reviewer roles become a LangGraph 1.x `StateGraph`, with the review pause persisted as a durable job state. |
| `adapters/context.py`, `adapters/material.py` | `gpt_researcher/context/compression.py`, `gpt_researcher/skills/researcher.py` | Retains context/de-duplication intent while preserving DeerFlow provenance (`rec_uuid`, source id, source type) and enforcing the existing material-provider boundary. |
| `runners/images.py`, `adapters/image.py` | `gpt_researcher/skills/image_generator.py` | Retains report-image placement intent, but image generation uses a configured DeerFlow OpenAI-compatible adapter and protected hidden-thread artifacts. |
| `runners/images.py`, `adapters/image.py` | `gpt_researcher/skills/image_generator.py` | Retains report-image placement intent, while configured OpenAI-compatible and DashScope-native adapters both persist protected hidden-thread artifacts. |
## Required adaptation invariants
@@ -1,14 +1,17 @@
"""Optional, DeerFlow-configured report image generation.
The upstream GPT Researcher image skill owns its provider configuration, so it
cannot be safely reused in DeerFlow. This adapter instead talks to a deployment
configured OpenAI-compatible ``/images/generations`` endpoint. It is never
configured from a browser request: model credentials stay in ``config.yaml``
and the per-session snapshot contains only the boolean ``generate_images``.
cannot be safely reused in DeerFlow. This adapter supports both a deployment
configured OpenAI-compatible ``/images/generations`` endpoint and the native
DashScope multimodal-generation endpoint used by Qwen Image 3.0 and Wan 2.7.
It is never configured from a browser request: model credentials stay in
``config.yaml`` and the per-session snapshot contains only the boolean
``generate_images``.
Images are requested as base64 rather than provider-hosted URLs. The runner can
therefore put the exact bytes into the session's protected artifact directory;
reports never depend on an expiring public image URL.
OpenAI-compatible images are requested as base64; DashScope image URLs are
downloaded immediately. The runner therefore always puts exact bytes into the
session's protected artifact directory, and reports never depend on an expiring
public image URL.
"""
from __future__ import annotations
@@ -16,11 +19,13 @@ from __future__ import annotations
import base64
import binascii
from typing import Any, Literal, Protocol, runtime_checkable
from urllib.parse import urlparse
import httpx
from pydantic import BaseModel, Field, field_validator
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
_DASHSCOPE_GENERATION_PATH = "/api/v1/services/aigc/multimodal-generation/generation"
class ImageGenerationError(RuntimeError):
@@ -28,17 +33,17 @@ class ImageGenerationError(RuntimeError):
class ImageGenerationSettings(BaseModel):
"""Server-only configuration for an OpenAI-compatible image endpoint.
"""Server-only configuration for a report image endpoint.
Example ``config.yaml`` section::
deep_research:
image_generation:
enabled: true
provider: openai_compatible
base_url: https://api.openai.com/v1
provider: dashscope_native
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
api_key: $DEEP_RESEARCH_IMAGE_API_KEY
model: gpt-image-1
model: qwen-image-3.0-pro
``api_key`` is resolved by :class:`AppConfig` while loading YAML, but this
object is deliberately not placed in ``DeepResearchConfig`` and is never
@@ -46,12 +51,13 @@ class ImageGenerationSettings(BaseModel):
"""
enabled: bool = False
provider: Literal["openai_compatible"] = "openai_compatible"
provider: Literal["openai_compatible", "dashscope_native"] = "openai_compatible"
base_url: str = ""
api_key: str = ""
model: str = ""
size: str = "1024x1024"
quality: str | None = None
watermark: bool = False
timeout_seconds: float = Field(default=120, ge=1, le=300)
model_config = {"extra": "ignore"}
@@ -215,11 +221,203 @@ class OpenAICompatibleImageProvider:
return {"data": data, "mime_type": mime_type, "alt": alt, "prompt": prompt}
class DashScopeNativeImageProvider:
"""Generate report illustrations through DashScope's native image API.
Qwen Image 3.0 and Wan 2.7 do not implement the OpenAI
``/images/generations`` request/response contract. They receive a
multimodal ``input.messages`` payload and normally return a short-lived
image URL. The URL is downloaded immediately so the research report keeps
a protected local artifact, just like the OpenAI-compatible provider.
"""
def __init__(
self,
settings: ImageGenerationSettings,
*,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
if not settings.is_configured:
raise ValueError("Image generation provider is not configured")
self._settings = settings
self._transport = transport
async def generate(
self,
prompt: str,
*,
section_id: str = "",
user_id: str = "",
) -> dict[str, Any]:
del user_id # Credentials are deployment-scoped; users never supply them.
body = _dashscope_request_body(self._settings, prompt)
headers = {
"Authorization": f"Bearer {self._settings.api_key}",
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(
timeout=self._settings.timeout_seconds,
transport=self._transport,
follow_redirects=True,
) as client:
response = await client.post(
_dashscope_generation_endpoint(self._settings.base_url),
json=body,
headers=headers,
)
response.raise_for_status()
payload = response.json()
encoded, image_url, mime_type = _dashscope_image_result(payload)
if encoded:
data = _decode_image_base64(encoded)
elif image_url:
data, mime_type = await _download_dashscope_image(client, image_url, mime_type)
else:
raise ImageGenerationError("千问图片生成服务未返回图片地址或图片数据")
except ImageGenerationError:
raise
except httpx.TimeoutException as exc:
raise ImageGenerationError("图片生成服务响应超时") from exc
except httpx.HTTPStatusError as exc:
raise ImageGenerationError(_http_error_message(exc.response)) from exc
except httpx.HTTPError as exc:
raise ImageGenerationError("图片生成服务暂时不可用") from exc
except ValueError as exc:
raise ImageGenerationError("图片生成服务返回了无效响应") from exc
if not data or len(data) > _MAX_IMAGE_BYTES:
raise ImageGenerationError("生成图片为空或超过大小限制")
alt = f"{section_id or '研究报告'}配图"
return {"data": data, "mime_type": _normalise_mime_type(mime_type), "alt": alt, "prompt": prompt}
def _dashscope_generation_endpoint(base_url: str) -> str:
"""Resolve a native DashScope endpoint without relying on a workspace id.
Token Plan deployments commonly expose the full native endpoint, while
regular DashScope configuration often uses just an API origin. Supporting
both avoids an accidental ``.../generation/images/generations`` path.
"""
base = base_url.rstrip("/")
if base.endswith(_DASHSCOPE_GENERATION_PATH):
return base
parsed = urlparse(base)
if not parsed.scheme or not parsed.netloc:
return base
return f"{parsed.scheme}://{parsed.netloc}{_DASHSCOPE_GENERATION_PATH}"
def _dashscope_request_body(settings: ImageGenerationSettings, prompt: str) -> dict[str, Any]:
parameters: dict[str, Any] = {
"n": 1,
"size": settings.size.replace("x", "*").replace("X", "*"),
"watermark": settings.watermark,
}
# ``prompt_extend`` is a Qwen Image-only parameter. Wan 2.7 rejects
# unrelated generation options, so do not send it there.
if settings.model.strip().lower().startswith("qwen-image-"):
parameters["prompt_extend"] = True
return {
"model": settings.model,
"input": {
"messages": [
{
"role": "user",
"content": [{"text": prompt}],
}
]
},
"parameters": parameters,
}
def _dashscope_image_result(payload: object) -> tuple[str, str, str]:
"""Return optional base64, image URL and MIME type from native output."""
if not isinstance(payload, dict):
return "", "", ""
output = payload.get("output")
choices = output.get("choices") if isinstance(output, dict) else None
if not isinstance(choices, list):
return "", "", ""
for choice in choices:
message = choice.get("message") if isinstance(choice, dict) else None
content = message.get("content") if isinstance(message, dict) else None
if not isinstance(content, list):
continue
for part in content:
if not isinstance(part, dict):
continue
encoded = str(part.get("b64_json") or "")
raw_image_url = part.get("image") or part.get("image_url")
if isinstance(raw_image_url, dict):
raw_image_url = raw_image_url.get("url")
image_url = str(raw_image_url or "")
if encoded or image_url:
return encoded, image_url, str(part.get("mime_type") or "")
return "", "", ""
async def _download_dashscope_image(
client: httpx.AsyncClient,
image_url: str,
mime_type: str,
) -> tuple[bytes, str]:
"""Download only an HTTPS model result, never forwarding the API key."""
parsed = urlparse(image_url)
if parsed.scheme != "https" or not parsed.netloc:
raise ImageGenerationError("千问图片生成服务返回了不安全的图片地址")
response = await client.get(image_url, headers={"Accept": "image/png,image/jpeg,image/webp"})
response.raise_for_status()
final_url = urlparse(str(response.url))
if final_url.scheme != "https" or not final_url.netloc:
raise ImageGenerationError("千问图片下载地址不安全")
response_mime_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
return response.content, response_mime_type or mime_type
def _decode_image_base64(encoded: str) -> bytes:
if encoded.startswith("data:") and "," in encoded:
encoded = encoded.split(",", 1)[1]
try:
return base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error) as exc:
raise ImageGenerationError("图片生成服务返回的图片编码无效") from exc
def _normalise_mime_type(value: str) -> str:
mime_type = value.lower()
return mime_type if mime_type in {"image/png", "image/jpeg", "image/webp"} else "image/png"
def _http_error_message(response: httpx.Response) -> str:
"""Expose a short provider message without leaking credentials or payloads."""
detail = ""
try:
payload = response.json()
if isinstance(payload, dict):
error = payload.get("error")
source = error if isinstance(error, dict) else payload
message = source.get("message") if isinstance(source, dict) else None
if isinstance(message, str) and message.strip():
detail = f":{message.strip()[:300]}"
except ValueError:
pass
return f"图片生成服务返回 HTTP {response.status_code}{detail}"
def build_image_provider() -> ImageProvider:
"""Build the optional provider from server config, or a safe no-op."""
settings = ImageGenerationSettings.from_app_config()
if not settings.is_configured:
return NullImageProvider()
if settings.provider == "dashscope_native":
return DashScopeNativeImageProvider(settings)
return OpenAICompatibleImageProvider(settings)
@@ -229,5 +427,6 @@ __all__ = [
"ImageProvider",
"NullImageProvider",
"OpenAICompatibleImageProvider",
"DashScopeNativeImageProvider",
"build_image_provider",
]
@@ -33,6 +33,7 @@ from deerflow.agents.deep_research.cancellation import CancellationToken
from deerflow.agents.deep_research.config import DeepResearchConfig, ModelRole
from deerflow.agents.deep_research.events import EventSink
from deerflow.agents.deep_research.types import CompletionResult
from deerflow.utils.stream_text import InlineThinkTagFilter
if TYPE_CHECKING:
from langchain.chat_models import BaseChatModel
@@ -83,6 +84,16 @@ def _content_to_text(resp: Any) -> str:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
# Never fall through to ``content`` for structured reasoning
# blocks. Providers differ here, but those blocks are not
# report prose and must not reach ReportChunkEmitter.
if block.get("type") in {
"thinking",
"thinking_delta",
"reasoning",
"reasoning_delta",
}:
continue
parts.append(str(block.get("text") or block.get("content") or ""))
return "".join(parts)
return str(content or "")
@@ -99,7 +110,20 @@ def _reasoning_delta(chunk: Any) -> str:
"""
ak = getattr(chunk, "additional_kwargs", None) or {}
val = ak.get("reasoning_content") or ak.get("reasoning") or ""
return val if isinstance(val, str) else ""
if isinstance(val, str) and val:
return val
# Anthropic/OpenAI-compatible adapters may put the same increment in an
# explicitly typed content block instead of ``additional_kwargs``.
content = getattr(chunk, "content", None)
if isinstance(content, list):
parts = [
str(block.get("text") or block.get("content") or "")
for block in content
if isinstance(block, dict)
and block.get("type") in {"thinking", "thinking_delta", "reasoning", "reasoning_delta"}
]
return "".join(parts)
return ""
def _extract_usage(resp: Any) -> dict[str, Any]:
@@ -234,9 +258,7 @@ class DeerFlowCompletionBackend:
parsed = _try_parse_structured(text, response_schema)
if parsed is not None:
await self._record_usage(model_name, operation, usage)
return CompletionResult(
text=text, model=model_name, usage=usage, structured=parsed
)
return CompletionResult(text=text, model=model_name, usage=usage, structured=parsed)
if attempt == 0:
# One repair re-prompt (§9.5).
logger.warning("deep_research llm: JSON parse failed for %s, retrying", operation)
@@ -306,13 +328,20 @@ class DeerFlowCompletionBackend:
text_parts: list[str] = []
usage: dict[str, Any] = {}
inline_think_filter = InlineThinkTagFilter()
async for chunk in model.astream(to_langchain_messages(messages), **kwargs):
if self._cancel is not None:
self._cancel.raise_if_cancelled()
reasoning = _reasoning_delta(chunk)
if reasoning and on_reasoning is not None:
await on_reasoning(reasoning)
delta = _content_to_text(chunk)
# A few OpenAI-compatible models put their reasoning in the
# ordinary content channel instead of ``reasoning_content``.
# Split it from Markdown even when tags span tokens: callers may
# display a live thought trace, but it never joins ``text``.
delta, inline_reasoning = inline_think_filter.push_parts(_content_to_text(chunk))
if inline_reasoning and on_reasoning is not None:
await on_reasoning(inline_reasoning)
if delta:
text_parts.append(delta)
if on_delta is not None:
@@ -321,6 +350,14 @@ class DeerFlowCompletionBackend:
if any(chunk_usage.values()):
usage = chunk_usage
tail, inline_reasoning_tail = inline_think_filter.finish_parts()
if inline_reasoning_tail and on_reasoning is not None:
await on_reasoning(inline_reasoning_tail)
if tail:
text_parts.append(tail)
if on_delta is not None:
await on_delta(tail)
await self._record_usage(model_name, operation, usage)
return CompletionResult(text="".join(text_parts), model=model_name, usage=usage)
@@ -328,9 +365,7 @@ class DeerFlowCompletionBackend:
async def _record_usage(self, model_name: str, operation: str, usage: dict[str, Any]) -> None:
"""Accumulate per-model totals + emit a ``model_usage`` event."""
bucket = self._usage_totals.setdefault(
model_name, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
)
bucket = self._usage_totals.setdefault(model_name, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
for k in bucket:
bucket[k] += int(usage.get(k, 0) or 0)
@@ -390,10 +425,7 @@ def _schema_instructions(schema: type[BaseModel]) -> str:
"""Build a compact "respond as JSON matching this schema" instruction."""
props = schema.model_json_schema().get("properties", {})
fields = ", ".join(f'"{k}"' for k in props)
return (
"Respond with ONLY a valid JSON object (no markdown, no prose) "
f"containing these keys: {fields}."
)
return f"Respond with ONLY a valid JSON object (no markdown, no prose) containing these keys: {fields}."
def _inject_schema_hint(messages: list[BaseMessage], hint: str) -> list[BaseMessage]:
@@ -403,21 +435,13 @@ def _inject_schema_hint(messages: list[BaseMessage], hint: str) -> list[BaseMess
return [*messages, SystemMessage(content=hint)]
def _inject_repair_hint(
messages: list[BaseMessage], bad_text: str, schema: type[BaseModel]
) -> list[BaseMessage]:
def _inject_repair_hint(messages: list[BaseMessage], bad_text: str, schema: type[BaseModel]) -> list[BaseMessage]:
"""Re-prompt: 'your last reply was invalid JSON, fix it'."""
from langchain_core.messages import HumanMessage
return [
*messages,
HumanMessage(
content=(
"Your previous reply was not valid JSON and could not be parsed:\n"
f"{bad_text[:500]}\n\n"
"Please respond again with ONLY a valid JSON object."
)
),
HumanMessage(content=(f"Your previous reply was not valid JSON and could not be parsed:\n{bad_text[:500]}\n\nPlease respond again with ONLY a valid JSON object.")),
]
@@ -63,6 +63,11 @@ ALL_PHASES: tuple[ResearchPhase, ...] = (
EventType = Literal[
"job_status",
# Terminal events are kept separate from job_status for the historical
# event timeline. The executor persists these after the DB state has
# been committed, so a reconnecting browser receives an explicit end.
"job_completed",
"job_failed",
"phase_changed",
"plan_created",
"queries_planned",
@@ -364,6 +364,7 @@ class BasicRunner:
messages=[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": prompt}],
operation="write_report",
on_delta=emitter.add,
on_reasoning=emitter.add_thinking,
)
await emitter.flush()
return result.text
@@ -338,6 +338,7 @@ class DetailedRunner(BasicRunner):
],
operation="write_detailed_introduction",
on_delta=emitter.add,
on_reasoning=emitter.add_thinking,
)
await emitter.flush()
return result.text
@@ -373,6 +374,7 @@ class DetailedRunner(BasicRunner):
],
operation="write_detailed_section",
on_delta=emitter.add,
on_reasoning=emitter.add_thinking,
)
await emitter.flush()
return result.text
@@ -389,6 +391,7 @@ class DetailedRunner(BasicRunner):
],
operation="write_detailed_conclusion",
on_delta=emitter.add,
on_reasoning=emitter.add_thinking,
)
await emitter.flush()
return result.text
@@ -64,6 +64,7 @@ async def generate_report_images(
)
image_paths: dict[str, str] = {}
amended_report = report_markdown
total = len(sections)
for position, section in enumerate(sections, 1):
if cancellation is not None:
@@ -72,7 +73,7 @@ async def generate_report_images(
await adapters.events.emit(
"image_generation_started",
phase="illustrating",
payload={"section": section, "position": position},
payload={"section": section, "position": position, "total": total},
)
try:
generated = await adapters.images.generate(
@@ -95,13 +96,26 @@ async def generate_report_images(
await adapters.events.emit(
"image_generated",
phase="illustrating",
payload={"section": section, "position": position, "name": name, "alt": alt},
payload={
"section": section,
"position": position,
"total": total,
"name": name,
"alt": alt,
},
)
except ImageGenerationError as exc:
await adapters.events.emit(
"warning",
phase="illustrating",
payload={"code": "image_generation_failed", "message": str(exc), "recoverable": True},
payload={
"code": "image_generation_failed",
"message": str(exc),
"recoverable": True,
"section": section,
"position": position,
"total": total,
},
)
except Exception: # noqa: BLE001
# Third-party image APIs and artifact storage must never turn a
@@ -114,6 +128,9 @@ async def generate_report_images(
"code": "image_generation_failed",
"message": "报告配图生成失败,已继续保留文字报告",
"recoverable": True,
"section": section,
"position": position,
"total": total,
},
)
@@ -321,6 +321,7 @@ class MultiAgentRunner(BasicRunner):
],
operation="revise_multi_agent_report",
on_delta=emitter.add,
on_reasoning=emitter.add_thinking,
)
await emitter.flush()
return {"report": result.text, "revision_count": int(state.get("revision_count") or 0) + 1}
@@ -64,6 +64,20 @@ class ReportChunkEmitter:
await self._emit(self._buffer[: self._chunk_size])
self._buffer = self._buffer[self._chunk_size :]
async def add_thinking(self, delta: str) -> None:
"""Forward reasoning to the live UI without persisting it as report text."""
if not delta:
return
await self._events.publish_live(
"report_thinking",
phase=self._phase,
payload={
"delta": delta,
"target": self._target,
"reportVersion": self._report_version,
},
)
async def flush(self) -> None:
"""Persist the final tail once the model stream has finished."""
if self._buffer:
@@ -0,0 +1,90 @@
"""Safe helpers for provider text streams.
Some OpenAI-compatible providers place reasoning in the normal ``content``
stream as ``<think>...</think>`` instead of using LangChain's dedicated
reasoning fields. Those fragments must be separated from report/file content
while callers can still surface them in an explicitly marked live UI trace.
"""
from __future__ import annotations
class InlineThinkTagFilter:
"""Separate inline ``<think>`` blocks while preserving token streaming.
Tags can be split across arbitrary provider chunks, so regex-per-chunk is
not safe: it can leak a partial tag or the model's reasoning into the
visible draft. This small state machine holds only the suffix that might
still form a tag and emits all confirmed answer text immediately.
"""
_OPEN_TAG = "<think>"
_CLOSE_TAG = "</think>"
def __init__(self) -> None:
self._inside_think = False
self._pending = ""
@staticmethod
def _possible_tag_prefix_size(content: str, tag: str) -> int:
"""Return the longest suffix which may become the next tag."""
lowered = content.lower()
for size in range(min(len(content), len(tag) - 1), 0, -1):
if lowered[-size:] == tag[:size]:
return size
return 0
def push_parts(self, chunk: str) -> tuple[str, str]:
"""Return ``(visible_text, thinking_text)`` for one provider chunk."""
if not chunk:
return "", ""
remaining = self._pending + chunk
self._pending = ""
visible: list[str] = []
thinking: list[str] = []
while remaining:
tag = self._CLOSE_TAG if self._inside_think else self._OPEN_TAG
marker_at = remaining.lower().find(tag)
if marker_at >= 0:
(thinking if self._inside_think else visible).append(remaining[:marker_at])
remaining = remaining[marker_at + len(tag) :]
self._inside_think = not self._inside_think
continue
suffix_size = self._possible_tag_prefix_size(remaining, tag)
confirmed = remaining[:-suffix_size] if suffix_size else remaining
(thinking if self._inside_think else visible).append(confirmed)
self._pending = remaining[-suffix_size:] if suffix_size else ""
break
return "".join(visible), "".join(thinking)
def push(self, chunk: str) -> str:
"""Compatibility helper returning only confirmed visible text."""
return self.push_parts(chunk)[0]
def finish_parts(self) -> tuple[str, str]:
"""Flush the remaining visible or thinking text at end of stream."""
if self._inside_think:
thinking = self._pending
self._pending = ""
self._inside_think = False
return "", thinking
tail = self._pending
self._pending = ""
return tail, ""
def finish(self) -> str:
"""Compatibility helper returning only the visible stream tail."""
return self.finish_parts()[0]
def strip_inline_think_tags(content: str) -> str:
"""Return complete text with inline model-thinking blocks removed."""
text_filter = InlineThinkTagFilter()
return text_filter.push(content) + text_filter.finish()
__all__ = ["InlineThinkTagFilter", "strip_inline_think_tags"]
@@ -38,17 +38,27 @@ class _ChainStore:
return self.chain
class _AgentStore:
def __init__(self, agents: dict[str, dict[str, object]] | None = None) -> None:
self.agents = agents or {}
async def get_any(self, agent_id: str) -> dict[str, object] | None:
return self.agents.get(agent_id)
def _request(
role: str,
chain: dict[str, object] | None = None,
*,
email: str = "lqq@example.com",
agents: dict[str, dict[str, object]] | None = None,
):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
business_mapping_store=_MappingStore(),
roundtable_chain_store=_ChainStore(chain),
agent_store=_AgentStore(agents),
)
),
state=SimpleNamespace(user=SimpleNamespace(id="admin-id", email=email, system_role=role)),
@@ -130,3 +140,34 @@ async def test_global_mapping_rejects_a_private_chain() -> None:
assert error.value.status_code == 400
assert request.app.state.business_mapping_store.calls == []
@pytest.mark.asyncio
async def test_global_mapping_rejects_a_chain_with_an_unpublished_agent_seat() -> None:
request = _request(
"admin",
{
"id": "chain-8bf",
"title": "岗位协同链",
"is_public": True,
"seats": [
{"agent_id": "published", "name": "已发布智能体"},
{"agent_id": "private", "name": "未发布智能体"},
],
},
agents={
"published": {"id": "published", "user_id": "owner", "published": True, "is_private": False},
"private": {"id": "private", "user_id": "owner", "published": False, "is_private": False},
},
)
with pytest.raises(HTTPException) as error:
await business_mapping_router.update_mapping(
request,
"8BF",
business_mapping_router.BusinessMappingUpdateRequest(chain_id="chain-8bf"),
)
assert error.value.status_code == 400
assert "未发布智能体" in error.value.detail
assert request.app.state.business_mapping_store.calls == []
@@ -4,6 +4,7 @@ import pytest
from app.gateway import deep_research_job_executor as executor_module
from app.gateway.deep_research_job_executor import DeepResearchJobExecutor
from deerflow.agents.deep_research.events import DeepResearchEvent
from deerflow.agents.deep_research.types import DeepResearchResult
@@ -64,3 +65,8 @@ async def test_report_projection_failure_never_marks_job_completed(monkeypatch):
)
assert jobs.progress_calls == []
def test_terminal_timeline_events_are_valid_durable_event_types():
assert DeepResearchEvent(type="job_completed", phase="done").type == "job_completed"
assert DeepResearchEvent(type="job_failed", phase="done").type == "job_failed"
@@ -117,6 +117,61 @@ async def test_queued_job_still_starts_when_session_projection_temporarily_fails
assert dispatcher.nudges == 1
@pytest.mark.asyncio
async def test_chat_report_card_can_enable_images_after_collection(monkeypatch):
"""The write-time card override must win over the collection-time default."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _ChatSessionStore(_SessionStore):
async def get(self, session_id: str, *, user_id: str): # noqa: ARG002
return {
"id": session_id,
"query": "测试主题",
"title": "测试主题",
"runtime_thread_id": "collector-thread",
"config_snapshot": {
"mode": "basic",
"collection_mode": "chat",
"generate_images": False,
"max_generated_images": 2,
},
}
class _CapturingJobStore(_JobStore):
def __init__(self) -> None:
self.created: dict | None = None
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
self.created = kwargs
return {"id": "drj_images", "status": "queued"}, True
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
sessions = _ChatSessionStore()
jobs = _CapturingJobStore()
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(
entry="chat",
config={"generate_images": True, "max_generated_images": 3},
),
_request(sessions=sessions, jobs=jobs, dispatcher=_Dispatcher()),
)
assert response["jobId"] == "drj_images"
assert jobs.created is not None
snapshot = jobs.created["input_snapshot"]["config"]
assert snapshot["generate_images"] is True
assert snapshot["max_generated_images"] == 3
# The session snapshot is persisted too, so a reload/replay keeps the
# write-time choice instead of reverting to the creation-time false.
assert any(
update.get("config_snapshot", {}).get("generate_images") is True
for update in sessions.updates
)
@pytest.mark.asyncio
async def test_stream_stays_open_when_durable_replay_has_a_transient_failure(monkeypatch):
"""A stream DB blip must yield a recoverable warning, not an ASGI error group."""
@@ -206,14 +206,14 @@ def test_position_intent_prompt_keeps_the_original_step_one_guardrails():
assert phrase in prompt
def test_position_intent_clarification_waits_for_the_user_before_ready():
"""A same-run READY block must not consume the user-assistance pause."""
def test_fresh_intent_clarification_waits_for_the_user_before_ready():
"""A same-run READY block must not consume a fresh assistance pause."""
assert intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=True,
)
assert not intent_router._clarification_blocks_ready(
assert intent_router._clarification_blocks_ready(
intent_router.INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=True,
+3 -1
View File
@@ -54,7 +54,9 @@ setsid bash -c '
uv run uvicorn app.gateway.app:app \
--host "$2" \
--port "$3" \
--reload
--reload \
--reload-exclude ".deer-flow/*" \
--reload-exclude ".runtime/*"
' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" "${BACKEND_VENDOR_DIR}" \
>"${LOG_FILE}" 2>&1 &