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