Merge branch 'master-nj' into 'master'
增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。 See merge request knowledge-base-bj/deerflow-code!25
This commit is contained in:
commit
33b0af333c
81 files changed
+8436
-1076
No files matched your search
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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" />
|
||||
达成共识生成全案成果 >
|
||||
</button>
|
||||
<Sparkles className="size-3.5 text-emerald-100" />
|
||||
达成共识生成全案成果 >
|
||||
</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" />
|
||||
达成共识生成全案成果 >
|
||||
</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,
|
||||
}),
|
||||
|
||||
+609
@@ -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: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"\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)
|
||||
|
||||
@@ -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 相关配置
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+81
-28
@@ -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:
|
||||
|
||||
+211
-12
@@ -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",
|
||||
]
|
||||
+46
-22
@@ -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.")),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+5
@@ -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",
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+20
-3
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+1
@@ -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}
|
||||
|
||||
+14
@@ -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:
|
||||
|
||||
+162
-112
@@ -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)]
|
||||
|
||||
|
||||
+16
-35
@@ -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
|
||||
+26
-12
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+9
-4
@@ -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
|
||||
|
||||
+12
-6
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+8
-1
@@ -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="")
|
||||
|
||||
+10
-4
@@ -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
|
||||
@@ -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>"
|
||||
@@ -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>"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 &
|
||||
|
||||
|
||||
Reference in new issue
Block a user