Merge branch 'master-nj' into 'master'

增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。

See merge request knowledge-base-bj/deerflow-code!25
This commit is contained in:
董宏凯 committed 2026-08-18 09:55:17 +00:00
commit 33b0af333c
81 files changed
+8436 -1076

No files matched your search

+6
View File
@@ -93,6 +93,12 @@ offline-backend-20260512/backend/tests/*
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Deep-research reliability regression coverage.
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
deploy
.history
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_BRAND_WELCOME_DARK_URL: "",
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
// VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
@@ -50,6 +50,10 @@ window.__CM_RUNTIME_CONFIG__ = {
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
// 不影响原多智能体会商使用的任务详情接口。
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
// 佳来模型对话接口。
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
@@ -90,7 +94,7 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer",
VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer",
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
@@ -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 />
@@ -780,11 +780,10 @@ function buildTurnReferenceBatches(
continue;
}
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
if (nextHumanIndex === -1 && isLoading) {
continue;
}
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
const assistant =
findLastAssistantAnswer(messages, index + 1, endIndex) ??
findFrontendRagAssistant(messages, index + 1, endIndex);
if (!assistant?.id) {
continue;
}
@@ -794,6 +793,14 @@ function buildTurnReferenceBatches(
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false,
}).filter((source) => source.mode !== "none");
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
// those cards available immediately, while keeping normal streaming turns
// on their existing "wait for an answer" behavior.
const isFrontendRagTurn =
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
continue;
}
if (sources.length > 0) {
batches.push({
id: assistant.id,
@@ -832,6 +839,23 @@ function findLastAssistantAnswer(
return null;
}
function findFrontendRagAssistant(
messages: Message[],
start: number,
end: number,
): Message | null {
for (let index = end - 1; index >= start; index -= 1) {
const message = messages[index];
if (
message?.type === "ai" &&
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
) {
return message;
}
}
return null;
}
function hasAssistantAnswerContent(message: Message): boolean {
const content = message.content;
if (typeof content === "string") {
@@ -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>
);
}
@@ -6,9 +6,10 @@ import {
ExternalLinkIcon,
FileIcon,
Loader2Icon,
PencilIcon,
SearchIcon,
} from 'lucide-react';
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
import {
memo,
useCallback,
@@ -16,9 +17,10 @@ import {
useEffect,
useMemo,
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
} from 'react';
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
import rehypeKatex from 'rehype-katex';
import { Loader } from '@/components/ai-elements/loader';
@@ -838,7 +840,7 @@ function FrontendRagSourcesPanel({
const [open, setOpen] = useState(false);
if (sources.length === 0) return null;
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full">
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button
type="button"
@@ -944,14 +946,17 @@ function MultiRoundRagContent({
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
if (!trace.isComplete) setOpen(true);
}, [trace.isComplete]);
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
// Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]);
return (
<div className="space-y-3">
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button
type="button"
@@ -965,31 +970,42 @@ function MultiRoundRagContent({
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `已完成 ${trace.rounds.length} 轮检索`
: '正在分析问题并检索资料'}
</div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
</div>
</div>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
/>
</button>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<MultiRoundRagRoundCard
key={round.id}
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
))}
</div>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
className={cn(
'relative pl-5',
index < trace.rounds.length - 1 && 'pb-1',
)}
>
{index < trace.rounds.length - 1 && (
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
</div>
))}
</div>
)}
</section>
<section className="border-border/70 rounded-xl border p-4">
@@ -1014,7 +1030,7 @@ function MultiRoundRagContent({
);
}
function MultiRoundRagRoundCard({
function MultiRoundRagRoundCard({
index,
round,
isLoading,
@@ -1026,78 +1042,228 @@ function MultiRoundRagRoundCard({
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
return (
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
{index}
</span>
<span>{round.title}</span>
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.thought && (
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</section>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{round.query && (
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
<SearchIcon className="size-3 shrink-0" />
<span className="truncate">{round.query}</span>
</div>
)}
{round.retrievals.length > 0 && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">
检索内容 · {round.retrievals.length} 条
</div>
<div className="space-y-2">
{round.retrievals.map((item, resultIndex) => (
<details
key={`${item.id ?? item.title}-${resultIndex}`}
className="border-border/60 rounded-md border px-3 py-2"
open={resultIndex === 0}
>
<summary className="cursor-pointer list-none text-sm font-medium">
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
{item.title}
</summary>
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</div>
{(item.source || item.time) && (
<div className="text-muted-foreground mt-2 text-xs">
{[item.source, item.time].filter(Boolean).join(' · ')}
</div>
)}
</details>
))}
</div>
</section>
)}
</div>
);
}
}) {
const [open, setOpen] = useState(!round.query || round.isSearching);
useEffect(() => {
// A newly created round stays expanded to expose its thought and loading
// query. Once that query has returned, retain only the round header until
// the user asks to inspect the completed detail again.
setOpen(!round.query || round.isSearching);
}, [round.id, round.isSearching, round.query]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate">{round.title}</span>
{round.isSearching ? (
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
检索中
</span>
) : (
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
<div className="pt-3">
{round.thought && (
<MultiRoundRagDetailSection
title="思考内容"
icon={<SparklesIcon className="text-primary size-4" />}
>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</MultiRoundRagDetailSection>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-lg border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{(round.query || round.isSearching) && (
<div
className={cn(
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
round.isSearching && 'animate-pulse motion-reduce:animate-none',
)}
aria-live={round.isSearching ? 'polite' : undefined}
>
{round.isSearching ? (
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">检索词</span>
<span className="min-w-0 truncate font-medium">
{round.query || '正在生成检索词…'}
</span>
</div>
)}
{(round.query || round.isSearching || round.retrievals.length > 0) && (
<MultiRoundRagDetailSection
defaultOpen={!round.isSearching}
title="检索内容"
icon={<FileIcon className="text-primary size-3.5" />}
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
>
{round.isSearching ? (
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
正在召回相关内容…
</div>
) : (
<div className="space-y-2">
{round.retrievals.length > 0 ? (
round.retrievals.map((item, resultIndex) => (
<MultiRoundRagResultCard
key={`${item.id ?? item.title}-${resultIndex}`}
item={item}
index={resultIndex + 1}
/>
))
) : (
<p className="text-muted-foreground py-1 text-sm">
本轮未召回相关资料。
</p>
)}
</div>
)}
</MultiRoundRagDetailSection>
)}
</div>
</CollapsibleContent>
</article>
</Collapsible>
);
}
function MultiRoundRagDetailSection({
title,
icon,
suffix,
defaultOpen = true,
children,
}: {
title: string;
icon: ReactNode;
suffix?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
{icon}
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
{suffix}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
{children}
</CollapsibleContent>
</section>
</Collapsible>
);
}
function MultiRoundRagResultCard({
item,
index,
}: {
item: MultiRoundRagRound['retrievals'][number];
index: number;
}) {
const [open, setOpen] = useState(index === 1);
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
{item.title}
</span>
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</p>
{(sourceMeta || item.url) && (
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{sourceMeta && <span>{sourceMeta}</span>}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
查看原文
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null,
@@ -255,15 +255,15 @@ export function MessageList({
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const hasSingleRoundRag = useMemo(
const hasSingleRoundRag = useMemo(
() =>
messages.some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -436,11 +436,12 @@ export function MessageList({
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
contentClassName,
)}
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
@@ -54,6 +54,7 @@ import type {
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { createClientId } from "@/lib/client-id";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return createClientId("shortcut");
}
function newShortcutParam(): CompactShortcutRouteParam {
@@ -43,6 +43,7 @@ import {
customThemeTextContrast,
type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme";
import { createClientId } from "@/lib/client-id";
import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12;
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
];
function makeThemeId(): `custom-theme-${string}` {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
return `custom-theme-${suffix}`;
}
@@ -6,6 +6,7 @@ import type {
DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
import { createClientId } from "@/lib/client-id";
export type DocumentRewritePresentation = {
id: string;
@@ -19,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. */
@@ -42,9 +49,14 @@ type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "generateImages"
| "maxGeneratedImages"
| "generatedImageCount"
| "illustrationTarget"
| "originalCharCount"
| "writtenCharCount"
| "requirementsPlan"
| "thinkingText"
| "warnings"
| "summary"
| "originalContent"
@@ -59,6 +71,8 @@ type CreatePresentationInput = Omit<
originalContent?: string;
rewrittenContent?: string | null;
requirementsPlan?: string;
generateImages?: boolean;
maxGeneratedImages?: number;
};
type DocumentRewritePresentationContextValue = {
@@ -74,10 +88,7 @@ const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
return createClientId("drw-ui");
}
export function DocumentRewritePresentationProvider({
@@ -96,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 ?? "",
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { runSentimentAgent, toAgUiMessages } from "./api";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.sentiment-agent.";
@@ -53,9 +54,9 @@ export function useSentimentAgentChat(threadId: string) {
const question = query.trim();
if (!question) return;
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
const assistantId = createClientId("sentiment-ai");
const userMessage: Message = {
id: `sentiment-human-${crypto.randomUUID()}`,
id: createClientId("sentiment-human"),
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: {
@@ -115,7 +116,7 @@ export function useSentimentAgentChat(threadId: string) {
try {
await runSentimentAgent({
threadId,
runId: `run-${crypto.randomUUID()}`,
runId: createClientId("run"),
messages: history,
signal: controller.signal,
handlers: {
@@ -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();
}
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.multi-round-rag.";
@@ -13,6 +14,9 @@ export type MultiRoundRagRetrieval = {
time?: string;
recUuid?: string;
url?: string;
/** Original tool payload, retained so a future renderer can add more fields
* without losing data received from the multi-round service. */
raw?: Record<string, unknown>;
};
export type MultiRoundRagRound = {
@@ -74,7 +78,7 @@ function writeStoredMessages(threadId: string, messages: Message[]) {
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -87,8 +91,11 @@ function asRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
/** Values in the deployed tools are occasionally numeric IDs, not strings. */
function asText(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return "";
}
function parseJson(value: unknown): unknown {
@@ -103,8 +110,11 @@ function parseJson(value: unknown): unknown {
function parseEventLine(line: string): MultiRoundEvent | null {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return null;
const payload = trimmed.slice("data:".length).trim();
if (!payload || payload === "[DONE]") return { event: "done" };
try {
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent;
const parsed = parseJson(payload);
return asRecord(parsed) as MultiRoundEvent | null;
} catch {
return null;
}
@@ -141,10 +151,10 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
if (current.title === "正在准备检索" && !current.thought && !current.query) {
current.title = "思考过程";
current.thought = thought;
return;
return current;
}
current.isSearching = false;
trace.rounds.push({
const next = {
id: createId("multi-rag-round"),
title: "思考过程",
thought,
@@ -152,7 +162,9 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
query: "",
isSearching: true,
retrievals: [],
});
};
trace.rounds.push(next);
return next;
}
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
@@ -162,49 +174,94 @@ function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
current.body = `${current.body ?? ""}${body}`;
}
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
const root = asRecord(parseJson(value));
const list =
(Array.isArray(root?.results) && root.results) ||
(Array.isArray(root?.organic) && root.organic) ||
[];
return list.flatMap((item, index) => {
function firstArray(values: unknown[]): unknown[] {
for (const value of values) {
const parsed = parseJson(value);
if (Array.isArray(parsed)) return parsed;
}
return [];
}
function toRecordList(value: unknown): Record<string, unknown>[] {
const parsed = parseJson(value);
const root = asRecord(parsed);
const list = Array.isArray(parsed)
? parsed
: firstArray([
root?.results,
root?.organic,
root?.data,
root?.items,
root?.list,
root?.documents,
]);
return list.flatMap((item) => {
if (typeof item === "string") return [{ content: item }];
const record = asRecord(item);
if (!record) return [];
const recUuid = asString(record.recUuid ?? record.rec_uuid);
const url = asString(record.url ?? record.link ?? record.href);
return record ? [record] : [];
});
}
/**
* GeneralQA's service fans out to several business search tools. Normalising
* all of their result shapes here prevents a successful tool call from being
* silently absent just because it did not use `results` or `organic`.
*/
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
const list = toRecordList(value);
return list.flatMap((item, index) => {
const recUuid = asText(item.recUuid ?? item.rec_uuid ?? item.uuid);
const url = asText(item.url ?? item.link ?? item.href);
const title = asText(
item.title ?? item.m_title ?? item.taskName ?? item.user_name ?? item.name ?? item.user_id,
);
const content = asText(
item.content ??
item.m_content ??
item.m_conent ??
item.snippet ??
item.text ??
item.overview ??
item.page_content ??
item.description ??
item.summary,
);
return [
{
id: asString(record.id) || recUuid || undefined,
title: asString(record.title) || `检索资料 ${index + 1}`,
content: asString(record.content ?? record.snippet ?? record.text),
source: asString(record.source ?? record.source1 ?? url) || undefined,
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined,
id: asText(item.id) || recUuid || undefined,
title: title || `检索资料 ${index + 1}`,
content: content || (url && !title ? url : ""),
source: asText(item.source ?? item.source1 ?? item.m_source ?? item.platform) || undefined,
time: asText(item.publish_time ?? item.m_publish ?? item.m_publicsh ?? item.date ?? item.time) || undefined,
recUuid: recUuid || undefined,
url: /^https?:\/\//i.test(url) ? url : undefined,
raw: item,
},
];
});
}
function mapReferences(value: unknown): MultiRoundRagSource[] {
const record = asRecord(value);
const list = Array.isArray(record?.references) ? record.references : [];
const parsed = parseJson(value);
const record = asRecord(parsed);
const list = Array.isArray(parsed)
? parsed
: firstArray([record?.references, record?.results, record?.sources, record?.documents]);
return list.flatMap((item, index) => {
const source = asRecord(item);
if (!source) return [];
const content = asString(source.content ?? source.snippet);
const rawUrl = asString(source.url ?? source.link ?? source.href);
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid);
const content = asText(source.content ?? source.m_content ?? source.m_conent ?? source.snippet ?? source.text);
const rawUrl = asText(source.url ?? source.link ?? source.href);
const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid);
return [
{
id: asString(source.id) || recUuid || undefined,
title: asString(source.title) || `参考资料 ${index + 1}`,
type: asString(source.type) || "参考资料",
snippet: asString(source.snippet) || content,
id: asText(source.id) || recUuid || undefined,
title: asText(source.title ?? source.m_title ?? source.taskName ?? source.user_name) || `参考资料 ${index + 1}`,
type: asText(source.type) || "参考资料",
snippet: asText(source.snippet) || content,
content,
source: asString(source.source ?? source.source1) || undefined,
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined,
source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined,
time: asText(source.time ?? source.m_publish ?? source.m_publicsh ?? source.publish_time ?? source.date) || undefined,
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: source,
},
@@ -222,6 +279,71 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
};
}
function toolTitle(toolName: string): string {
switch (toolName) {
case "local_search":
return "本地知识检索";
case "tavily_search":
return "联网检索";
case "google_search":
case "getTaskLikeTop":
return "思考过程";
case "search_user_profile":
return "靶标平台画像库";
case "get_taiwan_news":
case "search_keyword_taiwan_news":
return "台湾热点新闻";
case "get_world_news":
return "世界热点新闻";
case "get_root_messaage_comment":
return "获取该消息的全部评论";
case "search_user_behavior":
return "查询该用户的历史行为";
case "get_america_news":
return "美国热点新闻";
case "get_time_data":
return "态势新闻搜索";
case "get_textid_content":
return "获取新闻详细内容";
default:
return toolName || "检索过程";
}
}
function isSearchTool(toolName: string): boolean {
return [
"local_search",
"tavily_search",
"google_search",
"getTaskLikeTop",
"search_user_profile",
"get_taiwan_news",
"search_keyword_taiwan_news",
"get_world_news",
"get_root_messaage_comment",
"search_user_behavior",
"get_america_news",
"get_time_data",
].includes(toolName);
}
function queryFromToolInput(input: Record<string, unknown>, result: unknown): string {
const resultRecord = asRecord(parseJson(result));
const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig);
if (direct) return direct;
if (input.s || input.e) {
return [asText(input.s), asText(input.e)].filter(Boolean).join(" - ");
}
return asText(resultRecord?.query ?? resultRecord?.q);
}
function textFromEventData(value: unknown): string {
const parsed = parseJson(value);
if (typeof parsed === "string") return parsed;
const record = asRecord(parsed);
return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output);
}
function isSingleRoundRagMessage(message: Message) {
return message.additional_kwargs?.single_round_rag === true;
}
@@ -370,92 +492,204 @@ export function useMultiRoundRagMessages(threadId: string) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let activeThoughtRound: MultiRoundRagRound | null = null;
let activeThoughtTailIsAnswer = false;
let answerFromShowText = false;
const appendAnswer = (text: string, allowCompleteSnapshot = false) => {
if (!text) return;
// Some gateways emit one complete `message` after a `show_text`
// answer. Restrict snapshot detection to that case and to a
// meaningful length: adjacent streaming chunks can legitimately
// contain the same character, and must never be discarded.
if (
allowCompleteSnapshot &&
trace.answer.length >= 16 &&
text.startsWith(trace.answer)
) {
trace.answer = text;
return;
}
trace.answer += text;
};
const consumeShowText = (text: string, tailIsAnswer = false) => {
if (!text) return;
if (activeThoughtRound) {
const endIndex = text.indexOf("</think>");
if (endIndex < 0) {
activeThoughtRound.thought += text;
return;
}
activeThoughtRound.thought += text.slice(0, endIndex);
const roundBody = text.slice(endIndex + "</think>".length).trim();
if (roundBody) {
if (activeThoughtTailIsAnswer) {
appendAnswer(roundBody);
answerFromShowText = false;
trace.isComplete = true;
} else {
appendRoundBody(trace, roundBody);
}
}
activeThoughtRound = null;
activeThoughtTailIsAnswer = false;
return;
}
const thinkStart = text.indexOf("<think>");
if (thinkStart < 0) {
const current = latestRound(trace);
current.isSearching = false;
appendAnswer(text);
answerFromShowText = true;
trace.isComplete = true;
return;
}
const prefix = text.slice(0, thinkStart).trim();
if (prefix) appendRoundBody(trace, prefix);
// A full <think>…</think> envelope is normally delivered as one
// tool call. Keep the unfinished variant open as well: production
// proxies are allowed to split an SSE frame at any byte boundary.
const thinkEnd = text.indexOf("</think>", thinkStart + "<think>".length);
const thought = text
.slice(thinkStart + "<think>".length, thinkEnd < 0 ? undefined : thinkEnd)
.trim();
const thoughtRound = addThinkingRound(trace, thought);
if (thinkEnd < 0) {
activeThoughtRound = thoughtRound;
activeThoughtTailIsAnswer = tailIsAnswer;
return;
}
const roundBody = text.slice(thinkEnd + "</think>".length).trim();
if (roundBody) {
if (tailIsAnswer) {
appendAnswer(roundBody);
answerFromShowText = false;
trace.isComplete = true;
} else {
appendRoundBody(trace, roundBody);
}
}
};
const consumeToolCall = (payload: unknown) => {
const parsedPayload = parseJson(payload);
const tools = Array.isArray(parsedPayload) ? parsedPayload : [parsedPayload];
for (const toolValue of tools) {
const tool = asRecord(toolValue);
if (!tool) continue;
const toolName = asText(tool.tool_name ?? tool.name);
const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
consumeShowText(asText(input.text ?? input.content));
continue;
}
if (toolName === "get_textid_content") {
current.title = toolTitle(toolName);
const article = asRecord(parseJson(input.result));
const body = asText(article?.content ?? article?.text ?? input.content ?? input.result);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
continue;
}
const hasResult = input.result !== undefined;
const query = queryFromToolInput(input, input.result);
if (isSearchTool(toolName) || hasResult) {
current.title = toolTitle(toolName);
current.query = query || current.query;
if (!hasResult && isSearchTool(toolName)) {
// GeneralQA can receive a model text event before the search
// tool announces its query. The query starts a new visible
// loading phase, so restore the round/trace state here and
// keep the query badge spinning until its result arrives.
current.isSearching = true;
trace.isComplete = false;
} else if (hasResult) {
const retrievals = mapRetrievals(input.result);
// An empty search response is still a completed response; it
// must replace a previous list instead of leaving stale cards.
current.retrievals = retrievals;
if (retrievals.length === 0) {
const fallbackBody = textFromEventData(input.result);
if (fallbackBody && fallbackBody !== current.query) {
appendRoundBody(trace, fallbackBody);
}
}
current.isSearching = false;
}
}
}
};
const finishTrace = () => {
trace.isComplete = true;
activeThoughtRound = null;
activeThoughtTailIsAnswer = false;
for (const round of trace.rounds) round.isSearching = false;
};
const consumeEvent = (event: MultiRoundEvent) => {
const eventType = asText(event.event);
const data = event.data;
if (eventType === "tool_call") {
consumeToolCall(data);
} else if (eventType === "message") {
const text = textFromEventData(data);
if (text) {
if (activeThoughtRound || /<\/?think>/i.test(text)) {
// Most deployments put thoughts in `show_text`, but this
// fallback also preserves a compatible gateway that emits
// `<think>` in normal message chunks.
consumeShowText(text, true);
} else {
latestRound(trace).isSearching = false;
appendAnswer(text, answerFromShowText);
answerFromShowText = false;
trace.isComplete = true;
}
}
} else if (eventType === "references") {
sources = mapReferences(data);
} else if (eventType === "error") {
const message = textFromEventData(data) || "多轮 RAG 服务返回错误。";
setError(message);
if (!trace.answer) trace.answer = message;
finishTrace();
} else if (eventType === "done") {
// A few compatible gateway versions include the whole final
// answer in `done`; render it when no `message` chunk was sent.
const finalText = textFromEventData(data);
if (finalText && !trace.answer) appendAnswer(finalText);
finishTrace();
}
refreshAssistant();
};
const consumeLines = (flush = false) => {
const lines = buffer.split(/\r?\n/);
buffer = flush ? "" : (lines.pop() ?? "");
for (const line of lines) {
const event = parseEventLine(line);
if (event) consumeEvent(event);
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
const event = parseEventLine(line);
if (!event) continue;
const eventType = asString(event.event);
const data = event.data;
if (eventType === "tool_call") {
const tool = asRecord(data);
const toolName = asString(tool?.tool_name);
const input = asRecord(tool?.tool_input) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
const text = asString(input.text);
if (text.includes("<think>")) {
const thinkEnd = text.indexOf("</think>");
addThinkingRound(
trace,
(thinkEnd >= 0
? text.slice(0, thinkEnd + "</think>".length)
: text
)
.replace(/<\/?think>/g, "")
.trim(),
);
if (thinkEnd >= 0) {
const roundBody = text
.slice(thinkEnd + "</think>".length)
.trim();
if (roundBody) {
appendRoundBody(trace, roundBody);
}
}
} else {
// A non-thinking `show_text` immediately after a thought is
// this round's narrative; the terminal `message` event is
// still rendered only as the final answer below all rounds.
current.isSearching = false;
trace.answer += text;
trace.isComplete = true;
}
} else if (
toolName === "local_search" ||
toolName === "tavily_search" ||
toolName === "google_search"
) {
const queryData = asRecord(parseJson(input.result));
current.title =
toolName === "local_search" ? "本地知识检索" : "联网检索";
current.query =
asString(input.q ?? input.query) ||
asString(queryData?.query) ||
current.query;
const retrievals = mapRetrievals(input.result);
if (retrievals.length > 0) current.retrievals = retrievals;
current.isSearching = false;
} else if (toolName === "get_textid_content") {
const article = asRecord(parseJson(input.result));
const body =
asString(article?.content ?? article?.text) ||
asString(input.content);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
}
refreshAssistant();
} else if (eventType === "message" && typeof data === "string") {
latestRound(trace).isSearching = false;
trace.answer += data;
trace.isComplete = true;
refreshAssistant();
} else if (eventType === "references") {
sources = mapReferences(data);
refreshAssistant();
} else if (eventType === "done") {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
}
}
consumeLines();
}
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
buffer += decoder.decode();
consumeLines(true);
finishTrace();
refreshAssistant();
} catch (caught) {
trace.isComplete = true;
@@ -2,10 +2,11 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.single-round-rag.";
type KnowledgeBaseChunk = {
type KnowledgeBaseChunk = Record<string, unknown> & {
answer?: unknown;
docs?: unknown;
};
@@ -168,38 +169,116 @@ function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk {
}
}
function flattenDocs(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) {
return value.flatMap((item) => flattenDocs(item));
function readString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value;
}
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
if ("docs" in record) return flattenDocs(record.docs);
return [record];
return undefined;
}
function normalizeSources(value: unknown): RagSource[] {
return flattenDocs(value).map((item, index) => {
const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`);
const content = String(item.m_content ?? item.content ?? item.snippet ?? "");
const source = item.source1 ?? item.source;
const rawUrl = item.url ?? item.link ?? item.href;
return {
id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined,
title,
type: String(item.source1 ?? item.type ?? "知识库"),
snippet: content,
content,
source: typeof source === "string" ? source : undefined,
time: typeof item.m_publish === "string" ? item.m_publish : undefined,
url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: item,
};
function docsFallbackContent(docs: unknown): string | undefined {
const candidates = Array.isArray(docs) ? docs : [docs];
return readString(...candidates);
}
function buildKnowledgeDetailUrl(recUuid: string | undefined): string | undefined {
const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL")?.replace(/\/$/, "");
if (!knowledgeUrl || !recUuid) return undefined;
return `${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recUuid)}`;
}
function toRagSource(
record: Record<string, unknown>,
index: number,
fallbackContent?: string,
): RagSource {
const title =
readString(record.m_title, record.title, record.source, record.source1) ??
`参考资料 ${index + 1}`;
const content =
readString(
record.m_content,
// Some deployed responses spell this field incorrectly; keep it as a
// read-only compatibility alias rather than dropping the document.
record.m_conent,
record.content,
record.snippet,
fallbackContent,
record.url,
) ?? "";
const source = readString(record.source1, record.source, record.m_source);
const rawUrl = readString(record.url, record.link, record.href);
const recUuid = readString(record.recUuid, record.rec_uuid, record.uuid, record.id);
return {
id: recUuid,
title,
type: readString(record.type, record.source1) ?? "知识库",
snippet: content,
content,
source,
time: readString(record.m_publish, record.m_publicsh, record.time),
// GeneralQA uses recUuid to open the knowledge-detail page. Prefer that
// stable internal record link, then fall back to a backend-provided URL.
url:
buildKnowledgeDetailUrl(recUuid) ??
(rawUrl && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined),
raw: record,
};
}
/**
* A single-RAG stream is a concatenation of source envelopes, for example:
* `{docs:["..."], recUuid:"...", m_title:"...", m_content:"..."}`.
* The root-level metadata is the source; `docs` merely marks the envelope and
* supplies a fallback body. This mirrors GeneralQA's per-chunk processing.
*/
function normalizeSources(chunk: KnowledgeBaseChunk): RagSource[] {
const hasRootMetadata = [
chunk.recUuid,
chunk.rec_uuid,
chunk.m_title,
chunk.m_content,
chunk.m_conent,
chunk.source1,
chunk.m_publish,
chunk.m_publicsh,
].some((value) => typeof value === "string" && value.trim());
if (hasRootMetadata) {
return [toRagSource(chunk, 0, docsFallbackContent(chunk.docs))];
}
const docs = Array.isArray(chunk.docs) ? chunk.docs : chunk.docs ? [chunk.docs] : [];
return docs.flatMap((item, index): RagSource[] => {
if (typeof item === "string") {
return [toRagSource({}, index, item)];
}
if (!item || typeof item !== "object") return [];
return [toRagSource(item as Record<string, unknown>, index)];
});
}
function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[] {
const result = [...current];
const keys = new Set(
current.map((source) =>
source.id
? `id:${source.id}`
: `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`,
),
);
for (const source of incoming) {
const key = source.id
? `id:${source.id}`
: `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`;
if (keys.has(key)) continue;
keys.add(key);
result.push(source);
}
return result;
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -397,7 +476,9 @@ export function useSingleRoundRagMessages(threadId: string) {
for (const jsonText of extracted.objects) {
const chunk = parseKnowledgeBaseChunk(jsonText);
if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer });
if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs);
if (chunk.docs !== undefined) {
sources = mergeSources(sources, normalizeSources(chunk));
}
refreshAssistant();
}
}
@@ -0,0 +1,310 @@
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeEnv } from "@/env";
export interface EightBfTask {
id: string;
externalTaskId: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
taskPriority: string;
targetDirection: string;
taskStatus: string;
source: string;
raw: Record<string, unknown> | null;
}
export interface EightBfDictionaryOption {
label: string;
value: string;
alias?: string;
statusTone?: string;
}
export interface EightBfTaskDictionaries {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
}
export interface EightBfTaskContextPayload {
kind: "eightbf_roundtable_task";
usage: string;
task_id: string;
task: {
id: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
task_priority: string;
target_direction: string;
task_status: string;
};
task_tags: {
priority: string;
direction: string;
status: string;
};
}
/**
* 外部任务系统在离线部署或本地联调时可能不可用。此回退仅用于页面展示与
* 意图上下文,保持与岗位会商示例一致,避免左上任务卡退化成接口错误文本。
*/
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",
direction: "task_direction",
status: "task_status",
} as const;
function externalApiBase(): string {
// The position-roundtable Vue app calls this task system through
// VITE_API_BASE_URL (usually the /zncm gateway), not DeerFlow's own API.
return (
readRuntimeEnv("VITE_8BF_TASK_API_BASE_URL")?.replace(/\/+$/, "") ||
readRuntimeEnv("VITE_API_BASE_URL")?.replace(/\/+$/, "") ||
getBackendBaseURL().replace(/\/+$/, "")
);
}
function externalUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
}
/**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
* 两套 token 的用途不同,不能直接复用 `apiFetch` 的默认鉴权头;否则
* 任务详情/字典接口可能返回 401,页面只会看到空任务卡。
*
* 这里仍保留 include credentials,与原 Vue 请求保持一致;同时不走
* `apiFetch` 的 401 自动清理逻辑,避免外部任务系统的鉴权失败误登出
* DeerFlow 当前用户。
*/
async function fetchExternalTaskSystem(
path: string,
init: RequestInit = {},
): Promise<Response> {
const headers = new Headers(init.headers);
if (!headers.has("Authorization")) {
headers.set("Authorization", "Bearer admin");
}
if (!headers.has("Accept")) {
headers.set("Accept", "application/json");
}
return fetch(externalUrl(path), {
...init,
credentials: init.credentials ?? "include",
headers,
});
}
function firstText(...values: unknown[]): string {
for (const value of values) {
if (value === undefined || value === null) continue;
const text = String(value).trim();
if (text) return text;
}
return "";
}
function unwrapData(payload: unknown): Record<string, unknown> {
let current: unknown = payload;
// Task services in different deployments wrap the object as either
// { data: task } or { data: { data: task } }.
for (let index = 0; index < 3; index += 1) {
if (!current || typeof current !== "object" || Array.isArray(current)) break;
const next = (current as { data?: unknown }).data;
if (!next || typeof next !== "object" || Array.isArray(next)) break;
current = next;
}
return current && typeof current === "object" && !Array.isArray(current)
? (current as Record<string, unknown>)
: {};
}
export function normalizeEightBfTask(
raw: Record<string, unknown> | null | undefined,
fallbackTaskId = "",
): EightBfTask {
const source = raw ?? {};
const id = firstText(
source.id,
source.taskId,
source.externalTaskId,
source.external_task_id,
fallbackTaskId,
);
return {
id,
externalTaskId: firstText(source.externalTaskId, source.external_task_id, id),
title: firstText(source.title, source.taskName, source.task_name, source.name),
description: firstText(
source.description,
source.taskDescription,
source.task_description,
source.taskContent,
source.task_content,
source.content,
),
direction: firstText(
source.direction,
source.taskDirection,
source.task_direction,
),
overview: firstText(source.overview, source.summary),
priority: firstText(source.priority, source.urgency),
taskPriority: firstText(
source.taskPriority,
source.task_priority,
source.priority,
source.urgency,
),
targetDirection: firstText(
source.targetDirection,
source.target_direction,
source.direction,
source.taskDirection,
source.task_direction,
),
taskStatus: firstText(source.taskStatus, source.task_status, source.status),
source: "测试分析岗任务详情接口",
raw: source,
};
}
function dictionaryRows(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === "object") {
const directData = (payload as { data?: unknown }).data;
if (Array.isArray(directData)) return directData;
if (directData && typeof directData === "object") {
const nested = directData as { rows?: unknown; list?: unknown };
if (Array.isArray(nested.rows)) return nested.rows;
if (Array.isArray(nested.list)) return nested.list;
}
}
const data = unwrapData(payload);
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.list)) return data.list;
return [];
}
function normalizeDictionary(payload: unknown): EightBfDictionaryOption[] {
return dictionaryRows(payload)
.map((item) => {
if (!item || typeof item !== "object") return null;
const row = item as Record<string, unknown>;
const label = firstText(row.dictLabel, row.label);
const value = firstText(row.dictValue, row.dict_value, row.value);
if (!label || !value) return null;
return {
label,
value,
alias: firstText(row.alias) || undefined,
statusTone: firstText(row.statusTone, row.status_tone) || undefined,
} as EightBfDictionaryOption;
})
.filter((item): item is EightBfDictionaryOption => item !== null);
}
export async function fetchEightBfTask(taskId: string): Promise<EightBfTask> {
const encodedId = encodeURIComponent(taskId);
const path = TASK_DETAIL_PATH.replaceAll("{taskId}", encodedId);
const response = await fetchExternalTaskSystem(path, { method: "GET" });
if (!response.ok) {
throw new Error(`任务详情接口请求失败(${response.status})`);
}
const payload = (await response.json()) as unknown;
const task = normalizeEightBfTask(unwrapData(payload), taskId);
// Keep the external route id as the stable scope even if a deployment's
// response includes a legacy/internal numeric id.
return { ...task, id: taskId, externalTaskId: taskId };
}
export async function fetchEightBfTaskDictionaries(): Promise<EightBfTaskDictionaries> {
const entries = await Promise.all(
(Object.entries(DICTIONARY_TYPES) as Array<[
keyof EightBfTaskDictionaries,
string,
]>).map(async ([key, path]) => {
try {
const response = await fetchExternalTaskSystem(
`/system/dict/data/type/${path}`,
{ method: "GET" },
);
if (!response.ok) throw new Error(`字典接口请求失败(${response.status})`);
return [key, normalizeDictionary(await response.json())] as const;
} catch (error) {
// A dictionary failure must not hide the task card; the card falls back
// to the raw task codes and the detail request remains usable.
console.warn(`[8bf] ${key} 字典加载失败`, error);
return [key, []] as const;
}
}),
);
return Object.fromEntries(entries) as EightBfTaskDictionaries;
}
export function dictionaryText(
options: EightBfDictionaryOption[] | undefined,
value: unknown,
preferredField: "label" | "alias" = "label",
): string {
const raw = firstText(value);
if (!raw) return "";
const match = options?.find((option) => option.value === raw);
return firstText(match?.[preferredField], match?.label, raw);
}
export function buildEightBfTaskContext(
task: EightBfTask | null,
dictionaries: EightBfTaskDictionaries,
): EightBfTaskContextPayload | null {
if (!task?.id) return null;
return {
kind: "eightbf_roundtable_task",
usage:
"这是 8BF 页面左上角任务卡片中的任务信息。请直接基于这些信息理解用户问题,不要要求用户重复提供已经存在的任务名称、任务描述、任务方向或任务状态。",
task_id: task.id,
task: {
id: task.id,
title: task.title,
description: task.description,
direction: task.direction,
overview: task.overview,
priority: task.priority,
task_priority: task.taskPriority,
target_direction: task.targetDirection,
task_status: task.taskStatus,
},
task_tags: {
priority: dictionaryText(dictionaries.priority, task.taskPriority),
direction: dictionaryText(dictionaries.direction, task.targetDirection, "alias"),
status: dictionaryText(dictionaries.status, task.taskStatus),
},
};
}
@@ -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 [];
}
@@ -0,0 +1,97 @@
import { cn } from "@/lib/utils";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import {
dictionaryText,
type EightBfDictionaryOption,
type EightBfTask,
} from "@/eightbf-roundtable/api/task-context";
interface EightBfTaskCardProps {
task: EightBfTask | null;
dictionaries: {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
};
loading?: boolean;
error?: string | null;
/** 第二步左栏使用,与“智能体配置”同款图标收起整列。 */
onCollapse?: () => void;
}
export function EightBfTaskCard({
task,
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 = task?.raw === null
? ["紧急", "本系统创建", "暑期活动"]
: [priority, status, direction].filter(Boolean);
return (
<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>
{tags.length > 0 && (
<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
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>
</section>
);
}
@@ -1,7 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save, Settings2, X } from "lucide-react";
import { useSearchParams } from "react-router-dom";
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,
type EightBfTask,
type EightBfTaskDictionaries,
} from "@/eightbf-roundtable/api/task-context";
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains";
@@ -26,10 +42,28 @@ const BUSINESS_CODE = "8BF";
*/
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
const auth = getStoredAuth();
const [searchParams] = useSearchParams();
const taskId = useMemo(
() => searchParams.get("taskId")?.trim() || "",
[searchParams],
);
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
const storageTaskId = useMemo(
() => `8bf:${auth?.user_id || "anonymous"}:${taskId || "anonymous"}`,
[auth?.user_id, taskId],
);
const [task, setTask] = useState<EightBfTask | null>(() => (
taskId ? normalizeEightBfTask({}, taskId) : null
));
const [taskDictionaries, setTaskDictionaries] = useState<EightBfTaskDictionaries>({
priority: [],
direction: [],
status: [],
});
const [taskLoading, setTaskLoading] = useState(Boolean(taskId));
const [taskError, setTaskError] = useState<string | null>(null);
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
const [chains, setChains] = useState<BusinessChain[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -41,6 +75,55 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId],
);
const taskContext = useMemo(
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
useEffect(() => {
if (!taskId) {
setTask(null);
setTaskError(null);
setTaskLoading(false);
setTaskDictionaries({ priority: [], direction: [], status: [] });
return;
}
let cancelled = false;
setTaskLoading(true);
setTaskError(null);
setTask(normalizeEightBfTask({}, taskId));
let pendingRequests = 2;
const markRequestFinished = () => {
pendingRequests -= 1;
if (!cancelled && pendingRequests === 0) setTaskLoading(false);
};
// Keep detail and dictionary requests independent: a missing dictionary
// must not prevent the task card or task context from being available.
void fetchEightBfTask(taskId)
.then((nextTask) => {
if (!cancelled) setTask(nextTask);
})
.catch((error) => {
if (cancelled) return;
console.warn("[8bf] 任务详情请求失败,使用页面示例任务", error);
setTask(createEightBfFallbackTask(taskId));
setTaskError(null);
})
.finally(markRequestFinished);
void fetchEightBfTaskDictionaries()
.then((nextDictionaries) => {
if (!cancelled) setTaskDictionaries(nextDictionaries);
})
.catch(() => {
// fetchEightBfTaskDictionaries already degrades each dictionary to an
// empty list; this guard protects future adapter changes.
})
.finally(markRequestFinished);
return () => {
cancelled = true;
};
}, [taskId]);
const loadConfiguration = useCallback(async () => {
setIsLoading(true);
@@ -94,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
@@ -162,7 +239,64 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
lockedChainId={mapping?.chainId ?? null}
lockBusinessChain
storageTaskId={storageTaskId}
taskContext={taskContext as Record<string, unknown> | null}
entryRailTaskId={taskId || null}
entryRailTaskCardSlot={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
/>
) : 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,8 +23,265 @@
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
height: 500px;
min-height: 460px;
height: 430px;
min-height: 430px;
}
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
/* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
.eightbf-roundtable .eightbf-roundtable__task-card {
flex: 0 0 auto;
min-width: 0;
}
.eightbf-roundtable .eightbf-roundtable__task-card-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags {
margin-top: 12px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
border-radius: 2px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description {
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: #fca5a5; font-size: 12px; line-height: 1.5; }
.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;
}
.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;
}
.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; }
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Generate a client-only id without assuming a secure browser context.
*
* `crypto.randomUUID()` is unavailable in several browsers when the app is
* opened directly through an `http://<public-ip>:<port>` address. Request and
* UI ids need uniqueness, not a security token, so a timestamp/random fallback
* keeps those flows usable until the deployment is served over HTTPS.
*/
export function createClientId(prefix?: string): string {
const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined;
let id: string | undefined;
if (typeof cryptoApi?.randomUUID === "function") {
id = cryptoApi.randomUUID();
} else if (typeof cryptoApi?.getRandomValues === "function") {
try {
const values = new Uint32Array(4);
cryptoApi.getRandomValues(values);
id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
} catch {
// Some restricted browser contexts expose crypto but reject random data.
}
}
id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
return prefix ? `${prefix}-${id}` : id;
}
@@ -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,
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useAgentSelector } from "@/core/agents";
import { createClientId } from "@/lib/client-id";
import {
type DomainAgentMapping,
normalizeDomainInput,
@@ -20,10 +21,7 @@ import {
function makeRow(): DomainAgentMapping {
return {
id:
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
id: createClientId(),
domain: "",
agentId: "",
enabled: true,
@@ -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",
@@ -6,6 +6,7 @@
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
/**
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
@@ -147,10 +148,7 @@ const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
*/
function newCommandId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
return createClientId().replace(/-/g, "");
}
/**
@@ -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 非空)
@@ -24,6 +24,7 @@ import {
type IntentSummary,
} from "@/roundtable-planning/api/intent";
import { uploadFiles } from "@/core/uploads/api";
import { createClientId } from "@/lib/client-id";
import {
clarificationKey,
formatIntentClarificationAnswer,
@@ -449,9 +450,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
const clientTurnId =
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const clientTurnId = createClientId();
const res = await streamIntent({
threadId: intentThreadId,
@@ -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";
@@ -298,8 +298,32 @@ export default function RoundtablePlanningPage({
toolbarSlot,
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
entryRailBusinessChainSlot,
/** 独立业务页在第一步左上角提供自己的任务卡片;不触发原深链任务接口。 */
entryRailTaskCardSlot,
/** 独立业务页传给意图智能体的隐藏任务上下文。 */
taskContext,
/** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */
entryRailTaskId,
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
rootClassName,
/** 独立业务页替换第一步消息工作区的纯展示组件;状态和行为仍由本页统一拥有。 */
renderStep1,
/** 独立业务页替换第一步左侧入口栏。 */
renderStep1LeftSidebar,
/** 独立业务页在第二步左栏顶部复用自己的任务/意图展示。 */
renderStep2LeftSidebarHeader,
/** 第一阶段使用内嵌右栏时,隐藏通用的模型/摘要/澄清记录栏。 */
hideDefaultStep1RightSidebar = false,
/** 独立业务页的工作区品牌区,取代通用步骤导航。 */
headerLeftSlot,
/** 放在历史记录后面的独立业务操作或状态。 */
headerActionAfterHistory,
/** 独立业务页可隐藏通用的“保存草稿”按钮。 */
hideHeaderSaveDraft = false,
/** 独立业务页可调整新建动作的展示文案,行为不变。 */
newTaskLabel,
/** 独立业务页可隐藏通用的设置入口。 */
hideHeaderSettings = false,
}: {
compactChrome?: boolean;
lockedChainId?: string | null;
@@ -307,7 +331,26 @@ export default function RoundtablePlanningPage({
storageTaskId?: string | null;
toolbarSlot?: ReactNode;
entryRailBusinessChainSlot?: ReactNode;
entryRailTaskCardSlot?: ReactNode;
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 常驻层)。
@@ -315,6 +358,10 @@ export default function RoundtablePlanningPage({
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
useEffect(() => {
taskContextRef.current = taskContext ?? null;
}, [taskContext]);
/** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */
const inWujieEmbed = isWujieEnv();
@@ -331,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(
() =>
@@ -342,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;
@@ -542,6 +632,38 @@ export default function RoundtablePlanningPage({
}>({ intentReady: null, interactiveMessages: [] });
const buildInitialIntent = useCallback((): string => {
const task = taskContextRef.current?.task as Record<string, unknown> | null | undefined;
const taskTags = taskContextRef.current?.task_tags as
| Record<string, unknown>
| undefined;
const taskValue = (value: unknown) =>
value === undefined || value === null ? "" : String(value).trim();
const taskLines = task
? [
["任务 ID", taskValue(task.id ?? taskContextRef.current?.task_id)],
["任务名称", taskValue(task.title)],
["任务描述", taskValue(task.description)],
["任务概览", taskValue(task.overview)],
["任务方向", taskValue(task.direction ?? task.target_direction)],
["优先级", taskValue(task.task_priority ?? task.priority)],
["任务状态", taskValue(task.task_status)],
["优先级标签", taskValue(taskTags?.priority)],
["方向标签", taskValue(taskTags?.direction)],
["状态标签", taskValue(taskTags?.status)],
]
.filter(([, value]) => value)
.map(([label, value]) => `${label}: ${value}`)
: [];
const taskCardContext = taskLines.length
? [
"【8BF 任务卡片上下文】",
...taskLines,
"请把以上任务卡片信息作为本轮研讨的既有背景,不要让用户重复提供。",
"",
].join("\n")
: "";
const withTaskCardContext = (value: string) =>
taskCardContext ? `${taskCardContext}${value}` : value;
const { intentReady, interactiveMessages } = step1StateRef.current;
if (intentReady) {
const parts: string[] = [intentReady.objective];
@@ -559,17 +681,17 @@ export default function RoundtablePlanningPage({
parts.push(
"用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。",
);
return parts.join("\n");
return withTaskCardContext(parts.join("\n"));
}
const userTurns = interactiveMessages
.filter((m) => m.sender === "user")
.map((m) => m.content?.trim())
.filter((s): s is string => !!s);
if (userTurns.length === 0) {
return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。";
return withTaskCardContext("用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。");
}
const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n");
return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`;
return withTaskCardContext(`${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`);
}, []);
const step2 = useStep2Orchestration({
@@ -593,6 +715,7 @@ export default function RoundtablePlanningPage({
currentStep,
selectedModel,
triggerToast,
taskContext,
});
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
@@ -1917,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",
@@ -2022,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}
@@ -2077,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 && (
@@ -2126,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" />
@@ -2157,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}
@@ -2176,12 +2367,15 @@ export default function RoundtablePlanningPage({
onSelectChain={handleSelectChainFromSidebar}
onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId}
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
entryRailTaskId={entryRailTaskId ?? recordTaskIdOf(prefillRef.current)}
historyTaskId={draftStorageTaskId}
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
lockedChainId={lockedChainId}
lockBusinessChain={lockBusinessChain}
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
entryRailTaskCardSlot={entryRailTaskCardSlot}
renderStep1LeftSidebar={renderStep1LeftSidebar}
renderStep2LeftSidebarHeader={renderStep2LeftSidebarHeader}
/>
{/* MIDDLE VIEWPORT */}
@@ -2190,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 &&
@@ -2203,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}
@@ -2339,7 +2494,7 @@ export default function RoundtablePlanningPage({
</section>
{/* RIGHT SIDEBAR */}
<RightSidebar
{!hideDefaultStep1RightSidebar && <RightSidebar
currentStep={currentStep}
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
@@ -2349,7 +2504,7 @@ export default function RoundtablePlanningPage({
timelineEntries={step1.timelineEntries}
triggerToast={triggerToast}
onSaveIntent={handleSaveIntent}
/>
/>}
</main>
{/* 深链 taskId 模式:底部「快捷跳转」栏(与单智能体问答共用,按业务取按钮管理配置)。 */}
@@ -2743,6 +2898,15 @@ interface LeftSidebarProps {
lockedChainId?: string | null;
/** 第一阶段替换默认业务链选择区的自定义内容。 */
entryRailBusinessChainSlot?: ReactNode;
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
entryRailTaskCardSlot?: ReactNode;
/** 独立业务页替换第一步左侧工作区;仅提供已解析的意图状态。 */
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode;
/** 独立业务页在第二步左栏顶部复用任务与意图区。 */
renderStep2LeftSidebarHeader?: (context: {
intentReady: object | null;
onCollapse: () => void;
}) => ReactNode;
}
function Step2SandboxSidebarSync({
@@ -2794,6 +2958,9 @@ function LeftSidebar({
lockBusinessChain,
lockedChainId,
entryRailBusinessChainSlot,
entryRailTaskCardSlot,
renderStep1LeftSidebar,
renderStep2LeftSidebarHeader,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2811,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",
@@ -2832,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">
@@ -2926,18 +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}
/>
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>
);
@@ -2992,6 +3168,7 @@ function RoundtableEntryRail({
lockedChainId,
lockBusinessChain = false,
businessChainSlot,
taskCardSlot,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
@@ -3007,6 +3184,7 @@ function RoundtableEntryRail({
lockedChainId?: string | null;
lockBusinessChain?: boolean;
businessChainSlot?: ReactNode;
taskCardSlot?: ReactNode;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
@@ -3093,7 +3271,12 @@ function RoundtableEntryRail({
)}
>
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
{deeplink ? (
{taskCardSlot ? (
<>
{taskCardSlot}
{lockBusinessChain && businessChainSlot}
</>
) : deeplink ? (
isXdfx ? (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
@@ -17,6 +17,7 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
import type {
DocumentRewriteSummary,
DocumentRewriteStreamEvent,
@@ -355,7 +356,7 @@ export async function startJob(
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: opts.requestId ?? crypto.randomUUID(),
request_id: opts.requestId ?? createClientId(),
entry: opts.entry ?? "legacy",
config: opts.config ?? null,
}),
@@ -0,0 +1,609 @@
# Offline backend template for DeerFlow
config_version: 8
log_level: info
# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程
# loopback 调用会拼 http://127.0.0.1:<port> 打到本机其它 endpoint,所以必须与
# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。
# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。
gateway:
port: 8001
# Optional Redis-backed global concurrency coordination.
# Leave disabled to keep the existing in-process behavior.
# When enabled, Redis operations are short-timeout and fail-open by default:
# if Redis is slow or unavailable, user Q&A continues with local-only limits.
concurrency:
redis:
enabled: false
url: redis://127.0.0.1:6379/0
password: null
# Prefix with the deployment/system id because Redis may be shared by
# multiple projects. Keep this unique per system to avoid cross-project
# leases affecting each other.
namespace: cmzs_deerflow:concurrency
socket_connect_timeout_ms: 80
socket_timeout_ms: 80
acquire_timeout_ms: 120
operation_timeout_ms: 80
circuit_breaker_failures: 3
circuit_breaker_cooldown_seconds: 30
lease_ttl_seconds: 90
heartbeat_interval_seconds: 20
fail_open: true
models:
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: $DEEPSEEK_CHAT_API_KEY
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: zai-org/GLM-5-FP8
api_key: $GLM5_FP8_API_KEY
base_url: https://api.us-west-2.modal.direct/v2
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
supports_reasoning_effort: false
- name: deepseek-ai/DeepSeek-V4-Flash
display_name: deepseek-ai/DeepSeek-V4-Flash
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Flash
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
when_thinking_disabled:
extra_body:
enable_thinking: false
- name: inclusionAI/Ling-flash-2.0
display_name: inclusionAI/Ling-flash-2.0
use: langchain_openai:ChatOpenAI
model: inclusionAI/Ling-flash-2.0
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-V4-Pro
display_name: deepseek-ai/DeepSeek-V4-Pro
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Pro
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: qwen3.6-flash
display_name: qwen3.6-flash
use: langchain_openai:ChatOpenAI
model: qwen3.6-flash
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: true
supports_reasoning_effort: false
supports_vision: false
- name: qwen3.6
display_name: qwen3.6
use: langchain_openai:ChatOpenAI
model: qwen3.6
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
base_url: http://119.254.86.251:8001/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: false
supports_reasoning_effort: false
supports_vision: false
# - name: deepseek-v4-flash
# display_name: DeepSeek V4 Flash
# use: langchain_openai:ChatOpenAI
# model: deepseek-v4-flash
# api_key: sk-3fee562f834a4c08bb0d471ef44cb445
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: deepseek-v4-pro
# display_name: DeepSeek V4 Pro
# use: langchain_openai:ChatOpenAI
# model: deepseek-v4-pro
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: MiniMax-M2.7
# display_name: MiniMax-M2.7
# use: langchain_openai:ChatOpenAI
# model: MiniMax-M2.7
# api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
# base_url: https://api.minimaxi.com/v1
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: glm-5.1
# display_name: glm-5.1
# use: langchain_openai:ChatOpenAI
# model: glm-5.1
# api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
# base_url: https://open.bigmodel.cn/api/paas/v4/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: deepseek-ai/DeepSeek-OCR
# display_name: deepseek-ai/DeepSeek-OCR
# use: langchain_openai:ChatOpenAI
# model: deepseek-ai/DeepSeek-OCR
# api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
# base_url: https://api.siliconflow.cn/v1/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: zai-org/GLM-5-FP8
# display_name: zai-org/GLM-5-FP8
# use: langchain_openai:ChatOpenAI
# model: zai-org/GLM-5-FP8
# api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
# base_url: https://api.us-west-2.modal.direct/v1
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: Qwen/Qwen3.6-27B
# display_name: Qwen/Qwen3.6-27B
# use: langchain_openai:ChatOpenAI
# model: Qwen/Qwen3.6-27B
# api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
# base_url: https://api.siliconflow.cn/v1/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」,
# 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后,
# 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
# - prompt:不带具体问题时使用的默认识图指令(可选)。
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
vision:
model_name: deepseek-ai/DeepSeek-OCR
# prompt: 请详细描述这张图片的内容。
# max_tokens: 2048
# 深度研究可选的报告配图。默认关闭;仅在部署方配置文生图模型后,前端才会展示开关。
# 使用 OpenAI 兼容的 POST {base_url}/images/generations 协议,并强制要求 b64_json
# 返回,以便将图片保存为当前研究会话的受鉴权产物,而不是引用会过期的公网 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
api_key: sk-sp-H.XDIPL.Zgvs.MEQCIBasaS2RPHwgNUpGVfFJeRaTp5UgmZ4Y2rCVGNtpwfD1AiBTFg3S-uE_gqVDnHgdyEhDwcfaVRLi92xviye80AZnkg # 生产环境建议填 $DEEP_RESEARCH_IMAGE_API_KEY
model: wan2.7-image # 例如 gpt-image-1
size: 1024x1024
# quality: standard
timeout_seconds: 120
# browser_act 相关配置
browser_context:
enabled: true
actions_enabled: true
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
uploads:
auto_convert_documents: true
pdf_converter: auto
title:
enabled: true
max_words: 6
max_chars: 60
prompt_template: |
请为下面这段对话生成一个简洁的标题,概括对话的核心主题。
要求:
- 标题必须使用简体中文
- 尽量精炼,不超过 {max_words} 个词
- 只返回标题本身,不要加引号、不要解释、结尾不要标点
User: {user_msg}
Assistant: {assistant_msg}
# 上下文压缩(对话摘要):长对话接近上下文上限时,把较早的消息总结成一段摘要,
# 只保留最近若干条原始消息,避免撑爆模型上下文窗口。
# - trigger:触发条件,可配多个,任意一个先满足就压缩(OR 关系)。真正决定何时
# 压缩的是 trigger(不是 keep)。type=tokens 按 token 总数;type=messages 按条数。
# - keep:压缩后保留多少最近的上下文(tokens / messages)。
# - trim_tokens_to_summarize:喂给摘要模型的消息最多裁到多少 token。
# 当前为「测试值(易触发)」:发到服务器后稍微聊几句就会触发,可在 Gateway 日志里
# grep「上下文压缩」/「context compaction」确认生效;确认后再调回生产值
# (trigger tokens 60000 / messages 80,keep tokens 16000)。
summarization:
# 开关已移到聊天框下方(参考文献下面)的「上下文压缩」按钮,默认关闭。
# 这里 enabled 仅作为非 Web 路径(渠道/定时任务)的后备开关;下面的 trigger/keep
# 等仅是调参,Web 端是否压缩由前端按钮(summarization_enabled)决定。
enabled: false
# model_name: deepseek-chat # 留空则用主对话模型
trigger:
- type: tokens
value: 120000
- type: messages
value: 160
keep:
type: tokens
value: 40000
trim_tokens_to_summarize: 8000
tool_groups:
- name: web
- name: file:read
- name: file:write
- name: bash
tools:
- name: web_search
group: web
# DuckDuckGo search — no API key required. To switch to the deployment's
# internal search service later, restore the configurable_search block
# preserved in the comments below (fill in the real values) and point
# `use` back to deerflow.community.configurable_search.tools:web_search_tool.
use: deerflow.community.ddg_search.tools:web_search_tool
enabled: true
max_results: 5
# ── configurable_search alternative (deployment-internal service) ──
# endpoint: "https://xxx.xxx.xxx/searchName"
# verify_ssl: false
# timeout: 30
# result_url_template: "https://xxx.xxx.xxx/mindId/xxx?recUuid={recUuid}"
# The runtime query always overwrites payload.query; all other fields are
# sent unchanged so deployment-specific request parameters stay in config.
# payload:
# query: ""
# xxx: "xxxx"
- name: web_fetch
group: web
use: deerflow.community.jina_ai.tools:web_fetch_tool
timeout: 15
- name: ls
group: file:read
use: deerflow.sandbox.tools:ls_tool
- name: read_file
group: file:read
use: deerflow.sandbox.tools:read_file_tool
- name: glob
group: file:read
use: deerflow.sandbox.tools:glob_tool
max_results: 200
- name: grep
group: file:read
use: deerflow.sandbox.tools:grep_tool
max_results: 100
- name: write_file
group: file:write
use: deerflow.sandbox.tools:write_file_tool
- name: str_replace
group: file:write
use: deerflow.sandbox.tools:str_replace_tool
- name: bash
group: bash
use: deerflow.sandbox.tools:bash_tool
# browser_act 相关配置
- name: browser_fetch_page
group: web
use: deerflow.tools.builtins.browser_context_tool:browser_fetch_page_tool
# browser_act 相关配置
- name: browser_act
group: web
use: deerflow.tools.builtins.browser_act_tool:browser_act_tool
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
allow_host_bash: true
mounts:
- host_path: C:\Users\iie\Desktop\deerflow-new\offline-backend-20260512\backend\knowledge\knowledge_base
container_path: /mnt/knowledge
read_only: true
skills:
path: skills
security_scan:
enabled: false
skill_evolution:
enabled: true
curator:
enabled: true # 每 24 小时自动清理 + 合并 skill
interval_hours: 24
review_worker:
enabled: true # 被动复盘开关
min_tool_calls: 5 # 触发阈值:本轮至少 5 次工具调用,与 prompt 中"≥5 步"硬条件一致
max_messages: 60 # 传给 LLM 的最大消息条数
timeout_seconds: 120 # 单次 review 超时(秒)
model_name: # 留空则使用主模型
agents_api:
enabled: true
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。
auth_login:
# 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令,
# 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。
username_login:
require_password: false
password: ''
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
# 用户名不在库里时自动注册。
token_login:
enabled: true
token_info_url: 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo'
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
service_authorization: 'Bearer admin'
timeout_seconds: 10
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
verify_ssl: false
ca_cert_path: '' # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
# 离线测试映射:token -> 用户名。命中的 token 跳过 token_info_url 调用,直接当成
# 该用户登录——外网调不通 getTokenInfo 时用来本地全链路联调。生产留空即可(不生效)。
# 例:用 /login/任意?authToken=123ewq 进入,即以 admin 身份登录。
mock_tokens:
'123ewq': 'admin'
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
database:
backend: sqlite
sqlite_dir: .deer-flow/data
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
pool_recycle: 1800 # 连接回收周期(秒);防火墙/pgbouncer/MySQL wait_timeout 静默断连兜底。多 worker 部署:每 worker 上限 = pool_size + max_overflow,总数 × worker 数 < DB max_connections
echo_sql: false
checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: true
version:
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
provider:
hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个
# (version=v1 时此字段忽略)
# V1 专属配置 —— version=v1 时生效
v1:
max_facts: 100 # 最多保留的事实条数,超出时驱逐最低置信度的事实
fact_confidence_threshold: 0.7 # 事实置信度阈值,低于此值不保存
debounce_seconds: 30 # 对话结束后延迟多少秒才触发 LLM 提取
model_name: '' # 用于事实提取的 LLM 模型名;空则继承全局默认模型
token_budget: 2000 # 注入系统提示词的 token 预算
builtin:
enabled: true
memory_char_limit: 2200
user_char_limit: 1375
deduplicate_on_load: true
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
api_url: http://47.88.25.99:18888
api_key:
'' # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
bank_id_template: 'deerflow-user-{user_id}'
work_bank_id_template: 'deerflow-work-{user_id}-{agent_id}'
memory_mode: context # 'context' | 'tools' | 'hybrid'
bank_mission: '你是一个中文记忆助手,负责从对话中提炼和存储重要信息。所有记忆内容必须使用中文记录。'
bank_retain_mission: '请从以下对话中提炼重要信息,包括用户的偏好、需求、背景知识和关键事实。所有提炼的内容必须用中文表达。'
recall_budget: mid # 'low' | 'mid' | 'high'
recall_max_tokens: 4096
recall_max_input_chars: 800
auto_recall: true
auto_retain: true
retain_async: true
retain_every_n_turns: 1
retain_user_prefix: 'User'
retain_assistant_prefix: 'Assistant'
# background_extraction:预留功能,代码尚未实现 —— 仅设计文档存在。
# 待对应版本支持后,取消下面整段注释即可启用(漏记兜底)。
# background_extraction:
# enabled: false # 默认关闭,作为漏记兜底
# debounce_seconds: 30
# model_name: ~ # null = 用默认模型
# fact_confidence_threshold: 0.7
# max_facts_per_extraction: 10
# correction_detection: true
# reinforcement_detection: true
injection:
enabled: true
max_tokens: 2000
include_builtin: true
include_hindsight: true
context_tag: 'memory-context' # 包裹 Hindsight recall 的标签名
security:
scan_content: true # 注入/外渗模式扫描
block_invisible_unicode: true
# streaming_scrubber:预留字段,代码尚未实现 —— 待版本支持后取消注释。
# streaming_scrubber: true # SSE 流前清洗 <memory-context>
# AI 写作子系统 —— 素材收集专家专用配置。
# 注意:这三项必须嵌套在 ai_writing: 段下;写到顶层会被 _ai_writing_config()
# (app/gateway/routers/ai_writing.py:_ai_writing_config) 直接忽略。
ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
# 用「真实智能体运行时」跑配置技能(read SKILL.md + 全套工具 + 沙箱),让技能的真实
# 检索逻辑(调内网接口/MCP/读文件)真正执行,而不是塞进通用 web/ES 检索 provider。
# 内网技能必须开 true,否则技能会走通用 provider、连不上内网而报 ConnectError。
# 仅对非「知识库检索(knowledge-base-search)」的配置技能生效;失败回退直连检索。
researcher_skill_agent: true
# 最终兜底:当所有技能都没检索到素材时,直连内网知识库 ES 接口兜底检索(避免素材全空)。
# 配上你内网 ES 接口地址即启用;留空则不启用该兜底。
# 接口契约:POST {url} 体 {"query","knowledge_base_name","ts":"yes"} → [{"page_content","metadata":{m_title,content,recUuid,m_publish,source1}}]
intranet_search_url: "" # 例如 http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: "samples" # 兜底检索用的知识库名
intranet_search_timeout: 300 # 内网检索超时时间(秒),代码层强制最低 300;需要更长可调大
# 内网接口诊断开关:开启后即使技能已检索成功,也照样把内网接口跑完并把执行结果
# (端点/每个检索词命中数/报错/原始响应片段)记到 material_package.intranet_debug 里,
# 便于在接口返回(devtools)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
intranet_search_debug: false
# 四阶段「内置功能型智能体」开关(按阶段灰度)。true = 该阶段节点改为加载
# .deer-flow/agents/ai-writing-<stage>/ 的 SOUL.md + config(admin 可在智能体
# 管理页编辑人设/模型),false / 缺失 = 走旧的硬编码 prompt 路径。
# 改完保存即生效(config 按 mtime 热加载),新写作会话立即按新开关运行。
use_builtin_agents:
researcher: false # 素材收集专家(检索词生成 / 素材排序两个子任务)
outliner: false # 大纲规划师(大纲生成 / 按反馈调整)
writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变)
editor: false # 编辑(事实/逻辑/语言三维度审查)
# Optional independently deployed WeKnora integration. DeerFlow owns only
# these connection values; database/Redis/storage/parser/model settings stay
# in WeKnora's own docker-compose/.env/config files. A non-empty api_base_url
# enables WeKnora mode; leaving it empty preserves the existing LLMWiki iframe.
# admin_email/admin_password are used only by DeerFlow's server-side WeKnora
# detail-page iframe proxy. Prefer environment variables in production, e.g.
# admin_email: "$WEKNORA_ADMIN_EMAIL"
# admin_password: "$WEKNORA_ADMIN_PASSWORD"
llmwiki:
weknora:
api_base_url: "http://119.254.86.251:8088/deerflow-api"
web_base_url: "http://119.254.86.251:8088"
admin_email: "lqq@qq.com"
admin_password: "1qaz@WSX"
log_system:
enabled: true
url: 'https://your-log-system.example.com'
label: '日志系统'
task_deeplink:
# 线上接口未通时用假数据;三个开关互相独立。
test: true # 目的树查询走 mock.purpose_detail 假数据
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
# 开放接口 POST /api/open/3qfx/ask 按 taskId 拉取任务详情拼开场白;上游不可达时打开下面开关走 mock.cop_task_detail。
cop_task_detail_test: true
cop_task_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/cop-task-detail"
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
# rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。
delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData"
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
# xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。
action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action"
# xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。
action_history_url: ""
# rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。
detail_3q_url: ""
mock:
# test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
purpose_detail:
state: "200"
msg: "操作成功!"
data:
- purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 }
childrenList:
- actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 }
childrenList:
- expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" }
childrenList:
- drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 }
childrenList:
- narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 }
childrenList: []
- actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 }
childrenList:
- expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" }
childrenList:
- drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 }
childrenList:
- narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 }
childrenList: []
# delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。
delete:
message: "行动已经开始策划,不可覆盖"
success: true
# action_test=true 时原样返回给前端(结构同真实 /consumer/plan/action/{actionId} 返回)。
# 前端取 data.taskId 作为真正的「任务id」(xdfx 深链 ?taskId= 传入的是 data.id 这个行动id)。
action_detail:
state: "200"
msg: "操作成功!"
data:
id: 27354
purposeId: 2850
taskId: 2055
actionName: ""
actionStatus: 25
narration: ""
# cop_task_detail_test=true 时原样返回给开放接口 /api/open/3qfx/ask(结构同真实 cop-task-detail 返回)。
cop_task_detail:
data:
id: 2055
overview: "暑期文旅消费高峰期间的舆情态势综合研判与引导任务"
taskDirection: "正向引导 + 风险预警"
taskName: "任务01:暑期文旅舆情综合研判"
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
+36 -2
View File
@@ -1147,6 +1147,10 @@ Automatic conversation summarization when approaching token limits:
the config flag. `config.summarization.enabled` now defaults to **false**.
- `config.yaml` `summarization` still supplies the **tuning**: trigger types
(tokens, messages, or fraction of max input) and the keep policy.
- A compressed request must retain the latest non-empty `HumanMessage`, even
when it has already been folded into the hidden summary. This keeps strict
OpenAI-compatible gateways from rejecting a tool-continuation call with
`No user query found in message`.
- Keeps recent messages while summarizing older ones.
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
@@ -1265,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 /
@@ -1284,6 +1293,31 @@ The per-user Deep Research concurrency guard counts only session statuses
consume a slot; otherwise saved drafts make `POST /sessions/{id}/jobs` return
429 permanently. Regression: `test_saved_draft_sessions_do_not_consume_research_concurrency`.
`POST /api/deep-research/sessions/{id}/jobs` is retry-safe under MySQL
read/write splitting. Deep Research session/job/event/source/message writes
must serialise their changed row while the write transaction is still open
(`flush` for inserts, select-after-update **before** commit), never through a
post-commit `session.refresh()` or a fresh pooled read. Replica lag otherwise
turns a committed job into a misleading 500, can make dispatcher `claim_job()`
lose its snapshot, or can fail report/follow-up persistence after the model has
already completed. Always nudge the dispatcher for both a newly created job and
an idempotently reused queued job; otherwise a retry after such an interrupted
response returns the old job but leaves it waiting for the periodic scan. The
SSE replay generator must catch temporary event/job-store reads, emit a
recoverable warning, and keep the connection open so the next poll can repair
the cursor instead of surfacing an ASGI `ExceptionGroup`. Regressions:
`test_deep_research_writes_never_depend_on_post_commit_refresh`,
`test_job_create_never_reads_back_after_commit`,
`test_retry_of_existing_queued_job_nudges_dispatcher`, and
`test_stream_stays_open_when_durable_replay_has_a_transient_failure`.
At ordinary report completion, persist and confirm the session's
`report_markdown` projection **before** marking the Job `completed`. Retry a
short transient projection failure; if it cannot be confirmed, let the job
become failed rather than reporting a report that vanishes on refresh.
`test_report_is_persisted_before_job_is_marked_completed` and
`test_report_projection_failure_never_marks_job_completed` cover the ordering.
The chat-collection frontend must keep report-writing progress in the existing
workspace `MessageList` / `MessageGroup` tool-step renderer: inject only
virtual `deep_research_progress` tool calls for the durable phases, keep the
+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
+16 -7
View File
@@ -151,7 +151,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
@@ -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
@@ -61,6 +62,8 @@ WORKER_ID = f"w-{os.getpid()}-{_uuid.uuid4().hex[:8]}"
_LEASE_TTL_SECONDS = 60
_HEARTBEAT_INTERVAL = 20.0
_CANCEL_POLL_INTERVAL = 5.0
_REPORT_PROJECTION_RETRY_ATTEMPTS = 3
_REPORT_PROJECTION_RETRY_DELAY_SECONDS = 0.5
# Phase → progress% mapping for the job timeline.
_PHASE_PROGRESS: dict[str, int] = {
@@ -155,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
@@ -263,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,
@@ -388,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(
@@ -419,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
@@ -447,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(
@@ -456,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
@@ -490,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()
@@ -527,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,
@@ -536,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:
@@ -558,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)
@@ -580,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(
@@ -641,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,
@@ -747,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(
@@ -806,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:
@@ -847,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)
@@ -907,7 +927,7 @@ class DeepResearchJobExecutor:
f"{requirements_plan}\n"
"【已确认的改写执行计划结束】\n\n"
"【原始 Markdown 开始】\n"
f"{original}\n"
f"{rewrite_source}\n"
"【原始 Markdown 结束】"
)
),
@@ -932,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)
@@ -941,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)
@@ -972,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",
@@ -982,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:
@@ -991,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,
@@ -999,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",
@@ -1124,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,
@@ -1332,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,
@@ -1369,10 +1556,18 @@ class DeepResearchJobExecutor:
# ── terminal-state writers ──────────────────────────────────────────────
async def _mark_completed(
self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult
) -> None:
# Job row → completed.
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
# make a successfully generated report disappear after refresh.
await self._persist_completed_report_projection(
job_id=job_id,
session_id=session_id,
result=result,
)
# Job row → completed only after the report is durable.
await self._job_store.update_progress(
job_id,
lease_owner=lease_owner,
@@ -1385,29 +1580,61 @@ class DeepResearchJobExecutor:
"plan": result.plan,
},
)
# Session row → completed + report.
source_count = len(result.source_ids)
with suppress(Exception): # noqa: BLE001
await self._session_store.update(
session_id,
status="completed",
report_markdown=result.report_markdown,
report_html=result.report_html,
plan_snapshot=result.plan,
# Keep the terminal job id alongside model usage. The
# frontend uses it only to replay the persisted research trace
# after a refresh; it does not affect billing or job state.
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
source_count=source_count,
active_job_id=None,
)
# Emit a terminal event for SSE consumers.
with suppress(Exception): # noqa: BLE001
await self._emit_terminal(job_id, session_id, "job_completed")
async def _mark_awaiting(
self, job_id: str, session_id: str, pause: ResearchAwaitingInput
async def _persist_completed_report_projection(
self,
*,
job_id: str,
session_id: str,
result: DeepResearchResult,
) -> None:
"""Durably save the report before publishing job completion.
A write can succeed while a short-lived MySQL proxy/readback hiccup
makes its caller fail. Retry the full owner-scoped update a few times;
if it still cannot be confirmed, propagate the error so ``_run`` marks
the job failed instead of advertising a report that cannot be reopened.
"""
last_error: Exception | None = None
for attempt in range(1, _REPORT_PROJECTION_RETRY_ATTEMPTS + 1):
try:
updated = await self._session_store.update(
session_id,
status="completed",
report_markdown=result.report_markdown,
report_html=result.report_html,
plan_snapshot=result.plan,
# Keep the terminal job id alongside model usage. The
# frontend uses it only to replay the persisted research
# trace after a refresh; it does not affect billing.
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
source_count=len(result.source_ids),
active_job_id=None,
)
if updated is not None:
return
last_error = RuntimeError(f"Deep Research session {session_id} no longer exists")
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
last_error = exc
if attempt < _REPORT_PROJECTION_RETRY_ATTEMPTS:
logger.warning(
"deep-research report projection failed for %s (attempt %s/%s); retrying",
job_id,
attempt,
_REPORT_PROJECTION_RETRY_ATTEMPTS,
exc_info=True,
)
await asyncio.sleep(_REPORT_PROJECTION_RETRY_DELAY_SECONDS * attempt)
raise RuntimeError(f"研究报告未能保存到会话 {session_id},任务不会标记为完成") from last_error
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(
@@ -1417,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
@@ -639,17 +639,30 @@ async def start_job(session_id: str, body: StartJobRequest, request: Request) ->
input_snapshot=input_snapshot,
)
# Update session status.
await session_store.update(
session_id,
user_id=user_id,
status="running",
active_job_id=job["id"],
)
# The durable job is the source of truth once it has been committed. A
# transient failure updating the display projection must not turn that
# successful write into a misleading 500 response or leave it undispatched.
# The executor re-projects the terminal status when it completes.
try:
await session_store.update(
session_id,
user_id=user_id,
status="running",
active_job_id=job["id"],
)
except Exception: # noqa: BLE001
logger.exception(
"deep-research job %s was queued but its session running projection could not be updated",
job["id"],
)
# Nudge the dispatcher to claim immediately.
# Nudge the dispatcher even for an idempotent retry. If an earlier request
# committed the job but failed before this point (for example a transient
# MySQL read-after-write failure), the retry receives the existing queued
# job. Restricting the nudge to ``created`` would leave that durable job
# queued until the next periodic scan and make the UI appear stuck.
dispatcher = _get_dispatcher(request)
if dispatcher is not None and created:
if dispatcher is not None:
dispatcher.nudge()
return {
@@ -810,36 +823,68 @@ async def stream_job(
if loop.time() >= next_durable_poll:
# Durable replay repairs a missed live delta after
# reconnect or when another worker owned the job.
events = await event_store.list_after(job_id, after=cursor, limit=200)
for ev in events:
payload = {
"seq": ev["seq"],
"sessionId": session_id,
"jobId": job_id,
"type": ev["event_type"],
"phase": ev.get("phase", "initializing"),
"timestamp": _iso(ev.get("created_at")),
"payload": ev.get("payload") or {},
}
yield format_sse("progress", payload, event_id=str(ev["seq"]))
cursor = max(cursor, ev["seq"])
current = await job_store.get_unscoped(job_id)
if current and current.get("status") in TERMINAL_STATUSES:
try:
events = await event_store.list_after(job_id, after=cursor, limit=200)
for ev in events:
payload = {
"seq": ev["seq"],
"sessionId": session_id,
"jobId": job_id,
"type": ev["event_type"],
"phase": ev.get("phase", "initializing"),
"timestamp": _iso(ev.get("created_at")),
"payload": ev.get("payload") or {},
}
yield format_sse("progress", payload, event_id=str(ev["seq"]))
cursor = max(cursor, ev["seq"])
current = await job_store.get_unscoped(job_id)
if current and current.get("status") in TERMINAL_STATUSES:
yield format_sse(
"progress",
{
"seq": cursor + 1,
"sessionId": session_id,
"jobId": job_id,
"type": "job_status",
"phase": current.get("phase", "done"),
"timestamp": _iso(datetime.now(UTC)),
"payload": {"status": current["status"], "progress": current.get("progress", 0)},
},
event_id=str(cursor + 1),
)
return
next_durable_poll = loop.time() + _POLL_INTERVAL_S
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
# Do not let one read-replica / transient DB failure
# abort the ASGI generator (which surfaces as an
# ExceptionGroup and leaves the page stuck). Live
# frames can still reach an attached browser, and the
# next durable poll repairs the cursor on recovery.
logger.warning(
"deep-research SSE durable replay temporarily failed for job %s; keeping stream open",
job_id,
exc_info=True,
)
yield format_sse(
"progress",
{
"seq": cursor + 1,
"seq": 0,
"liveId": f"durable-replay-retry:{job_id}:{int(loop.time() * 1000)}",
"sessionId": session_id,
"jobId": job_id,
"type": "job_status",
"phase": current.get("phase", "done"),
"type": "warning",
"phase": "initializing",
"timestamp": _iso(datetime.now(UTC)),
"payload": {"status": current["status"], "progress": current.get("progress", 0)},
"payload": {
"code": "durable_replay_temporarily_unavailable",
"message": "进度同步暂时不可用,任务仍在继续;正在自动重试。",
"recoverable": True,
},
},
event_id=str(cursor + 1),
)
return
next_durable_poll = loop.time() + _POLL_INTERVAL_S
next_durable_poll = loop.time() + max(_POLL_INTERVAL_S, 3.0)
if live_queue is None:
await asyncio.sleep(max(0.01, next_durable_poll - loop.time()))
continue
@@ -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(
@@ -195,17 +194,22 @@ def _intent_thread_metadata(
if not task_id:
return metadata
metadata.update(
{
"taskId": task_id,
"position_roundtable_task_id": task_id,
"position_task_id": f"{task_id}:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
}
)
if intent_agent_id == POSITION_INTENT_AGENT_ID:
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
metadata["taskId"] = task_id
# 8BF 使用通用意图接口但有明确的上下文 kind;它不能被岗位会商的
# 会话索引误认为岗位任务,否则两个页面的历史记录会串在一起。
# 其它历史调用保持原有 metadata 形状,避免影响既有会商恢复逻辑。
is_eightbf_context = task_context.get("kind") == "eightbf_roundtable_task"
if intent_agent_id == POSITION_INTENT_AGENT_ID or not is_eightbf_context:
metadata.update(
{
"position_roundtable_task_id": task_id,
"position_task_id": f"{task_id}:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
}
)
if intent_agent_id == POSITION_INTENT_AGENT_ID:
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
return metadata
@@ -691,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 = [
@@ -705,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
@@ -48,6 +48,11 @@ class PersistingEventSink:
self._session_id = session_id
self._job_id = job_id
self._on_event = on_event
# A durable event row normally owns the sequence. When the event
# database is temporarily unavailable, use distinct negative sequence
# numbers for the live-only path so clients can render every milestone
# without advancing their durable ``after`` cursor.
self._fallback_seq = 0
async def emit(
self,
@@ -56,26 +61,66 @@ class PersistingEventSink:
phase: ResearchPhase = "initializing",
payload: dict | None = None,
) -> DeepResearchEvent:
row = await self._repo.append(
session_id=self._session_id,
job_id=self._job_id,
event_type=type,
phase=phase,
payload=payload or {},
)
# Reconstruct the envelope from the persisted row (seq is now assigned).
event = DeepResearchEvent(
seq=int(row.get("seq", 0)),
session_id=self._session_id,
job_id=self._job_id,
type=row.get("event_type", type),
phase=row.get("phase", phase),
payload=row.get("payload") or payload or {},
)
if self._on_event is not None:
await self._on_event(event.to_sse_dict())
event_payload = payload or {}
try:
row = await self._repo.append(
session_id=self._session_id,
job_id=self._job_id,
event_type=type,
phase=phase,
payload=event_payload,
)
# Reconstruct the envelope from the persisted row (seq is now assigned).
event = DeepResearchEvent(
seq=int(row.get("seq", 0)),
session_id=self._session_id,
job_id=self._job_id,
type=row.get("event_type", type),
phase=row.get("phase", phase),
payload=row.get("payload") or event_payload,
)
except Exception: # noqa: BLE001
# Progress persistence must not turn a recoverable database blip
# into a failed report. The job/status stores still record terminal
# state; this live-only frame lets an attached UI stay responsive
# until durable replay becomes available again after reconnect.
self._fallback_seq -= 1
logger.exception(
"deep-research event persistence failed; continuing job with a live-only event (job=%s, type=%s, phase=%s)",
self._job_id,
type,
phase,
)
event = DeepResearchEvent(
seq=self._fallback_seq,
session_id=self._session_id,
job_id=self._job_id,
type=type,
phase=phase,
payload={**event_payload, "eventPersistenceDegraded": True},
)
await self._publish_event(event)
return event
async def _publish_event(self, event: DeepResearchEvent) -> None:
"""Push a persisted or live-only event without risking the worker."""
if self._on_event is None:
return
wire = event.to_sse_dict()
if event.seq < 0:
wire["liveId"] = f"event-persistence-fallback:{self._job_id}:{abs(event.seq)}"
try:
await self._on_event(wire)
except Exception: # noqa: BLE001
# A browser disconnect or a live-hub issue must not abort report
# generation; persisted rows will still be replayable on reconnect.
logger.warning(
"deep-research live event publish failed; job continues (job=%s, seq=%s)",
self._job_id,
event.seq,
exc_info=True,
)
async def publish_live(
self,
type: str, # noqa: A002
@@ -85,16 +130,24 @@ class PersistingEventSink:
) -> None:
if self._on_event is None:
return
await self._on_event(
{
"seq": 0,
"sessionId": self._session_id,
"jobId": self._job_id,
"type": type,
"phase": phase,
"payload": payload or {},
}
)
try:
await self._on_event(
{
"seq": 0,
"sessionId": self._session_id,
"jobId": self._job_id,
"type": type,
"phase": phase,
"payload": payload or {},
}
)
except Exception: # noqa: BLE001
logger.warning(
"deep-research live frame publish failed; job continues (job=%s, type=%s)",
self._job_id,
type,
exc_info=True,
)
class NullEventSink:
@@ -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:
@@ -27,14 +27,15 @@ from typing import Any, Protocol, override, runtime_checkable
from langchain.agents import AgentState
from langchain.agents.middleware import SummarizationMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import (
AIMessage,
AnyMessage,
SystemMessage,
ToolMessage,
message_to_dict,
messages_from_dict,
)
from langchain_core.messages import (
AIMessage,
AnyMessage,
HumanMessage,
SystemMessage,
ToolMessage,
message_to_dict,
messages_from_dict,
)
from langgraph.config import get_config
from langgraph.runtime import Runtime
@@ -145,8 +146,8 @@ class _SummaryCacheEntry:
summarized_ids: frozenset[str]
summary_text: str
preserved_ids: frozenset[str] = frozenset()
preserved_messages: tuple[AnyMessage, ...] = ()
preserved_ids: frozenset[str] = frozenset()
preserved_messages: tuple[AnyMessage, ...] = ()
# Process-wide summary cache shared across every middleware instance.
@@ -160,51 +161,99 @@ class _SummaryCacheEntry:
# so only the first threshold crossing (or a genuine re-compaction) pays for an
# LLM call. A lock guards the non-atomic LRU eviction against concurrent
# threads (subagents / parallel conversations share this process).
_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock()
_STATE_CACHE_KEY = "context_summary_cache"
def _clear_global_summary_cache() -> None:
"""Drop every cached summary. Intended for tests/diagnostics only."""
with _GLOBAL_SUMMARY_CACHE_LOCK:
_GLOBAL_SUMMARY_CACHE.clear()
def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]:
"""Serialize a summary cache entry so it can live in LangGraph state."""
return {
"version": 1,
"summarized_ids": sorted(entry.summarized_ids),
"summary_text": entry.summary_text,
"preserved_ids": sorted(entry.preserved_ids),
"preserved_messages": [message_to_dict(message) for message in entry.preserved_messages],
}
def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None:
"""Best-effort restore of a state-backed summary cache entry."""
if not isinstance(raw, dict):
return None
try:
summary_text = raw.get("summary_text")
if not isinstance(summary_text, str) or not summary_text:
return None
summarized_ids = raw.get("summarized_ids") or []
preserved_ids = raw.get("preserved_ids") or []
preserved_messages_raw = raw.get("preserved_messages") or []
if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list):
return None
preserved_messages = tuple(messages_from_dict(preserved_messages_raw))
return _SummaryCacheEntry(
summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None),
summary_text=summary_text,
preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None),
preserved_messages=preserved_messages,
)
except Exception:
logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True)
return None
_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock()
_STATE_CACHE_KEY = "context_summary_cache"
def _clear_global_summary_cache() -> None:
"""Drop every cached summary. Intended for tests/diagnostics only."""
with _GLOBAL_SUMMARY_CACHE_LOCK:
_GLOBAL_SUMMARY_CACHE.clear()
def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]:
"""Serialize a summary cache entry so it can live in LangGraph state."""
return {
"version": 1,
"summarized_ids": sorted(entry.summarized_ids),
"summary_text": entry.summary_text,
"preserved_ids": sorted(entry.preserved_ids),
"preserved_messages": [message_to_dict(message) for message in entry.preserved_messages],
}
def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None:
"""Best-effort restore of a state-backed summary cache entry."""
if not isinstance(raw, dict):
return None
try:
summary_text = raw.get("summary_text")
if not isinstance(summary_text, str) or not summary_text:
return None
summarized_ids = raw.get("summarized_ids") or []
preserved_ids = raw.get("preserved_ids") or []
preserved_messages_raw = raw.get("preserved_messages") or []
if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list):
return None
preserved_messages = tuple(messages_from_dict(preserved_messages_raw))
return _SummaryCacheEntry(
summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None),
summary_text=summary_text,
preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None),
preserved_messages=preserved_messages,
)
except Exception:
logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True)
return None
def _has_nonempty_human_message(message: AnyMessage) -> bool:
"""Whether a message is a real, non-empty user turn.
Some OpenAI-compatible gateways reject a completion request that contains
only ``system`` / ``assistant`` / ``tool`` roles. That can legitimately
happen after compaction: the active model call is continuing a tool turn,
while the user request that started the turn has already moved into the
internal summary.
"""
if not isinstance(message, HumanMessage):
return False
content = message.content
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return any(bool(block.strip()) if isinstance(block, str) else bool((block.get("text") or block.get("content") or "").strip()) if isinstance(block, dict) else False for block in content)
return bool(str(content).strip())
def _ensure_latest_human_message(
source_messages: list[AnyMessage],
compressed_messages: list[AnyMessage],
) -> list[AnyMessage]:
"""Keep the latest real user request visible to strict chat gateways.
The message is inserted after leading system messages, so it neither
exposes the internal summary to the UI nor splits an AI tool-call from its
following ToolMessages. When there was no user turn in the source at all,
do not fabricate one: that is an upstream caller bug and should remain
diagnosable rather than silently changing the task.
"""
if any(_has_nonempty_human_message(message) for message in compressed_messages):
return compressed_messages
latest_human = next(
(message for message in reversed(source_messages) if _has_nonempty_human_message(message)),
None,
)
if latest_human is None:
logger.warning("summarization: compressed model request has no user message and source history contains none; passing it through unchanged")
return compressed_messages
insert_at = 0
while insert_at < len(compressed_messages) and isinstance(compressed_messages[insert_at], SystemMessage):
insert_at += 1
return [*compressed_messages[:insert_at], latest_human, *compressed_messages[insert_at:]]
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
@@ -231,8 +280,8 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text).
# Points at the *process-wide* cache (not a fresh per-instance dict) so a
# summary survives across runs/turns — see ``_GLOBAL_SUMMARY_CACHE``.
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE
self._pending_state_cache_update: dict[str, Any] | None = None
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE
self._pending_state_cache_update: dict[str, Any] | None = None
@override
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
@@ -248,21 +297,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
"""Async counterpart of :meth:`before_model` — also a no-op."""
return None
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Persist the latest reusable summary cache into thread state."""
if self._pending_state_cache_update is None:
return None
update = self._pending_state_cache_update
self._pending_state_cache_update = None
return {_STATE_CACHE_KEY: update}
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Async counterpart of :meth:`after_model`."""
return self.after_model(state, runtime)
# ---- wrap_model_call: transient, display-preserving compression ----------
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Persist the latest reusable summary cache into thread state."""
if self._pending_state_cache_update is None:
return None
update = self._pending_state_cache_update
self._pending_state_cache_update = None
return {_STATE_CACHE_KEY: update}
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Async counterpart of :meth:`after_model`."""
return self.after_model(state, runtime)
# ---- wrap_model_call: transient, display-preserving compression ----------
@override
def wrap_model_call(
@@ -376,20 +425,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
return None
thread_id = self._resolve_request_thread_id(request)
if thread_id is None:
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
cached = self._get_cache_entry(thread_id) if thread_id else None
if cached is None:
cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY))
if cached is not None and thread_id:
self._store_cache_entry(thread_id, cached)
present_ids = {m.id for m in messages}
cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids
if cache_usable:
preserved = list(cached.preserved_messages)
excluded_ids = cached.summarized_ids | cached.preserved_ids
preserved.extend(m for m in messages if m.id not in excluded_ids)
effective = [self._summary_message(cached.summary_text), *preserved]
if thread_id is None:
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
cached = self._get_cache_entry(thread_id) if thread_id else None
if cached is None:
cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY))
if cached is not None and thread_id:
self._store_cache_entry(thread_id, cached)
present_ids = {m.id for m in messages}
cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids
if cache_usable:
preserved = list(cached.preserved_messages)
excluded_ids = cached.summarized_ids | cached.preserved_ids
preserved.extend(m for m in messages if m.id not in excluded_ids)
effective = [self._summary_message(cached.summary_text), *preserved]
effective = _ensure_latest_human_message(messages, effective)
# Decide re-summarization on the *counted* size of the compressed
# context only — NOT ``_should_summarize`` — because that folds in the
# last AIMessage's reported ``usage_metadata`` (system prompt + tool
@@ -400,8 +450,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# Refresh LRU recency and reuse without an LLM call.
self._touch_cache_entry(thread_id)
logger.info(
"summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary "
"thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d",
"summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d",
thread_id,
self.token_counter(messages),
self.token_counter(effective),
@@ -415,7 +464,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# an "it compacts every turn" report is diagnosable straight from the
# backend log: cache=miss → thread_id/key problem; unusable → message-id
# instability; stale → preserved genuinely grew back over the trigger.
cache_state = "miss" if cached is None else "stale"
cache_state = "miss" if cached is None else "stale"
logger.info(
"summarization: compacting thread=%s cache=%s counted_tokens=%d msgs=%d",
thread_id,
@@ -452,22 +501,23 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
) -> list[AnyMessage]:
"""Assemble the compressed message list and update the per-thread cache."""
compressed = [self._summary_message(summary), *preserved]
compressed = _ensure_latest_human_message([*messages_to_summarize, *preserved], compressed)
if thread_id:
summarized_ids = frozenset(m.id for m in messages_to_summarize if m.id is not None)
preserved_ids = {m.id for m in preserved if m.id is not None}
self._store_cache_entry(
thread_id,
entry := _SummaryCacheEntry(
summarized_ids=summarized_ids,
summary_text=summary,
preserved_ids=frozenset(preserved_ids),
preserved_messages=tuple(preserved),
),
)
self._pending_state_cache_update = _serialize_cache_entry(entry)
return compressed
self._store_cache_entry(
thread_id,
entry := _SummaryCacheEntry(
summarized_ids=summarized_ids,
summary_text=summary,
preserved_ids=frozenset(preserved_ids),
preserved_messages=tuple(preserved),
),
)
self._pending_state_cache_update = _serialize_cache_entry(entry)
return compressed
def _get_cache_entry(self, thread_id: str) -> _SummaryCacheEntry | None:
"""Return the cached entry for ``thread_id`` (thread-safe read)."""
@@ -571,7 +621,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
return None
return _resolve_thread_id(runtime)
def _summary_message(self, summary: str) -> SystemMessage:
def _summary_message(self, summary: str) -> SystemMessage:
"""Build the summary message handed to the model.
It is named ``summary`` and flagged ``hide_from_ui`` defensively. Because
@@ -579,21 +629,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
state), the frontend never receives it — the user keeps seeing the
original conversation.
"""
return SystemMessage(
content=(
"Internal conversation summary for continuity only. Use it silently "
"to answer the user's latest request. Never reveal, quote, mention, "
"or describe this summary or the fact that context was compacted.\n\n"
"<internal_context_summary>\n"
f"{summary}\n"
"</internal_context_summary>"
),
return SystemMessage(
content=(
"Internal conversation summary for continuity only. Use it silently "
"to answer the user's latest request. Never reveal, quote, mention, "
"or describe this summary or the fact that context was compacted.\n\n"
"<internal_context_summary>\n"
f"{summary}\n"
"</internal_context_summary>"
),
name="summary",
additional_kwargs={"hide_from_ui": True, "lc_source": "summarization"},
)
@override
def _build_new_messages(self, summary: str) -> list[AnyMessage]:
def _build_new_messages(self, summary: str) -> list[AnyMessage]:
"""Kept for backwards compatibility; delegates to :meth:`_summary_message`."""
return [self._summary_message(summary)]
@@ -68,13 +68,7 @@ class DeepResearchEventRepository:
"""
for attempt in range(max_retries):
async with self._sf() as session:
current_max = (
await session.execute(
select(func.max(DeepResearchEventRow.seq)).where(
DeepResearchEventRow.job_id == job_id
)
)
).scalar() or 0
current_max = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar() or 0
next_seq = int(current_max) + 1
row = DeepResearchEventRow(
job_id=job_id,
@@ -86,49 +80,38 @@ class DeepResearchEventRepository:
)
session.add(row)
try:
# Do not issue ``session.refresh(row)`` after commit. On a
# MySQL read/write-splitting deployment that read can land
# on a lagging replica, which cannot see the just-committed
# event and raises ``Could not refresh instance``. Flush
# assigns generated primary-key/default values on the writer
# connection, so the plain dict is complete before commit.
await session.flush()
result = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return result
except IntegrityError:
await session.rollback()
continue # retry with recomputed max
raise RuntimeError(
f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})"
)
raise RuntimeError(f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})")
async def list_after(
self, job_id: str, *, after: int = 0, limit: int = 200
) -> list[dict[str, Any]]:
async def list_after(self, job_id: str, *, after: int = 0, limit: int = 200) -> list[dict[str, Any]]:
"""Events with ``seq > after`` in ascending order (SSE replay, §15.3)."""
async with self._sf() as session:
stmt = (
select(DeepResearchEventRow)
.where(DeepResearchEventRow.job_id == job_id)
.where(DeepResearchEventRow.seq > after)
.order_by(DeepResearchEventRow.seq.asc())
.limit(limit)
)
stmt = select(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id).where(DeepResearchEventRow.seq > after).order_by(DeepResearchEventRow.seq.asc()).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
return [self._row_to_dict(r) for r in rows]
async def last_seq(self, job_id: str) -> int:
async with self._sf() as session:
mx = (
await session.execute(
select(func.max(DeepResearchEventRow.seq)).where(
DeepResearchEventRow.job_id == job_id
)
)
).scalar()
mx = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar()
return int(mx or 0)
async def delete_by_job(self, job_id: str) -> int:
from sqlalchemy import delete
async with self._sf() as session:
result = await session.execute(
delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id)
)
result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id))
await session.commit()
return result.rowcount
@@ -136,8 +119,6 @@ class DeepResearchEventRepository:
from sqlalchemy import delete
async with self._sf() as session:
result = await session.execute(
delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id)
)
result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id))
await session.commit()
return result.rowcount
@@ -113,9 +113,18 @@ class DeepResearchJobRepository:
phase=phase,
)
session.add(row)
# Do not issue ``session.refresh(row)`` after commit. A
# MySQL read/write-splitting proxy can route that read to
# a lagging replica, even though the INSERT has already
# committed on the writer. The caller would receive 500
# while an invisible queued job remains in the database.
# ``flush`` materializes ORM defaults on the writer, so we
# can return a complete, durable snapshot without a
# replica-dependent readback.
await session.flush()
result = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row), True
return result, True
except IntegrityError:
# Unique-index conflict → an active job already exists.
pass
@@ -230,13 +239,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── heartbeat ───────────────────────────────────────────────────────────
@@ -351,13 +361,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── two-phase cancel ────────────────────────────────────────────────────
@@ -385,13 +396,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
async def finalize_cancel(
self, job_id: str, *, lease_owner: str | None = None
@@ -419,13 +431,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── resume after human-review interrupt ─────────────────────────────────
@@ -460,13 +473,14 @@ class DeepResearchJobRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── cleanup ─────────────────────────────────────────────────────────────
@@ -74,9 +74,13 @@ class DeepResearchMessageRepository:
seq=int(current_max) + 1,
)
session.add(row)
# Keep the successful append independent of a later replica read.
# The explicit id/seq and Python-side defaults are available after
# flush, before the transaction releases its writer connection.
await session.flush()
snapshot = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return snapshot
async def list_by_session(
self, session_id: str, *, user_id: str | _AutoSentinel = AUTO, limit: int = 100
@@ -139,10 +143,11 @@ class DeepResearchMessageRepository:
if not result.rowcount:
await session.rollback()
return None
await session.commit()
stmt = select(DeepResearchMessageRow).where(*where)
row = (await session.execute(stmt)).scalar_one_or_none()
return self._row_to_dict(row) if row is not None else None
snapshot = self._row_to_dict(row) if row is not None else None
await session.commit()
return snapshot
async def delete_by_session(self, session_id: str) -> int:
from sqlalchemy import delete
@@ -85,9 +85,13 @@ class DeepResearchSessionRepository:
runtime_thread_id=runtime_thread_id,
)
session.add(row)
# New sessions are a control-plane write. Avoid a post-commit
# refresh because MySQL read/write-splitting can make a just
# created session look absent on a lagging replica.
await session.flush()
snapshot = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return snapshot
# ── read ────────────────────────────────────────────────────────────────
@@ -187,13 +191,14 @@ class DeepResearchSessionRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
async def replace_report_if_unchanged(
self,
@@ -231,13 +236,14 @@ class DeepResearchSessionRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
# ── delete ──────────────────────────────────────────────────────────────
@@ -23,6 +23,13 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
class DeepResearchSourceRow(Base):
__tablename__ = "deep_research_sources"
__table_args__ = (
# MySQL requires a prefix length when indexing TEXT columns. The
# column remains unbounded so long URLs are preserved; the prefix is
# only an index implementation detail and equality filters still
# compare the complete URL value.
sa.Index("ix_deep_research_sources_url", "url", mysql_length=512),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
@@ -32,7 +39,7 @@ class DeepResearchSourceRow(Base):
# The query that first surfaced this material.
first_seen_query: Mapped[str | None] = mapped_column(Text, nullable=True)
title: Mapped[str] = mapped_column(Text, nullable=False, default="")
url: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
url: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_content: Mapped[str] = mapped_column(PortableLongText, nullable=False, default="")
snippet: Mapped[str | None] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(256), nullable=False, default="")
@@ -37,9 +37,14 @@ class DeepResearchSourceRepository:
row = DeepResearchSourceRow(session_id=session_id, job_id=job_id, **fields)
session.add(row)
try:
# The source was produced on the worker's write path. Build
# its response on that same connection so a replica-lag read
# cannot turn a successfully collected source into a failed
# research run.
await session.flush()
snapshot = self._row_to_dict(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
return snapshot
except Exception:
await session.rollback()
# Fallback: find existing by rec_uuid or url.
@@ -147,13 +152,14 @@ class DeepResearchSourceRepository:
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(DeepResearchSourceRow).where(DeepResearchSourceRow.id == source_id)
)
).scalar_one_or_none()
return self._row_to_dict(row) if row else None
snapshot = self._row_to_dict(row) if row else None
await session.commit()
return snapshot
async def delete_by_session(self, session_id: str) -> int:
from sqlalchemy import delete
@@ -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 == []
@@ -0,0 +1,83 @@
"""Fault-tolerance coverage for Deep Research progress events."""
import pytest
from deerflow.agents.deep_research.adapters.event_sink import PersistingEventSink
class _UnavailableEventRepository:
async def append(self, **kwargs): # noqa: ANN003
raise RuntimeError("database temporarily unavailable")
@pytest.mark.asyncio
async def test_event_persistence_failure_keeps_job_live_and_emits_unique_fallback_event():
published: list[dict] = []
async def publish(event: dict) -> None:
published.append(event)
sink = PersistingEventSink(
_UnavailableEventRepository(),
session_id="drs_1",
job_id="drj_1",
on_event=publish,
)
first = await sink.emit("phase_changed", phase="planning", payload={"to": "planning"})
second = await sink.emit("queries_planned", phase="collecting", payload={"queries": ["AI"]})
assert (first.seq, second.seq) == (-1, -2)
assert first.payload["eventPersistenceDegraded"] is True
assert published[0]["liveId"] == "event-persistence-fallback:drj_1:1"
assert published[1]["liveId"] == "event-persistence-fallback:drj_1:2"
@pytest.mark.asyncio
async def test_live_publish_failure_does_not_fail_event_delivery():
class _EventRepository:
async def append(self, **kwargs): # noqa: ANN003
return {
"seq": 1,
"event_type": kwargs["event_type"],
"phase": kwargs["phase"],
"payload": kwargs["payload"],
}
async def broken_publisher(event: dict) -> None: # noqa: ARG001
raise RuntimeError("browser disconnected")
sink = PersistingEventSink(
_EventRepository(),
session_id="drs_1",
job_id="drj_1",
on_event=broken_publisher,
)
event = await sink.emit("phase_changed", phase="planning")
assert event.seq == 1
@pytest.mark.asyncio
async def test_document_rewrite_milestone_is_a_supported_durable_event():
"""Guard the artifact-rewrite job path against EventType regressions."""
class _EventRepository:
async def append(self, **kwargs): # noqa: ANN003
return {
"seq": 1,
"event_type": kwargs["event_type"],
"phase": kwargs["phase"],
"payload": kwargs["payload"],
}
sink = PersistingEventSink(_EventRepository(), session_id="drs_1", job_id="drj_1")
event = await sink.emit(
"document_rewrite",
phase="planning",
payload={"type": "requirements_started"},
)
assert event.type == "document_rewrite"
@@ -0,0 +1,72 @@
"""Failure-ordering regression coverage for final Deep Research report writes."""
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
class _JobStore:
def __init__(self, session_store) -> None:
self._session_store = session_store
self.progress_calls: list[dict] = []
async def update_progress(self, job_id: str, **fields): # noqa: ARG002
assert self._session_store.report_updates, "report must be persisted before job completion"
self.progress_calls.append(fields)
return {"id": job_id, **fields}
class _SessionStore:
def __init__(self, *, available: bool = True) -> None:
self.available = available
self.report_updates: list[dict] = []
async def update(self, session_id: str, **fields): # noqa: ARG002
if not self.available:
raise RuntimeError("temporary session database failure")
self.report_updates.append(fields)
return {"id": session_id, **fields}
@pytest.mark.asyncio
async def test_report_is_persisted_before_job_is_marked_completed():
sessions = _SessionStore()
jobs = _JobStore(sessions)
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
await runner._mark_completed(
"drj_1",
"drs_1",
"worker_1",
DeepResearchResult(report_markdown="# 报告", source_ids=["src_1"]),
)
assert sessions.report_updates[0]["report_markdown"] == "# 报告"
assert jobs.progress_calls[0]["status"] == "completed"
@pytest.mark.asyncio
async def test_report_projection_failure_never_marks_job_completed(monkeypatch):
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_ATTEMPTS", 2)
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_DELAY_SECONDS", 0)
sessions = _SessionStore(available=False)
jobs = _JobStore(sessions)
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
with pytest.raises(RuntimeError, match="研究报告未能保存"):
await runner._mark_completed(
"drj_1",
"drs_1",
"worker_1",
DeepResearchResult(report_markdown="# 报告"),
)
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"
@@ -0,0 +1,538 @@
"""Persistence tests for deep-research sessions, jobs, events, sources, messages.
Uses in-memory SQLite + aiosqlite, mirroring ``test_ai_writing_session_repo``.
Focuses on the durable-job mechanics that are load-bearing for correctness:
- Session CRUD + user isolation.
- Job ``try_create_or_get_active`` idempotency (unique dedupe key).
- Job lease claim / renew / release (atomic CAS).
- Two-phase cancel (``request_cancel`` → ``finalize_cancel``).
- Event seq monotonicity + ``list_after`` replay (SSE reconnect correctness).
Pattern: a plain ``async def _make_repos()`` helper (not a fixture) — matches
the project convention for async DB setup in pytest-asyncio strict mode.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
async def _make_repos():
"""Create an in-memory SQLite repo set with all deep-research tables."""
from deerflow.persistence.base import Base
from deerflow.persistence.deep_research_events.model import DeepResearchEventRow # noqa: F401
from deerflow.persistence.deep_research_events.sql import DeepResearchEventRepository
from deerflow.persistence.deep_research_jobs.model import DeepResearchJobRow # noqa: F401
from deerflow.persistence.deep_research_jobs.sql import DeepResearchJobRepository
from deerflow.persistence.deep_research_messages.model import DeepResearchMessageRow # noqa: F401
from deerflow.persistence.deep_research_messages.sql import DeepResearchMessageRepository
from deerflow.persistence.deep_research_sessions.model import DeepResearchSessionRow # noqa: F401
from deerflow.persistence.deep_research_sessions.sql import DeepResearchSessionRepository
from deerflow.persistence.deep_research_sources.model import DeepResearchSourceRow # noqa: F401
from deerflow.persistence.deep_research_sources.sql import DeepResearchSourceRepository
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
return {
"sessions": DeepResearchSessionRepository(sf),
"jobs": DeepResearchJobRepository(sf),
"events": DeepResearchEventRepository(sf),
"sources": DeepResearchSourceRepository(sf),
"messages": DeepResearchMessageRepository(sf),
"_engine": engine,
}
# ── sessions ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_session_create_and_get():
repos = await _make_repos()
try:
created = await repos["sessions"].create(id="drs_1", user_id="u1", title="Test", query="AI research", mode="deep")
assert created["id"] == "drs_1"
assert created["status"] == "draft"
got = await repos["sessions"].get("drs_1", user_id="u1")
assert got is not None
assert got["query"] == "AI research"
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_deep_research_writes_never_depend_on_post_commit_refresh(monkeypatch):
"""Control-plane writes must survive a MySQL replica-lag readback."""
from sqlalchemy.ext.asyncio import AsyncSession
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
raise AssertionError("Deep Research writes must not call session.refresh")
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
repos = await _make_repos()
try:
session = await repos["sessions"].create(
id="drs_1", user_id="u1", title="Test", query="AI research"
)
assert session["id"] == "drs_1"
assert await repos["sessions"].update("drs_1", user_id="u1", status="running")
job, created = await repos["jobs"].try_create_or_get_active(
id="drj_1", session_id="drs_1", user_id="u1"
)
assert created is True
assert job["id"] == "drj_1"
source = await repos["sources"].upsert(
session_id="drs_1",
id="src_1",
user_id="u1",
title="A",
raw_content="source content",
source="web",
content_hash="hash_1",
)
assert source["id"] == "src_1"
message = await repos["messages"].append(
id="msg_1",
session_id="drs_1",
user_id="u1",
role="assistant",
content="已开始撰写",
)
assert message["id"] == "msg_1"
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_session_user_isolation():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q1")
assert await repos["sessions"].get("drs_1", user_id="u2") is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_session_list_strips_heavy_columns():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q", config_snapshot={"mode": "deep"})
items = await repos["sessions"].list_by_user(user_id="u1")
assert len(items) == 1
assert "report_markdown" not in items[0]
assert "config_snapshot" not in items[0]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_session_update_and_delete():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q")
updated = await repos["sessions"].update("drs_1", user_id="u1", title="New Title")
assert updated["title"] == "New Title"
assert await repos["sessions"].delete("drs_1", user_id="u1") is True
assert await repos["sessions"].get("drs_1", user_id="u1") is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_count_active_by_user():
repos = await _make_repos()
try:
await repos["sessions"].create(id="drs_1", user_id="u1", query="q", status="running")
await repos["sessions"].create(id="drs_2", user_id="u1", query="q", status="completed")
count = await repos["sessions"].count_active_by_user(user_id="u1")
assert count == 1
finally:
await repos["_engine"].dispose()
# ── jobs: idempotent create ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_job_try_create_returns_created_true():
repos = await _make_repos()
try:
job, created = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1")
assert created is True
assert job["status"] == "queued"
assert job["active_dedupe_key"] is not None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_create_never_reads_back_after_commit(monkeypatch):
"""A committed job must not fail when a MySQL replica briefly lags."""
from sqlalchemy.ext.asyncio import AsyncSession
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
raise AssertionError("DeepResearchJobRepository must not call session.refresh")
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
repos = await _make_repos()
try:
job, created = await repos["jobs"].try_create_or_get_active(
id="drj_1",
session_id="drs_1",
user_id="u1",
request_id="req_1",
)
assert created is True
assert job["id"] == "drj_1"
assert job["status"] == "queued"
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_try_create_idempotent_returns_existing():
repos = await _make_repos()
try:
job1, created1 = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1")
job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1", request_id="req_2")
assert created1 is True
assert created2 is False
assert job2["id"] == job1["id"]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_dedupe_released_on_terminal():
repos = await _make_repos()
try:
job, _ = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
assert job["active_dedupe_key"] is not None
await repos["jobs"].update_progress("drj_1", lease_owner=None, user_id="u1", status="completed")
job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1")
assert created2 is True
assert job2["id"] == "drj_2"
finally:
await repos["_engine"].dispose()
# ── jobs: lease mechanics ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_job_claim_succeeds_for_queued():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
claimed = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
assert claimed is not None
assert claimed["status"] == "running"
assert claimed["lease_owner"] == "w1"
assert claimed["attempt"] == 1
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_dispatcher_claims_queued_job_and_starts_report_worker():
"""The claimed snapshot must reach the executor before the job can run."""
from app.gateway.deep_research_job_dispatcher import DeepResearchJobDispatcher
class _Executor:
def __init__(self) -> None:
self.started: list[tuple[dict, str, str | None]] = []
def start_job(self, snapshot: dict, *, job_id: str, lease_owner: str | None) -> bool:
self.started.append((snapshot, job_id, lease_owner))
return True
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(
id="drj_1",
session_id="drs_1",
user_id="u1",
input_snapshot={"query": "测试报告"},
)
runner = _Executor()
dispatcher = DeepResearchJobDispatcher(repos["jobs"], runner, worker_id="worker_1")
started = await dispatcher.dispatch_once()
assert started == 1
assert runner.started == [({"query": "测试报告"}, "drj_1", "worker_1")]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_claim_fails_if_already_claimed():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
first = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
assert first is not None
second = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=lease_until)
assert second is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_renew_lease_only_by_owner():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
assert await repos["jobs"].renew_lease("drj_1", lease_owner="w1", lease_until=lease_until) is True
assert await repos["jobs"].renew_lease("drj_1", lease_owner="w2", lease_until=lease_until) is False
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_expired_lease_reclaimable():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
expired = datetime.now(UTC) - timedelta(seconds=10)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=expired)
now = datetime.now(UTC)
claimable = await repos["jobs"].list_claimable(now=now)
assert "drj_1" in claimable
reclaimed = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=now + timedelta(seconds=60))
assert reclaimed is not None
assert reclaimed["lease_owner"] == "w2"
finally:
await repos["_engine"].dispose()
# ── jobs: two-phase cancel ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_job_cancel_two_phase():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
requested = await repos["jobs"].request_cancel("drj_1", user_id="u1")
assert requested is not None
assert requested["cancel_requested"] is True
finalized = await repos["jobs"].finalize_cancel("drj_1")
assert finalized is not None
assert finalized["status"] == "cancelled"
assert finalized["active_dedupe_key"] is None
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_job_cancel_idempotent():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
first = await repos["jobs"].request_cancel("drj_1", user_id="u1")
assert first is not None
await repos["jobs"].finalize_cancel("drj_1")
second = await repos["jobs"].request_cancel("drj_1", user_id="u1")
assert second is None
finally:
await repos["_engine"].dispose()
# ── jobs: progress update with lease gate ───────────────────────────────────
@pytest.mark.asyncio
async def test_job_update_progress_lease_gated():
repos = await _make_repos()
try:
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
lease_until = datetime.now(UTC) + timedelta(seconds=60)
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
updated = await repos["jobs"].update_progress("drj_1", lease_owner="w1", phase="collecting", progress=50)
assert updated is not None
assert updated["phase"] == "collecting"
assert updated["progress"] == 50
rejected = await repos["jobs"].update_progress("drj_1", lease_owner="w2", phase="writing")
assert rejected is None
finally:
await repos["_engine"].dispose()
# ── events: monotonic seq + replay ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_event_seq_monotonic():
repos = await _make_repos()
try:
repo = repos["events"]
e1 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="phase_changed", payload={"to": "planning"})
e2 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s1"})
e3 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s2"})
assert e1["seq"] == 1
assert e2["seq"] == 2
assert e3["seq"] == 3
assert await repo.last_seq("drj_1") == 3
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_append_never_reads_back_after_commit(monkeypatch):
"""Event persistence must work when a post-commit read hits a lagging replica."""
from sqlalchemy.ext.asyncio import AsyncSession
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
raise AssertionError("DeepResearchEventRepository must not call session.refresh")
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
repos = await _make_repos()
try:
event = await repos["events"].append(
session_id="drs_1",
job_id="drj_1",
event_type="phase_changed",
payload={"to": "planning"},
)
assert event["id"] is not None
assert event["seq"] == 1
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_list_after_replay():
repos = await _make_repos()
try:
repo = repos["events"]
for i in range(5):
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat", payload={"i": i})
replayed = await repo.list_after("drj_1", after=2)
assert len(replayed) == 3
assert [e["seq"] for e in replayed] == [3, 4, 5]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_payload_round_trip():
repos = await _make_repos()
try:
repo = repos["events"]
await repo.append(
session_id="drs_1",
job_id="drj_1",
event_type="plan_created",
payload={"subtopics": ["AI", "ML"], "nested": {"a": 1}},
)
events = await repo.list_after("drj_1", after=0)
assert events[0]["payload"]["subtopics"] == ["AI", "ML"]
assert events[0]["payload"]["nested"]["a"] == 1
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_event_seq_independent_per_job():
repos = await _make_repos()
try:
repo = repos["events"]
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat")
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat")
await repo.append(session_id="drs_1", job_id="drj_2", event_type="heartbeat")
assert await repo.last_seq("drj_1") == 2
assert await repo.last_seq("drj_2") == 1
finally:
await repos["_engine"].dispose()
# ── sources ─────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_source_upsert_and_list():
repos = await _make_repos()
try:
await repos["sources"].upsert(
session_id="drs_1",
id="src_1",
user_id="u1",
title="Source A",
url="https://example.com/a",
raw_content="content A",
source="web",
source_type="web",
content_hash="hash_a",
)
items = await repos["sources"].list_by_session("drs_1", user_id="u1")
assert len(items) == 1
assert items[0]["title"] == "Source A"
assert "raw_content" not in items[0]
finally:
await repos["_engine"].dispose()
@pytest.mark.asyncio
async def test_source_set_selected():
repos = await _make_repos()
try:
await repos["sources"].upsert(
session_id="drs_1",
id="src_1",
user_id="u1",
title="A",
raw_content="c",
source="web",
content_hash="h",
)
updated = await repos["sources"].set_selected("src_1", selected=True, reason="relevant", citation_key="1", user_id="u1")
assert updated["selected"] is True
assert updated["citation_key"] == "1"
finally:
await repos["_engine"].dispose()
# ── messages ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_message_append_and_list():
repos = await _make_repos()
try:
await repos["messages"].append(id="msg_1", session_id="drs_1", user_id="u1", role="user", content="question?")
await repos["messages"].append(
id="msg_2",
session_id="drs_1",
user_id="u1",
role="assistant",
content="answer",
citation_source_ids=["src_1"],
)
msgs = await repos["messages"].list_by_session("drs_1", user_id="u1")
assert len(msgs) == 2
assert msgs[0]["role"] == "user"
assert msgs[1]["citation_source_ids"] == ["src_1"]
finally:
await repos["_engine"].dispose()
@@ -0,0 +1,211 @@
"""Regression coverage for idempotent Deep Research job submission."""
from types import SimpleNamespace
import pytest
from app.gateway.routers import deep_research
class _SessionStore:
def __init__(self) -> None:
self.updates: list[dict] = []
async def get(self, session_id: str, *, user_id: str): # noqa: ARG002
return {
"id": session_id,
"query": "测试主题",
"title": "测试主题",
"config_snapshot": {"mode": "basic"},
}
async def count_active_by_user(self, *, user_id: str) -> int: # noqa: ARG002
return 0
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
self.updates.append(fields)
return {"id": session_id, **fields}
class _JobStore:
async def get_active_for_session(self, session_id: str, *, user_id: str): # noqa: ARG002
return None
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
# Simulate a retry after an earlier request committed the job but did
# not finish responding to the browser.
return {"id": "drj_existing", "status": "queued"}, False
class _Dispatcher:
def __init__(self) -> None:
self.nudges = 0
def nudge(self) -> None:
self.nudges += 1
def _request(*, sessions, jobs, dispatcher):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
deep_research_session_store=sessions,
deep_research_job_store=jobs,
deep_research_dispatcher=dispatcher,
)
)
)
@pytest.mark.asyncio
async def test_retry_of_existing_queued_job_nudges_dispatcher(monkeypatch):
"""An idempotent retry must recover a queued-but-not-yet-dispatched job."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
sessions = _SessionStore()
dispatcher = _Dispatcher()
request = _request(sessions=sessions, jobs=_JobStore(), dispatcher=dispatcher)
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(entry="legacy"),
request,
)
assert response == {
"jobId": "drj_existing",
"sessionId": "drs_1",
"status": "queued",
"reused": True,
}
assert dispatcher.nudges == 1
assert sessions.updates == [{"status": "running", "active_job_id": "drj_existing"}]
@pytest.mark.asyncio
async def test_queued_job_still_starts_when_session_projection_temporarily_fails(monkeypatch):
"""A committed job is more important than its non-authoritative UI projection."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _ProjectionFailureSessionStore(_SessionStore):
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
raise RuntimeError("temporary database readback failure")
class _NewJobStore(_JobStore):
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
return {"id": "drj_new", "status": "queued"}, True
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
dispatcher = _Dispatcher()
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(entry="legacy"),
_request(
sessions=_ProjectionFailureSessionStore(),
jobs=_NewJobStore(),
dispatcher=dispatcher,
),
)
assert response["jobId"] == "drj_new"
assert response["reused"] is False
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."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _StreamJobStore:
async def get(self, job_id: str, *, user_id: str): # noqa: ARG002
return {"id": job_id, "session_id": "drs_1"}
async def get_unscoped(self, job_id: str): # noqa: ARG002
return None
class _UnavailableEventStore:
async def list_after(self, *args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("temporary replica failure")
async def is_connected() -> bool:
return False
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
deep_research_job_store=_StreamJobStore(),
deep_research_event_store=_UnavailableEventStore(),
)
),
is_disconnected=is_connected,
)
response = await deep_research.stream_job("drj_1", request)
chunk = await anext(response.body_iterator)
await response.body_iterator.aclose()
assert "durable_replay_temporarily_unavailable" in chunk
@@ -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,
@@ -0,0 +1,30 @@
"""Regression coverage for provider-safe transient context compression."""
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from deerflow.agents.middlewares.summarization_middleware import _ensure_latest_human_message
def test_compression_restores_latest_human_turn_when_only_tool_tail_remains():
user = HumanMessage(content="请分析这份材料", id="user-1")
source = [
user,
AIMessage(content="", id="ai-1"),
ToolMessage(content="检索结果", tool_call_id="call-1", id="tool-1"),
]
compressed = [SystemMessage(content="internal summary"), *source[1:]]
result = _ensure_latest_human_message(source, compressed)
assert result[0].type == "system"
assert result[1] is user
assert [message.type for message in result[2:]] == ["ai", "tool"]
def test_compression_does_not_duplicate_human_turn_already_in_tail():
user = HumanMessage(content="继续", id="user-1")
compressed = [SystemMessage(content="internal summary"), user]
result = _ensure_latest_human_message([user], compressed)
assert result == compressed
+32
View File
@@ -0,0 +1,32 @@
# Package frontend node_modules for offline development.
# Uses tar -h to dereference pnpm junctions so the archive is self-contained.
param(
[string]$OutputDir = (Join-Path (Split-Path $PSScriptRoot -Parent) "dist")
)
$ErrorActionPreference = "Stop"
$Root = Split-Path $PSScriptRoot -Parent
$Frontend = Join-Path $Root "frontend-web"
$Archive = Join-Path $OutputDir "frontend-offline-deps.tar.gz"
if (-not (Test-Path (Join-Path $Frontend "node_modules\.pnpm"))) {
throw "frontend-web/node_modules is missing or incomplete. Run 'pnpm install' in frontend-web first."
}
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
if (Test-Path $Archive) { Remove-Item $Archive -Force }
Write-Host "Packaging frontend dependencies..."
Write-Host " Source: $Frontend"
Write-Host " Output: $Archive"
$start = Get-Date
tar -czhf $Archive -C $Frontend node_modules package.json pnpm-lock.yaml
$elapsed = ((Get-Date) - $start).TotalSeconds
$sizeMb = [math]::Round((Get-Item $Archive).Length / 1MB, 2)
Write-Host ""
Write-Host "Done in ${elapsed}s — ${sizeMb} MB"
Write-Host ""
Write-Host "Copy to offline machine, then run:"
Write-Host " scripts\restore-frontend-offline-deps.ps1 -Archive <path-to-archive>"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FRONTEND="${ROOT}/frontend-web"
OUTPUT_DIR="${1:-${ROOT}/dist}"
ARCHIVE="${OUTPUT_DIR}/frontend-offline-deps.tar.gz"
if [[ ! -d "${FRONTEND}/node_modules/.pnpm" ]]; then
echo "frontend-web/node_modules is missing or incomplete. Run 'pnpm install' in frontend-web first." >&2
exit 1
fi
mkdir -p "${OUTPUT_DIR}"
rm -f "${ARCHIVE}"
echo "Packaging frontend dependencies..."
echo " Source: ${FRONTEND}"
echo " Output: ${ARCHIVE}"
tar -czhf "${ARCHIVE}" -C "${FRONTEND}" node_modules package.json pnpm-lock.yaml
SIZE_MB=$(du -m "${ARCHIVE}" | awk '{print $1}')
echo ""
echo "Done — ${SIZE_MB} MB"
echo ""
echo "Copy to offline machine, then run:"
echo " scripts/restore-frontend-offline-deps.sh <path-to-archive>"
+42
View File
@@ -0,0 +1,42 @@
# Restore frontend node_modules from an offline dependency archive.
param(
[Parameter(Mandatory = $true)]
[string]$Archive
)
$ErrorActionPreference = "Stop"
$Root = Split-Path $PSScriptRoot -Parent
$Frontend = Join-Path $Root "frontend-web"
if (-not (Test-Path $Archive)) {
throw "Archive not found: $Archive"
}
Write-Host "Restoring frontend dependencies to $Frontend ..."
# Back up existing node_modules if present
$backup = Join-Path $Frontend "node_modules.bak"
if (Test-Path (Join-Path $Frontend "node_modules")) {
if (Test-Path $backup) { Remove-Item $backup -Recurse -Force }
Rename-Item (Join-Path $Frontend "node_modules") "node_modules.bak"
Write-Host " Existing node_modules backed up to node_modules.bak"
}
tar -xzf $Archive -C $Frontend
# Quick sanity check
$required = @(
"node_modules\.pnpm",
"node_modules\react\package.json",
"node_modules\vite\package.json"
)
foreach ($rel in $required) {
if (-not (Test-Path (Join-Path $Frontend $rel))) {
throw "Restore incomplete — missing $rel"
}
}
Write-Host ""
Write-Host "Restore complete. You can now run:"
Write-Host " cd frontend-web"
Write-Host " pnpm dev"
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <path-to-frontend-offline-deps.tar.gz>" >&2
exit 1
fi
ARCHIVE="$1"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FRONTEND="${ROOT}/frontend-web"
if [[ ! -f "${ARCHIVE}" ]]; then
echo "Archive not found: ${ARCHIVE}" >&2
exit 1
fi
echo "Restoring frontend dependencies to ${FRONTEND} ..."
if [[ -d "${FRONTEND}/node_modules" ]]; then
rm -rf "${FRONTEND}/node_modules.bak"
mv "${FRONTEND}/node_modules" "${FRONTEND}/node_modules.bak"
echo " Existing node_modules backed up to node_modules.bak"
fi
tar -xzf "${ARCHIVE}" -C "${FRONTEND}"
for rel in node_modules/.pnpm node_modules/react/package.json node_modules/vite/package.json; do
if [[ ! -e "${FRONTEND}/${rel}" ]]; then
echo "Restore incomplete — missing ${rel}" >&2
exit 1
fi
done
echo ""
echo "Restore complete. You can now run:"
echo " cd frontend-web"
echo " pnpm dev"
+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 &