增强文档改写配图与深度研究流式稳定性,并完善 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 { useParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Streamdown } from "streamdown";
|
import { defaultUrlTransform } from "react-markdown";
|
||||||
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Artifact,
|
Artifact,
|
||||||
@@ -42,6 +43,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|||||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||||
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
|
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
|
||||||
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
|
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
|
||||||
|
import { useCapabilities as useResearchCapabilities } from "@/strategy-components/api/deep-research";
|
||||||
import {
|
import {
|
||||||
listDurableDocumentRewrites,
|
listDurableDocumentRewrites,
|
||||||
streamDurableDocumentRewriteJob,
|
streamDurableDocumentRewriteJob,
|
||||||
@@ -149,9 +151,11 @@ export function ArtifactFileDetail({
|
|||||||
onClose,
|
onClose,
|
||||||
allowVirtualMarkdownEdit = false,
|
allowVirtualMarkdownEdit = false,
|
||||||
disableDesensitize = false,
|
disableDesensitize = false,
|
||||||
hideFileName = false,
|
hideFileName = false,
|
||||||
onDownloadClick,
|
onDownloadClick,
|
||||||
}: {
|
toolbarActions,
|
||||||
|
researchSessionId,
|
||||||
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
filepath: string;
|
filepath: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
@@ -177,8 +181,15 @@ export function ArtifactFileDetail({
|
|||||||
* (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的
|
* (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的
|
||||||
* 「另存为 Word」。默认不传 —— 其它调用方行为不变。
|
* 「另存为 Word」。默认不传 —— 其它调用方行为不变。
|
||||||
*/
|
*/
|
||||||
onDownloadClick?: () => void;
|
onDownloadClick?: () => void;
|
||||||
}) {
|
/** Deep-research reports use protected `deep-research://` image references. */
|
||||||
|
researchSessionId?: string;
|
||||||
|
/**
|
||||||
|
* 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它
|
||||||
|
* 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。
|
||||||
|
*/
|
||||||
|
toolbarActions?: ReactNode;
|
||||||
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { artifacts, setOpen, select } = useArtifacts();
|
const { artifacts, setOpen, select } = useArtifacts();
|
||||||
const { isMock, thread, publicShareCode } = useThread();
|
const { isMock, thread, publicShareCode } = useThread();
|
||||||
@@ -291,13 +302,20 @@ export function ArtifactFileDetail({
|
|||||||
const updateArtifact = useUpdateArtifactContent();
|
const updateArtifact = useUpdateArtifactContent();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const documentRewrite = useDocumentRewrite();
|
const documentRewrite = useDocumentRewrite();
|
||||||
|
const imageCapabilities = useResearchCapabilities();
|
||||||
const rewritePresentation = useDocumentRewritePresentation();
|
const rewritePresentation = useDocumentRewritePresentation();
|
||||||
const hydratedRewriteScopeRef = useRef<string | null>(null);
|
const hydratedRewriteScopeRef = useRef<string | null>(null);
|
||||||
const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({
|
const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({
|
||||||
presentationId: null,
|
presentationId: null,
|
||||||
plan: "",
|
plan: "",
|
||||||
});
|
});
|
||||||
|
const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({
|
||||||
|
presentationId: null,
|
||||||
|
thinking: "",
|
||||||
|
});
|
||||||
const documentRewriteActive = documentRewrite.isActive;
|
const documentRewriteActive = documentRewrite.isActive;
|
||||||
|
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||||
|
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||||
const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits;
|
const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits;
|
||||||
const displayContent = documentRewriteActive
|
const displayContent = documentRewriteActive
|
||||||
? documentRewrite.run.draft
|
? documentRewrite.run.draft
|
||||||
@@ -405,6 +423,29 @@ export function ArtifactFileDetail({
|
|||||||
writtenCharCount: event.charCount,
|
writtenCharCount: event.charCount,
|
||||||
});
|
});
|
||||||
break;
|
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":
|
case "validation_started":
|
||||||
rewritePresentation.update(presentationId, { stage: "validation" });
|
rewritePresentation.update(presentationId, { stage: "validation" });
|
||||||
break;
|
break;
|
||||||
@@ -463,6 +504,8 @@ export function ArtifactFileDetail({
|
|||||||
instruction: historical.request.instruction,
|
instruction: historical.request.instruction,
|
||||||
style: historical.request.style,
|
style: historical.request.style,
|
||||||
modelName: historical.request.modelName,
|
modelName: historical.request.modelName,
|
||||||
|
generateImages: historical.request.generateImages,
|
||||||
|
maxGeneratedImages: historical.request.maxGeneratedImages,
|
||||||
originalContent: historical.request.originalContent,
|
originalContent: historical.request.originalContent,
|
||||||
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
|
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
|
||||||
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
|
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
|
||||||
@@ -497,6 +540,8 @@ export function ArtifactFileDetail({
|
|||||||
instruction: latest.request.instruction,
|
instruction: latest.request.instruction,
|
||||||
style: latest.request.style,
|
style: latest.request.style,
|
||||||
modelName: latest.request.modelName,
|
modelName: latest.request.modelName,
|
||||||
|
generateImages: latest.request.generateImages,
|
||||||
|
maxGeneratedImages: latest.request.maxGeneratedImages,
|
||||||
originalContent,
|
originalContent,
|
||||||
rewrittenContent,
|
rewrittenContent,
|
||||||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||||
@@ -537,6 +582,8 @@ export function ArtifactFileDetail({
|
|||||||
instruction: latest.request.instruction,
|
instruction: latest.request.instruction,
|
||||||
style: latest.request.style,
|
style: latest.request.style,
|
||||||
modelName: latest.request.modelName ?? undefined,
|
modelName: latest.request.modelName ?? undefined,
|
||||||
|
generateImages: latest.request.generateImages,
|
||||||
|
maxGeneratedImages: latest.request.maxGeneratedImages,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
jobId: latest.id,
|
jobId: latest.id,
|
||||||
@@ -605,6 +652,19 @@ export function ArtifactFileDetail({
|
|||||||
rewritePresentation,
|
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(() => {
|
useEffect(() => {
|
||||||
const handleRewriteUndo = (event: Event) => {
|
const handleRewriteUndo = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<{
|
const detail = (event as CustomEvent<{
|
||||||
@@ -851,6 +911,8 @@ export function ArtifactFileDetail({
|
|||||||
instruction: string;
|
instruction: string;
|
||||||
style: "formal_analysis" | "professional" | "concise" | "readable";
|
style: "formal_analysis" | "professional" | "concise" | "readable";
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
|
generateImages: boolean;
|
||||||
|
maxGeneratedImages: number;
|
||||||
}) => {
|
}) => {
|
||||||
if (!canUseFullDocumentRewrite) {
|
if (!canUseFullDocumentRewrite) {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -872,6 +934,8 @@ export function ArtifactFileDetail({
|
|||||||
instruction: input.instruction,
|
instruction: input.instruction,
|
||||||
style: input.style,
|
style: input.style,
|
||||||
modelName: input.modelName,
|
modelName: input.modelName,
|
||||||
|
generateImages: input.generateImages,
|
||||||
|
maxGeneratedImages: input.maxGeneratedImages,
|
||||||
originalContent: draftContent,
|
originalContent: draftContent,
|
||||||
});
|
});
|
||||||
setDocumentRewritePresentationId(presentationId ?? null);
|
setDocumentRewritePresentationId(presentationId ?? null);
|
||||||
@@ -882,6 +946,8 @@ export function ArtifactFileDetail({
|
|||||||
instruction: input.instruction,
|
instruction: input.instruction,
|
||||||
style: input.style,
|
style: input.style,
|
||||||
modelName: input.modelName ?? undefined,
|
modelName: input.modelName ?? undefined,
|
||||||
|
generateImages: input.generateImages,
|
||||||
|
maxGeneratedImages: input.maxGeneratedImages,
|
||||||
expectedHash,
|
expectedHash,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -921,6 +987,29 @@ export function ArtifactFileDetail({
|
|||||||
writtenCharCount: event.charCount,
|
writtenCharCount: event.charCount,
|
||||||
});
|
});
|
||||||
break;
|
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":
|
case "validation_started":
|
||||||
rewritePresentation.update(presentationId, { stage: "validation" });
|
rewritePresentation.update(presentationId, { stage: "validation" });
|
||||||
break;
|
break;
|
||||||
@@ -1217,6 +1306,7 @@ export function ArtifactFileDetail({
|
|||||||
onClick={handleSaveArtifact}
|
onClick={handleSaveArtifact}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{toolbarActions}
|
||||||
{canEditArtifact && (
|
{canEditArtifact && (
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
|
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
|
||||||
@@ -1394,9 +1484,10 @@ export function ArtifactFileDetail({
|
|||||||
<ArtifactFilePreview
|
<ArtifactFilePreview
|
||||||
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
|
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
|
||||||
language={language ?? "text"}
|
language={language ?? "text"}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
publicShareCode={publicShareCode}
|
publicShareCode={publicShareCode}
|
||||||
animated={showPreviewSwitchPulse}
|
researchSessionId={researchSessionId}
|
||||||
|
animated={showPreviewSwitchPulse}
|
||||||
activeCursor={isApplyingEdit}
|
activeCursor={isApplyingEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1454,25 +1545,40 @@ export function ArtifactFileDetail({
|
|||||||
onOpenChange={setDocumentRewriteDialogOpen}
|
onOpenChange={setDocumentRewriteDialogOpen}
|
||||||
fileName={getFileName(filepath)}
|
fileName={getFileName(filepath)}
|
||||||
charCount={(displayContent ?? "").replace(/\s/g, "").length}
|
charCount={(displayContent ?? "").replace(/\s/g, "").length}
|
||||||
|
imageGenerationAvailable={imageGenerationAvailable}
|
||||||
|
maxGeneratedImages={maxGeneratedImages}
|
||||||
onStart={handleStartDocumentRewrite}
|
onStart={handleStartDocumentRewrite}
|
||||||
/>
|
/>
|
||||||
</Artifact>
|
</Artifact>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SandboxPreviewImage({
|
function SandboxPreviewImage({
|
||||||
src,
|
src,
|
||||||
alt,
|
alt,
|
||||||
threadId,
|
threadId,
|
||||||
publicShareCode,
|
publicShareCode,
|
||||||
...props
|
researchSessionId,
|
||||||
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
...props
|
||||||
threadId: string;
|
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||||
publicShareCode?: string;
|
threadId: string;
|
||||||
}) {
|
publicShareCode?: string;
|
||||||
if (!src || typeof src !== "string") {
|
researchSessionId?: string;
|
||||||
return null;
|
}) {
|
||||||
}
|
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;
|
let url = src;
|
||||||
if (src.startsWith("/mnt/")) {
|
if (src.startsWith("/mnt/")) {
|
||||||
@@ -1496,21 +1602,108 @@ function SandboxPreviewImage({
|
|||||||
src={url}
|
src={url}
|
||||||
alt={alt}
|
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({
|
export function ArtifactFilePreview({
|
||||||
content,
|
content,
|
||||||
language,
|
language,
|
||||||
threadId,
|
threadId,
|
||||||
publicShareCode,
|
publicShareCode,
|
||||||
animated = false,
|
researchSessionId,
|
||||||
|
animated = false,
|
||||||
activeCursor = false,
|
activeCursor = false,
|
||||||
}: {
|
}: {
|
||||||
content: string;
|
content: string;
|
||||||
language: string;
|
language: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
publicShareCode?: string;
|
publicShareCode?: string;
|
||||||
|
researchSessionId?: string;
|
||||||
animated?: boolean;
|
animated?: boolean;
|
||||||
activeCursor?: boolean;
|
activeCursor?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -1574,7 +1767,7 @@ export function ArtifactFilePreview({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [content]);
|
}, [content, publicShareCode, researchSessionId, threadId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (language !== "html") {
|
if (language !== "html") {
|
||||||
@@ -1639,16 +1832,26 @@ export function ArtifactFilePreview({
|
|||||||
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
className="size-full"
|
className="size-full"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
components={{
|
// 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,
|
...markdownComponents,
|
||||||
img: (props) => (
|
img: (props) => (
|
||||||
<SandboxPreviewImage
|
<SandboxPreviewImage
|
||||||
{...props}
|
{...props}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
publicShareCode={publicShareCode}
|
publicShareCode={publicShareCode}
|
||||||
|
researchSessionId={researchSessionId}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SparklesIcon } from "lucide-react";
|
import { ImagePlusIcon, SparklesIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||||
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
|
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
|
||||||
|
|
||||||
@@ -51,16 +52,22 @@ export function DocumentRewriteDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
fileName,
|
fileName,
|
||||||
charCount,
|
charCount,
|
||||||
|
imageGenerationAvailable = false,
|
||||||
|
maxGeneratedImages = 4,
|
||||||
onStart,
|
onStart,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
charCount: number;
|
charCount: number;
|
||||||
|
imageGenerationAvailable?: boolean;
|
||||||
|
maxGeneratedImages?: number;
|
||||||
onStart: (input: {
|
onStart: (input: {
|
||||||
instruction: string;
|
instruction: string;
|
||||||
style: DocumentRewriteStyle;
|
style: DocumentRewriteStyle;
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
|
generateImages: boolean;
|
||||||
|
maxGeneratedImages: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
}) {
|
}) {
|
||||||
const [instruction, setInstruction] = useState(
|
const [instruction, setInstruction] = useState(
|
||||||
@@ -68,11 +75,15 @@ export function DocumentRewriteDialog({
|
|||||||
);
|
);
|
||||||
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
|
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
|
||||||
const [modelName, setModelName] = useState<string | null>(null);
|
const [modelName, setModelName] = useState<string | null>(null);
|
||||||
|
const [generateImages, setGenerateImages] = useState(false);
|
||||||
|
const [imageCount, setImageCount] = useState("2");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setStyle("formal_analysis");
|
setStyle("formal_analysis");
|
||||||
setModelName(null);
|
setModelName(null);
|
||||||
|
setGenerateImages(false);
|
||||||
|
setImageCount("2");
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
|
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
|
||||||
@@ -136,6 +147,48 @@ export function DocumentRewriteDialog({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
取消
|
取消
|
||||||
@@ -144,7 +197,16 @@ export function DocumentRewriteDialog({
|
|||||||
disabled={!canStart}
|
disabled={!canStart}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canStart) return;
|
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 />
|
<SparklesIcon />
|
||||||
|
|||||||
@@ -2,15 +2,22 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CheckCircle2Icon,
|
CheckCircle2Icon,
|
||||||
|
ChevronDownIcon,
|
||||||
FilePenLineIcon,
|
FilePenLineIcon,
|
||||||
|
LightbulbIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
Undo2Icon,
|
Undo2Icon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import {
|
import {
|
||||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||||
@@ -26,6 +33,7 @@ const STEPS = [
|
|||||||
{ key: "snapshot", label: "固定原文版本" },
|
{ key: "snapshot", label: "固定原文版本" },
|
||||||
{ key: "requirements", label: "理解改写要求" },
|
{ key: "requirements", label: "理解改写要求" },
|
||||||
{ key: "writing", label: "流式重写 Markdown" },
|
{ key: "writing", label: "流式重写 Markdown" },
|
||||||
|
{ key: "illustrations", label: "生成文档配图" },
|
||||||
{ key: "validation", label: "校验文档" },
|
{ key: "validation", label: "校验文档" },
|
||||||
{ key: "comparison", label: "对比原文并生成说明" },
|
{ key: "comparison", label: "对比原文并生成说明" },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -38,15 +46,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
|
|||||||
return 1;
|
return 1;
|
||||||
case "writing":
|
case "writing":
|
||||||
return 2;
|
return 2;
|
||||||
case "validation":
|
case "illustrating":
|
||||||
return 3;
|
return 3;
|
||||||
|
case "validation":
|
||||||
|
return 4;
|
||||||
case "committing":
|
case "committing":
|
||||||
case "completed":
|
case "completed":
|
||||||
return 5;
|
return 6;
|
||||||
case "conflict":
|
case "conflict":
|
||||||
case "error":
|
case "error":
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
return 4;
|
return 5;
|
||||||
default:
|
default:
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@@ -56,6 +66,42 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
|
|||||||
return ["completed", "conflict", "error", "cancelled"].includes(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({
|
export function DocumentRewriteTimeline({
|
||||||
item,
|
item,
|
||||||
}: {
|
}: {
|
||||||
@@ -64,6 +110,9 @@ export function DocumentRewriteTimeline({
|
|||||||
const current = stepPosition(item.stage);
|
const current = stepPosition(item.stage);
|
||||||
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
|
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
|
||||||
const completed = item.stage === "completed";
|
const completed = item.stage === "completed";
|
||||||
|
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
|
||||||
|
? "requirements"
|
||||||
|
: "writing";
|
||||||
const presentation = useDocumentRewritePresentation();
|
const presentation = useDocumentRewritePresentation();
|
||||||
const [undoing, setUndoing] = useState(false);
|
const [undoing, setUndoing] = useState(false);
|
||||||
const [diffOpen, setDiffOpen] = useState(false);
|
const [diffOpen, setDiffOpen] = useState(false);
|
||||||
@@ -148,7 +197,13 @@ export function DocumentRewriteTimeline({
|
|||||||
<div className="space-y-3 px-5 py-4">
|
<div className="space-y-3 px-5 py-4">
|
||||||
{STEPS.map((step, index) => {
|
{STEPS.map((step, index) => {
|
||||||
const active = !isTerminal(item.stage) && index === current;
|
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 (
|
return (
|
||||||
<div className="flex items-start gap-3" key={step.key}>
|
<div className="flex items-start gap-3" key={step.key}>
|
||||||
<div
|
<div
|
||||||
@@ -200,6 +255,30 @@ export function DocumentRewriteTimeline({
|
|||||||
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
|
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
|
||||||
</p>
|
</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 && (
|
{step.key === "validation" && item.warnings.length > 0 && (
|
||||||
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
|
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
|
||||||
{item.warnings.join(";")}
|
{item.warnings.join(";")}
|
||||||
@@ -228,6 +307,11 @@ export function DocumentRewriteTimeline({
|
|||||||
本次优化:{item.summary.requestedOptimizations.join("、")}。
|
本次优化:{item.summary.requestedOptimizations.join("、")}。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{item.summary.requestedImageCount ? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{item.summary.headingChanges && (
|
{item.summary.headingChanges && (
|
||||||
item.summary.headingChanges.reordered ||
|
item.summary.headingChanges.reordered ||
|
||||||
item.summary.headingChanges.added.length > 0 ||
|
item.summary.headingChanges.added.length > 0 ||
|
||||||
|
|||||||
@@ -523,7 +523,13 @@ function DeepResearchProgressStep({
|
|||||||
? args.label
|
? args.label
|
||||||
: "正在撰写研究报告";
|
: "正在撰写研究报告";
|
||||||
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
|
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
|
||||||
|
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
|
||||||
const active = args.status !== "complete";
|
const active = args.status !== "complete";
|
||||||
|
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active && thinking) setThinkingOpen(true);
|
||||||
|
}, [active, thinking]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
@@ -546,6 +552,32 @@ function DeepResearchProgressStep({
|
|||||||
{detail}
|
{detail}
|
||||||
</div>
|
</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>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ export type DocumentRewritePresentation = {
|
|||||||
instruction: string;
|
instruction: string;
|
||||||
style: string;
|
style: string;
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
|
generateImages: boolean;
|
||||||
|
maxGeneratedImages: number;
|
||||||
|
generatedImageCount: number;
|
||||||
|
illustrationTarget: string;
|
||||||
stage: DocumentRewriteStage;
|
stage: DocumentRewriteStage;
|
||||||
originalCharCount: number;
|
originalCharCount: number;
|
||||||
writtenCharCount: number;
|
writtenCharCount: number;
|
||||||
/** Short model-generated plan, streamed while it understands the request. */
|
/** Short model-generated plan, streamed while it understands the request. */
|
||||||
requirementsPlan: string;
|
requirementsPlan: string;
|
||||||
|
/** Ephemeral thought trace. Never included in the Markdown draft or persisted job state. */
|
||||||
|
thinkingText: string;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
summary: DocumentRewriteSummary | null;
|
summary: DocumentRewriteSummary | null;
|
||||||
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
|
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
|
||||||
@@ -43,9 +49,14 @@ type CreatePresentationInput = Omit<
|
|||||||
DocumentRewritePresentation,
|
DocumentRewritePresentation,
|
||||||
| "id"
|
| "id"
|
||||||
| "stage"
|
| "stage"
|
||||||
|
| "generateImages"
|
||||||
|
| "maxGeneratedImages"
|
||||||
|
| "generatedImageCount"
|
||||||
|
| "illustrationTarget"
|
||||||
| "originalCharCount"
|
| "originalCharCount"
|
||||||
| "writtenCharCount"
|
| "writtenCharCount"
|
||||||
| "requirementsPlan"
|
| "requirementsPlan"
|
||||||
|
| "thinkingText"
|
||||||
| "warnings"
|
| "warnings"
|
||||||
| "summary"
|
| "summary"
|
||||||
| "originalContent"
|
| "originalContent"
|
||||||
@@ -60,6 +71,8 @@ type CreatePresentationInput = Omit<
|
|||||||
originalContent?: string;
|
originalContent?: string;
|
||||||
rewrittenContent?: string | null;
|
rewrittenContent?: string | null;
|
||||||
requirementsPlan?: string;
|
requirementsPlan?: string;
|
||||||
|
generateImages?: boolean;
|
||||||
|
maxGeneratedImages?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DocumentRewritePresentationContextValue = {
|
type DocumentRewritePresentationContextValue = {
|
||||||
@@ -94,9 +107,14 @@ export function DocumentRewritePresentationProvider({
|
|||||||
...input,
|
...input,
|
||||||
id,
|
id,
|
||||||
stage: "snapshot",
|
stage: "snapshot",
|
||||||
|
generateImages: input.generateImages === true,
|
||||||
|
maxGeneratedImages: Math.min(Math.max(input.maxGeneratedImages ?? 2, 1), 4),
|
||||||
|
generatedImageCount: 0,
|
||||||
|
illustrationTarget: "",
|
||||||
originalCharCount: 0,
|
originalCharCount: 0,
|
||||||
writtenCharCount: 0,
|
writtenCharCount: 0,
|
||||||
requirementsPlan: input.requirementsPlan ?? "",
|
requirementsPlan: input.requirementsPlan ?? "",
|
||||||
|
thinkingText: "",
|
||||||
warnings: [],
|
warnings: [],
|
||||||
summary: null,
|
summary: null,
|
||||||
originalContent: input.originalContent ?? "",
|
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 TASK_DETAIL_PATH = "/task/{taskId}";
|
||||||
const DICTIONARY_TYPES = {
|
const DICTIONARY_TYPES = {
|
||||||
priority: "task_priority",
|
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 { cn } from "@/lib/utils";
|
||||||
|
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
|
||||||
import {
|
import {
|
||||||
dictionaryText,
|
dictionaryText,
|
||||||
type EightBfDictionaryOption,
|
type EightBfDictionaryOption,
|
||||||
@@ -16,6 +15,8 @@ interface EightBfTaskCardProps {
|
|||||||
};
|
};
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
/** 第二步左栏使用,与“智能体配置”同款图标收起整列。 */
|
||||||
|
onCollapse?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EightBfTaskCard({
|
export function EightBfTaskCard({
|
||||||
@@ -23,36 +24,74 @@ export function EightBfTaskCard({
|
|||||||
dictionaries,
|
dictionaries,
|
||||||
loading = false,
|
loading = false,
|
||||||
error,
|
error,
|
||||||
|
onCollapse,
|
||||||
}: EightBfTaskCardProps) {
|
}: EightBfTaskCardProps) {
|
||||||
const priority = dictionaryText(dictionaries.priority, task?.taskPriority);
|
const priority = dictionaryText(dictionaries.priority, task?.taskPriority);
|
||||||
const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias");
|
const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias");
|
||||||
const status = dictionaryText(dictionaries.status, task?.taskStatus);
|
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 (
|
return (
|
||||||
<section className="eightbf-roundtable__task-card" aria-labelledby="eightbf-task-card-title">
|
<section
|
||||||
<div className="eightbf-roundtable__task-card-heading">
|
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"
|
||||||
<span className="eightbf-roundtable__task-card-icon" aria-hidden="true">
|
aria-labelledby="eightbf-task-card-title"
|
||||||
<Clipboard className="h-3.5 w-3.5" strokeWidth={2.5} />
|
>
|
||||||
</span>
|
<div className="position-roundtable-task-card__content relative z-10">
|
||||||
<h2 id="eightbf-task-card-title">当前任务</h2>
|
{onCollapse && (
|
||||||
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-label="正在加载任务" />}
|
<button
|
||||||
</div>
|
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 && (
|
{tags.length > 0 && (
|
||||||
<div className="eightbf-roundtable__task-card-tags" aria-label="任务标签">
|
<div
|
||||||
{tags.map((tag) => <span key={tag}>{tag}</span>)}
|
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>
|
</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 ? "正在读取任务详情…" : "暂无任务描述")}
|
{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}
|
||||||
</p>
|
</p>
|
||||||
{error && <p className="eightbf-roundtable__task-card-error" role="status">{error}</p>}
|
{error && <p className="eightbf-roundtable__task-card-error" role="status">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
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 { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||||
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
|
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
|
||||||
|
import {
|
||||||
|
EightBfStep1Panel,
|
||||||
|
EightBfStep1Sidebar,
|
||||||
|
EightBfStep2SidebarHeader,
|
||||||
|
} from "@/eightbf-roundtable/components/EightBfStep1Workspace";
|
||||||
import {
|
import {
|
||||||
buildEightBfTaskContext,
|
buildEightBfTaskContext,
|
||||||
|
createEightBfFallbackTask,
|
||||||
fetchEightBfTask,
|
fetchEightBfTask,
|
||||||
fetchEightBfTaskDictionaries,
|
fetchEightBfTaskDictionaries,
|
||||||
normalizeEightBfTask,
|
normalizeEightBfTask,
|
||||||
@@ -100,7 +106,9 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTaskError(error instanceof Error ? error.message : "任务详情加载失败");
|
console.warn("[8bf] 任务详情请求失败,使用页面示例任务", error);
|
||||||
|
setTask(createEightBfFallbackTask(taskId));
|
||||||
|
setTaskError(null);
|
||||||
})
|
})
|
||||||
.finally(markRequestFinished);
|
.finally(markRequestFinished);
|
||||||
void fetchEightBfTaskDictionaries()
|
void fetchEightBfTaskDictionaries()
|
||||||
@@ -169,18 +177,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
}
|
}
|
||||||
}, [canConfigureChain, chains, selectedChainId]);
|
}, [canConfigureChain, chains, selectedChainId]);
|
||||||
|
|
||||||
|
const openChainSettings = useCallback(() => {
|
||||||
|
if (canConfigureChain) setIsSettingsOpen(true);
|
||||||
|
}, [canConfigureChain]);
|
||||||
|
|
||||||
const toolbarSlot = (
|
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 && (
|
{canConfigureChain && isSettingsOpen && (
|
||||||
<div className="eightbf-roundtable__settings-mask" role="presentation">
|
<div className="eightbf-roundtable__settings-mask" role="presentation">
|
||||||
<section
|
<section
|
||||||
@@ -248,6 +250,53 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
toolbarSlot={toolbarSlot}
|
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={
|
entryRailBusinessChainSlot={
|
||||||
<PositionBusinessChainFlow
|
<PositionBusinessChainFlow
|
||||||
chain={configuredChain}
|
chain={configuredChain}
|
||||||
|
|||||||
@@ -23,104 +23,265 @@
|
|||||||
|
|
||||||
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
|
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
|
||||||
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
|
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
|
||||||
height: 500px;
|
height: 430px;
|
||||||
min-height: 460px;
|
min-height: 430px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
|
/* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
|
||||||
.eightbf-roundtable .eightbf-roundtable__task-card {
|
.eightbf-roundtable .eightbf-roundtable__task-card {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border: 1px solid #d9e5ef;
|
min-width: 0;
|
||||||
border-radius: 16px;
|
|
||||||
background: linear-gradient(145deg, #ffffff 0%, #f8fbff 100%);
|
|
||||||
padding: 14px;
|
|
||||||
box-shadow: 0 4px 20px rgb(15 23 42 / 3%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eightbf-roundtable .eightbf-roundtable__task-card-heading {
|
.eightbf-roundtable .eightbf-roundtable__task-card-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
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 {
|
.eightbf-roundtable .eightbf-roundtable__task-card-tags {
|
||||||
display: flex;
|
margin-top: 12px;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
|
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
|
||||||
border: 1px solid #c7e6df;
|
border-radius: 2px;
|
||||||
border-radius: 999px;
|
|
||||||
background: #ecfdf5;
|
|
||||||
color: #0f766e;
|
|
||||||
padding: 2px 7px;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eightbf-roundtable .eightbf-roundtable__task-card-description {
|
.eightbf-roundtable .eightbf-roundtable__task-card-description {
|
||||||
display: -webkit-box;
|
margin: 12px 0 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; }
|
.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 {
|
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
|
||||||
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%);
|
* 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,
|
.eightbf-roundtable .eightbf-roundtable__header-brand-icon {
|
||||||
.dark .eightbf-roundtable .eightbf-roundtable__task-card-title { color: #f1f7ff; }
|
display: inline-flex;
|
||||||
.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child { color: #65d6c6; }
|
width: 42px;
|
||||||
.dark .eightbf-roundtable .eightbf-roundtable__task-card-tags span { border-color: rgb(101 214 198 / 40%); background: rgb(15 118 110 / 24%); color: #9ef0df; }
|
height: 42px;
|
||||||
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description { color: #c3d4e8; }
|
flex: 0 0 auto;
|
||||||
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
|
align-items: center;
|
||||||
.dark .eightbf-roundtable .eightbf-roundtable__task-card-error { color: #fca5a5; }
|
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; }
|
.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;
|
instruction: string;
|
||||||
style?: DocumentRewriteStyle;
|
style?: DocumentRewriteStyle;
|
||||||
modelName?: string;
|
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;
|
expectedHash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +144,8 @@ export interface DocumentRewriteSummary {
|
|||||||
};
|
};
|
||||||
requestedOptimizations?: string[];
|
requestedOptimizations?: string[];
|
||||||
validationWarnings?: string[];
|
validationWarnings?: string[];
|
||||||
|
generatedImageCount?: number;
|
||||||
|
requestedImageCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DocumentRewriteStreamEvent =
|
export type DocumentRewriteStreamEvent =
|
||||||
@@ -156,6 +162,15 @@ export type DocumentRewriteStreamEvent =
|
|||||||
| { type: "rewrite_delta"; delta: string; charCount: number }
|
| { type: "rewrite_delta"; delta: string; charCount: number }
|
||||||
/** Durable full-draft recovery point used after an SSE reconnect. */
|
/** Durable full-draft recovery point used after an SSE reconnect. */
|
||||||
| { type: "rewrite_checkpoint"; content: string; charCount: number }
|
| { 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_started" }
|
||||||
| { type: "validation_completed"; passed: boolean; warnings: string[] }
|
| { type: "validation_completed"; passed: boolean; warnings: string[] }
|
||||||
| { type: "comparison_ready"; summary: DocumentRewriteSummary }
|
| { type: "comparison_ready"; summary: DocumentRewriteSummary }
|
||||||
@@ -177,6 +192,8 @@ export interface DurableDocumentRewriteJob {
|
|||||||
instruction: string;
|
instruction: string;
|
||||||
style: DocumentRewriteStyle;
|
style: DocumentRewriteStyle;
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
|
generateImages: boolean;
|
||||||
|
maxGeneratedImages: number;
|
||||||
originalContent: string;
|
originalContent: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -229,6 +246,30 @@ async function readDocumentRewriteEvent(
|
|||||||
content: String(parsed.content ?? ""),
|
content: String(parsed.content ?? ""),
|
||||||
charCount: Number(parsed.charCount ?? 0),
|
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":
|
case "validation_started":
|
||||||
return { type: "validation_started" };
|
return { type: "validation_started" };
|
||||||
case "validation_completed":
|
case "validation_completed":
|
||||||
@@ -362,6 +403,8 @@ export async function listDurableDocumentRewrites(
|
|||||||
instruction: String(request.instruction ?? ""),
|
instruction: String(request.instruction ?? ""),
|
||||||
style: (request.style ?? "professional") as DocumentRewriteStyle,
|
style: (request.style ?? "professional") as DocumentRewriteStyle,
|
||||||
modelName: typeof request.modelName === "string" ? request.modelName : null,
|
modelName: typeof request.modelName === "string" ? request.modelName : null,
|
||||||
|
generateImages: request.generateImages === true,
|
||||||
|
maxGeneratedImages: Number(request.maxGeneratedImages ?? 2),
|
||||||
originalContent: String(request.originalContent ?? ""),
|
originalContent: String(request.originalContent ?? ""),
|
||||||
},
|
},
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
cancelDurableDocumentRewrite,
|
cancelDurableDocumentRewrite,
|
||||||
streamDocumentRewrite,
|
streamDocumentRewrite,
|
||||||
} from "../api/rewrite";
|
} from "../api/rewrite";
|
||||||
|
import { InlineThinkTagFilter, stripInlineThinkTags } from "@/core/streamdown/inline-think";
|
||||||
|
|
||||||
export type DocumentRewriteStage =
|
export type DocumentRewriteStage =
|
||||||
| "idle"
|
| "idle"
|
||||||
| "snapshot"
|
| "snapshot"
|
||||||
| "requirements"
|
| "requirements"
|
||||||
| "writing"
|
| "writing"
|
||||||
|
| "illustrating"
|
||||||
| "validation"
|
| "validation"
|
||||||
| "committing"
|
| "committing"
|
||||||
| "completed"
|
| "completed"
|
||||||
@@ -27,6 +29,8 @@ export interface DocumentRewriteRun {
|
|||||||
draft: string;
|
draft: string;
|
||||||
/** Live, compact execution plan produced before Markdown generation starts. */
|
/** Live, compact execution plan produced before Markdown generation starts. */
|
||||||
requirementsPlan: string;
|
requirementsPlan: string;
|
||||||
|
/** Ephemeral model reasoning, deliberately kept out of the Markdown draft. */
|
||||||
|
thinkingText: string;
|
||||||
originalCharCount: number;
|
originalCharCount: number;
|
||||||
writtenCharCount: number;
|
writtenCharCount: number;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -41,6 +45,7 @@ const INITIAL_RUN: DocumentRewriteRun = {
|
|||||||
stage: "idle",
|
stage: "idle",
|
||||||
draft: "",
|
draft: "",
|
||||||
requirementsPlan: "",
|
requirementsPlan: "",
|
||||||
|
thinkingText: "",
|
||||||
originalCharCount: 0,
|
originalCharCount: 0,
|
||||||
writtenCharCount: 0,
|
writtenCharCount: 0,
|
||||||
fileName: "",
|
fileName: "",
|
||||||
@@ -67,6 +72,10 @@ export function useDocumentRewrite() {
|
|||||||
const frameRef = useRef<number | null>(null);
|
const frameRef = useRef<number | null>(null);
|
||||||
const pendingRequirementsDeltaRef = useRef("");
|
const pendingRequirementsDeltaRef = useRef("");
|
||||||
const requirementsFrameRef = useRef<number | null>(null);
|
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(() => {
|
const flushDelta = useCallback(() => {
|
||||||
if (frameRef.current !== null) {
|
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(() => {
|
const flushRequirements = useCallback(() => {
|
||||||
if (requirementsFrameRef.current !== null) {
|
if (requirementsFrameRef.current !== null) {
|
||||||
cancelAnimationFrame(requirementsFrameRef.current);
|
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;
|
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;
|
if (requirementsFrameRef.current !== null) return;
|
||||||
requirementsFrameRef.current = requestAnimationFrame(() => {
|
requirementsFrameRef.current = requestAnimationFrame(() => {
|
||||||
requirementsFrameRef.current = null;
|
requirementsFrameRef.current = null;
|
||||||
@@ -141,7 +155,30 @@ export function useDocumentRewrite() {
|
|||||||
requirementsPlan: previous.requirementsPlan + pending,
|
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(() => {
|
const cancel = useCallback(() => {
|
||||||
controllerRef.current?.abort();
|
controllerRef.current?.abort();
|
||||||
@@ -155,12 +192,13 @@ export function useDocumentRewrite() {
|
|||||||
}
|
}
|
||||||
flushDelta();
|
flushDelta();
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
|
flushThinking();
|
||||||
setRun((previous) =>
|
setRun((previous) =>
|
||||||
previous.stage === "completed"
|
previous.stage === "completed"
|
||||||
? previous
|
? previous
|
||||||
: { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" },
|
: { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" },
|
||||||
);
|
);
|
||||||
}, [flushDelta, flushRequirements]);
|
}, [flushDelta, flushRequirements, flushThinking]);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
controllerRef.current?.abort();
|
controllerRef.current?.abort();
|
||||||
@@ -169,6 +207,9 @@ export function useDocumentRewrite() {
|
|||||||
pendingDeltaRef.current = "";
|
pendingDeltaRef.current = "";
|
||||||
pendingWrittenCharCountRef.current = null;
|
pendingWrittenCharCountRef.current = null;
|
||||||
pendingRequirementsDeltaRef.current = "";
|
pendingRequirementsDeltaRef.current = "";
|
||||||
|
pendingThinkingRef.current = "";
|
||||||
|
draftThinkFilterRef.current = new InlineThinkTagFilter();
|
||||||
|
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
|
||||||
if (frameRef.current !== null) {
|
if (frameRef.current !== null) {
|
||||||
cancelAnimationFrame(frameRef.current);
|
cancelAnimationFrame(frameRef.current);
|
||||||
frameRef.current = null;
|
frameRef.current = null;
|
||||||
@@ -177,6 +218,10 @@ export function useDocumentRewrite() {
|
|||||||
cancelAnimationFrame(requirementsFrameRef.current);
|
cancelAnimationFrame(requirementsFrameRef.current);
|
||||||
requirementsFrameRef.current = null;
|
requirementsFrameRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (thinkingFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(thinkingFrameRef.current);
|
||||||
|
thinkingFrameRef.current = null;
|
||||||
|
}
|
||||||
setRun(INITIAL_RUN);
|
setRun(INITIAL_RUN);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -202,6 +247,9 @@ export function useDocumentRewrite() {
|
|||||||
pendingDeltaRef.current = "";
|
pendingDeltaRef.current = "";
|
||||||
pendingWrittenCharCountRef.current = null;
|
pendingWrittenCharCountRef.current = null;
|
||||||
pendingRequirementsDeltaRef.current = "";
|
pendingRequirementsDeltaRef.current = "";
|
||||||
|
pendingThinkingRef.current = "";
|
||||||
|
draftThinkFilterRef.current = new InlineThinkTagFilter();
|
||||||
|
requirementsThinkFilterRef.current = new InlineThinkTagFilter();
|
||||||
setRun({
|
setRun({
|
||||||
...INITIAL_RUN,
|
...INITIAL_RUN,
|
||||||
stage: "snapshot",
|
stage: "snapshot",
|
||||||
@@ -236,28 +284,36 @@ export function useDocumentRewrite() {
|
|||||||
case "requirements_delta":
|
case "requirements_delta":
|
||||||
enqueueRequirementsDelta(event.delta);
|
enqueueRequirementsDelta(event.delta);
|
||||||
break;
|
break;
|
||||||
|
case "thinking":
|
||||||
|
enqueueThinking(event.chunk);
|
||||||
|
break;
|
||||||
case "requirements_checkpoint":
|
case "requirements_checkpoint":
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
setRun((previous) => ({
|
setRun((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
stage: "requirements",
|
stage: "requirements",
|
||||||
requirementsPlan: event.content,
|
requirementsPlan: requirementsThinkFilterRef.current.replace(event.content),
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
case "requirements_completed":
|
case "requirements_completed":
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
setRun((previous) => ({
|
{
|
||||||
...previous,
|
const requirementsPlan =
|
||||||
stage: "requirements",
|
requirementsThinkFilterRef.current.replace(event.content) +
|
||||||
requirementsPlan: event.content,
|
requirementsThinkFilterRef.current.finish();
|
||||||
}));
|
setRun((previous) => ({
|
||||||
|
...previous,
|
||||||
|
stage: "requirements",
|
||||||
|
requirementsPlan,
|
||||||
|
}));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "requirements_resolved":
|
case "requirements_resolved":
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
setRun((previous) => ({
|
setRun((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
stage: "writing",
|
stage: "writing",
|
||||||
...(event.plan ? { requirementsPlan: event.plan } : {}),
|
...(event.plan ? { requirementsPlan: stripInlineThinkTags(event.plan) } : {}),
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
case "rewrite_delta":
|
case "rewrite_delta":
|
||||||
@@ -276,13 +332,20 @@ export function useDocumentRewrite() {
|
|||||||
setRun((previous) => ({
|
setRun((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
stage: "writing",
|
stage: "writing",
|
||||||
draft: event.content,
|
draft: draftThinkFilterRef.current.replace(event.content),
|
||||||
writtenCharCount: event.charCount,
|
writtenCharCount: stripInlineThinkTags(event.content).replace(/\s/g, "").length,
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
|
case "illustration_started":
|
||||||
|
case "illustration_generated":
|
||||||
|
case "illustration_completed":
|
||||||
|
flushDelta();
|
||||||
|
setRun((previous) => ({ ...previous, stage: "illustrating" }));
|
||||||
|
break;
|
||||||
case "validation_started":
|
case "validation_started":
|
||||||
flushDelta();
|
flushDelta();
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
|
flushThinking();
|
||||||
setRun((previous) => ({ ...previous, stage: "validation" }));
|
setRun((previous) => ({ ...previous, stage: "validation" }));
|
||||||
break;
|
break;
|
||||||
case "validation_completed":
|
case "validation_completed":
|
||||||
@@ -300,17 +363,23 @@ export function useDocumentRewrite() {
|
|||||||
case "committed":
|
case "committed":
|
||||||
flushDelta();
|
flushDelta();
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
setRun((previous) => ({
|
flushThinking();
|
||||||
...previous,
|
{
|
||||||
draft: event.content,
|
const committedContent =
|
||||||
writtenCharCount: event.content.replace(/\s/g, "").length,
|
draftThinkFilterRef.current.replace(event.content) + draftThinkFilterRef.current.finish();
|
||||||
versionId: event.versionId ?? previous.versionId,
|
setRun((previous) => ({
|
||||||
}));
|
...previous,
|
||||||
options?.onCommitted?.(event.content);
|
draft: committedContent,
|
||||||
|
writtenCharCount: committedContent.replace(/\s/g, "").length,
|
||||||
|
versionId: event.versionId ?? previous.versionId,
|
||||||
|
}));
|
||||||
|
options?.onCommitted?.(committedContent);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "done":
|
case "done":
|
||||||
flushDelta();
|
flushDelta();
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
|
flushThinking();
|
||||||
receivedTerminalEvent = true;
|
receivedTerminalEvent = true;
|
||||||
setRun((previous) => ({
|
setRun((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
@@ -321,6 +390,7 @@ export function useDocumentRewrite() {
|
|||||||
case "conflict":
|
case "conflict":
|
||||||
flushDelta();
|
flushDelta();
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
|
flushThinking();
|
||||||
receivedTerminalEvent = true;
|
receivedTerminalEvent = true;
|
||||||
setRun((previous) => ({
|
setRun((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
@@ -331,12 +401,14 @@ export function useDocumentRewrite() {
|
|||||||
case "cancelled":
|
case "cancelled":
|
||||||
flushDelta();
|
flushDelta();
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
|
flushThinking();
|
||||||
receivedTerminalEvent = true;
|
receivedTerminalEvent = true;
|
||||||
setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message }));
|
setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message }));
|
||||||
return;
|
return;
|
||||||
case "error":
|
case "error":
|
||||||
flushDelta();
|
flushDelta();
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
|
flushThinking();
|
||||||
receivedTerminalEvent = true;
|
receivedTerminalEvent = true;
|
||||||
setRun((previous) => ({
|
setRun((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
@@ -351,6 +423,7 @@ export function useDocumentRewrite() {
|
|||||||
if (!controller.signal.aborted && !receivedTerminalEvent) {
|
if (!controller.signal.aborted && !receivedTerminalEvent) {
|
||||||
flushDelta();
|
flushDelta();
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
|
flushThinking();
|
||||||
setRun((previous) => ({
|
setRun((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
stage: "error",
|
stage: "error",
|
||||||
@@ -361,6 +434,7 @@ export function useDocumentRewrite() {
|
|||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
flushDelta();
|
flushDelta();
|
||||||
flushRequirements();
|
flushRequirements();
|
||||||
|
flushThinking();
|
||||||
setRun((previous) => ({
|
setRun((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
stage: "error",
|
stage: "error",
|
||||||
@@ -372,7 +446,7 @@ export function useDocumentRewrite() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements],
|
[enqueueDelta, enqueueRequirementsDelta, enqueueThinking, flushDelta, flushRequirements, flushThinking],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -380,13 +454,14 @@ export function useDocumentRewrite() {
|
|||||||
controllerRef.current?.abort();
|
controllerRef.current?.abort();
|
||||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||||
if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current);
|
if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current);
|
||||||
|
if (thinkingFrameRef.current !== null) cancelAnimationFrame(thinkingFrameRef.current);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
run,
|
run,
|
||||||
isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage),
|
isActive: ["snapshot", "requirements", "writing", "illustrating", "validation", "committing"].includes(run.stage),
|
||||||
start,
|
start,
|
||||||
cancel,
|
cancel,
|
||||||
reset,
|
reset,
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ import {
|
|||||||
advancedOptionsFromConfig,
|
advancedOptionsFromConfig,
|
||||||
buildReportCardConfig,
|
buildReportCardConfig,
|
||||||
ReportConfigCard,
|
ReportConfigCard,
|
||||||
|
ReportOutlineEditor,
|
||||||
} from "./ReportConfigCard";
|
} from "./ReportConfigCard";
|
||||||
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
|
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
|
||||||
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
|
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
|
||||||
@@ -183,6 +184,8 @@ type WorkbenchRun = {
|
|||||||
phase: string;
|
phase: string;
|
||||||
planText: string;
|
planText: string;
|
||||||
planReasoning: string;
|
planReasoning: string;
|
||||||
|
/** Live-only model thinking while report Markdown is being streamed. */
|
||||||
|
reportThinking: string;
|
||||||
report: string;
|
report: string;
|
||||||
reportTargets: Record<string, { content: string; version: number }>;
|
reportTargets: Record<string, { content: string; version: number }>;
|
||||||
reportVersion: number;
|
reportVersion: number;
|
||||||
@@ -201,6 +204,7 @@ const EMPTY_RUN: WorkbenchRun = {
|
|||||||
phase: "initializing",
|
phase: "initializing",
|
||||||
planText: "",
|
planText: "",
|
||||||
planReasoning: "",
|
planReasoning: "",
|
||||||
|
reportThinking: "",
|
||||||
report: "",
|
report: "",
|
||||||
reportTargets: {},
|
reportTargets: {},
|
||||||
reportVersion: 1,
|
reportVersion: 1,
|
||||||
@@ -418,6 +422,10 @@ function DeepResearchWorkbenchInner() {
|
|||||||
presentationId: null,
|
presentationId: null,
|
||||||
plan: "",
|
plan: "",
|
||||||
});
|
});
|
||||||
|
const mirroredReportRewriteThinkingRef = useRef<{ presentationId: string | null; thinking: string }>({
|
||||||
|
presentationId: null,
|
||||||
|
thinking: "",
|
||||||
|
});
|
||||||
|
|
||||||
const sessions = useResearchSessions();
|
const sessions = useResearchSessions();
|
||||||
const sessionQuery = useResearchSession(selectedId);
|
const sessionQuery = useResearchSession(selectedId);
|
||||||
@@ -428,6 +436,10 @@ function DeepResearchWorkbenchInner() {
|
|||||||
const [localSettings] = useLocalSettings();
|
const [localSettings] = useLocalSettings();
|
||||||
const { models, isLoading: modelsLoading } = useModels();
|
const { models, isLoading: modelsLoading } = useModels();
|
||||||
const seenEventKeys = useRef(new Set<string>());
|
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 replayedTraceJobs = useRef(new Set<string>());
|
||||||
const pendingReportEvents = useRef<ProgressEvent[]>([]);
|
const pendingReportEvents = useRef<ProgressEvent[]>([]);
|
||||||
const reportAnimationFrame = useRef<number | null>(null);
|
const reportAnimationFrame = useRef<number | null>(null);
|
||||||
@@ -679,6 +691,16 @@ function DeepResearchWorkbenchInner() {
|
|||||||
});
|
});
|
||||||
}, [reportDocumentRewrite.run.requirementsPlan, rewritePresentation]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleRewriteUndo = (event: Event) => {
|
const handleRewriteUndo = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<{
|
const detail = (event as CustomEvent<{
|
||||||
@@ -854,6 +876,9 @@ function DeepResearchWorkbenchInner() {
|
|||||||
phase: session?.status === "completed" ? "done" : run.phase,
|
phase: session?.status === "completed" ? "done" : run.phase,
|
||||||
materialCount,
|
materialCount,
|
||||||
error: run.error,
|
error: run.error,
|
||||||
|
thinking: run.reportThinking,
|
||||||
|
events: run.events,
|
||||||
|
requestedImageCount: requestedReportImageCount(session?.config),
|
||||||
}),
|
}),
|
||||||
} as Injected);
|
} as Injected);
|
||||||
}
|
}
|
||||||
@@ -870,6 +895,7 @@ function DeepResearchWorkbenchInner() {
|
|||||||
? buildCompletedReportMessage({
|
? buildCompletedReportMessage({
|
||||||
sourceCount: materialCount,
|
sourceCount: materialCount,
|
||||||
summaryText: run.summaryText,
|
summaryText: run.summaryText,
|
||||||
|
imageCount: countEmbeddedReportImages(session?.reportMarkdown),
|
||||||
})
|
})
|
||||||
: buildStreamingSummaryMessage(run.summaryText),
|
: buildStreamingSummaryMessage(run.summaryText),
|
||||||
// MessageList 会将该标记转为既有的 assistant 流式渲染状态,避免摘要只在结束后才显示。
|
// MessageList 会将该标记转为既有的 assistant 流式渲染状态,避免摘要只在结束后才显示。
|
||||||
@@ -950,7 +976,9 @@ function DeepResearchWorkbenchInner() {
|
|||||||
pendingFollowUp,
|
pendingFollowUp,
|
||||||
followUpStream,
|
followUpStream,
|
||||||
run.error,
|
run.error,
|
||||||
|
run.events,
|
||||||
run.phase,
|
run.phase,
|
||||||
|
run.reportThinking,
|
||||||
run.summaryText,
|
run.summaryText,
|
||||||
session,
|
session,
|
||||||
streamJobId,
|
streamJobId,
|
||||||
@@ -980,6 +1008,7 @@ function DeepResearchWorkbenchInner() {
|
|||||||
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
|
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
|
||||||
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
|
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
|
||||||
seenEventKeys.current = new Set();
|
seenEventKeys.current = new Set();
|
||||||
|
liveEventSerial.current = 0;
|
||||||
pendingReportEvents.current = [];
|
pendingReportEvents.current = [];
|
||||||
setRun({ ...EMPTY_RUN, jobId: streamJobId });
|
setRun({ ...EMPTY_RUN, jobId: streamJobId });
|
||||||
// 进行中的写作仍自动打开沙箱;历史完成记录只回放步骤与摘要,等待用户点击
|
// 进行中的写作仍自动打开沙箱;历史完成记录只回放步骤与摘要,等待用户点击
|
||||||
@@ -997,6 +1026,7 @@ function DeepResearchWorkbenchInner() {
|
|||||||
let reportTargets = current.reportTargets;
|
let reportTargets = current.reportTargets;
|
||||||
let planText = current.planText;
|
let planText = current.planText;
|
||||||
let planReasoning = current.planReasoning;
|
let planReasoning = current.planReasoning;
|
||||||
|
let reportThinking = current.reportThinking;
|
||||||
let summaryText = current.summaryText;
|
let summaryText = current.summaryText;
|
||||||
let phase = current.phase;
|
let phase = current.phase;
|
||||||
for (const event of queued) {
|
for (const event of queued) {
|
||||||
@@ -1009,6 +1039,10 @@ function DeepResearchWorkbenchInner() {
|
|||||||
planReasoning += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
planReasoning += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (event.type === "report_thinking") {
|
||||||
|
reportThinking += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (event.type === "summary_delta") {
|
if (event.type === "summary_delta") {
|
||||||
summaryText += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
summaryText += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
||||||
continue;
|
continue;
|
||||||
@@ -1020,6 +1054,7 @@ function DeepResearchWorkbenchInner() {
|
|||||||
if (event.type === "report_reset") {
|
if (event.type === "report_reset") {
|
||||||
report = "";
|
report = "";
|
||||||
reportTargets = {};
|
reportTargets = {};
|
||||||
|
reportThinking = "";
|
||||||
reportVersion = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion + 1;
|
reportVersion = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion + 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1035,7 +1070,7 @@ function DeepResearchWorkbenchInner() {
|
|||||||
reportVersion = Math.max(reportVersion, version);
|
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,
|
streamJobId,
|
||||||
{
|
{
|
||||||
onEvent: (event) => {
|
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;
|
if (seenEventKeys.current.has(key)) return;
|
||||||
seenEventKeys.current.add(key);
|
seenEventKeys.current.add(key);
|
||||||
if (
|
if (
|
||||||
event.type === "plan_delta" ||
|
event.type === "plan_delta" ||
|
||||||
event.type === "plan_reasoning" ||
|
event.type === "plan_reasoning" ||
|
||||||
|
event.type === "report_thinking" ||
|
||||||
event.type === "report_delta" ||
|
event.type === "report_delta" ||
|
||||||
event.type === "report_chunk" ||
|
event.type === "report_chunk" ||
|
||||||
event.type === "report_reset" ||
|
event.type === "report_reset" ||
|
||||||
@@ -1521,6 +1559,10 @@ function DeepResearchWorkbenchInner() {
|
|||||||
case "config":
|
case "config":
|
||||||
return (
|
return (
|
||||||
<ReportConfigCard
|
<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}
|
availableModes={availableModes}
|
||||||
defaultMode={session.mode}
|
defaultMode={session.mode}
|
||||||
defaultOptions={advancedOptionsFromConfig(session.config)}
|
defaultOptions={advancedOptionsFromConfig(session.config)}
|
||||||
@@ -1842,6 +1884,7 @@ type WritingProgressToolCall = {
|
|||||||
label: string;
|
label: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
status: "complete" | "active";
|
status: "complete" | "active";
|
||||||
|
thinking?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1854,12 +1897,23 @@ function buildWritingProgressToolCalls({
|
|||||||
phase,
|
phase,
|
||||||
materialCount,
|
materialCount,
|
||||||
error,
|
error,
|
||||||
|
thinking,
|
||||||
|
events,
|
||||||
|
requestedImageCount,
|
||||||
}: {
|
}: {
|
||||||
phase: string;
|
phase: string;
|
||||||
materialCount: number;
|
materialCount: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
thinking: string;
|
||||||
|
events: ProgressEvent[];
|
||||||
|
requestedImageCount: number;
|
||||||
}): WritingProgressToolCall[] {
|
}): WritingProgressToolCall[] {
|
||||||
const rank = chatPhaseRank(phase);
|
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 = [
|
const stages = [
|
||||||
{
|
{
|
||||||
id: "harvest",
|
id: "harvest",
|
||||||
@@ -1879,6 +1933,18 @@ function buildWritingProgressToolCalls({
|
|||||||
boundary: chatPhaseRank("reviewing"),
|
boundary: chatPhaseRank("reviewing"),
|
||||||
detail: "正在逐字写入右侧沙箱中的 report.md",
|
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",
|
id: "summary",
|
||||||
label: "生成报告摘要",
|
label: "生成报告摘要",
|
||||||
@@ -1901,6 +1967,7 @@ function buildWritingProgressToolCalls({
|
|||||||
label: stage.label,
|
label: stage.label,
|
||||||
detail,
|
detail,
|
||||||
status: complete ? "complete" : "active",
|
status: complete ? "complete" : "active",
|
||||||
|
...(stage.id === "write" && thinking.trim() ? { thinking } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1910,14 +1977,19 @@ function buildWritingProgressToolCalls({
|
|||||||
function buildCompletedReportMessage({
|
function buildCompletedReportMessage({
|
||||||
sourceCount,
|
sourceCount,
|
||||||
summaryText,
|
summaryText,
|
||||||
|
imageCount,
|
||||||
}: {
|
}: {
|
||||||
sourceCount: number;
|
sourceCount: number;
|
||||||
summaryText: string;
|
summaryText: string;
|
||||||
|
imageCount: number;
|
||||||
}): string {
|
}): string {
|
||||||
const parts = [
|
const parts = [
|
||||||
"## 研究报告已完成",
|
"## 研究报告已完成",
|
||||||
`已基于 ${sourceCount} 条资料完成报告,正文已在右侧沙箱中打开;可通过沙箱工具栏下载 Markdown 或 Word。`,
|
`已基于 ${sourceCount} 条资料完成报告,正文已在右侧沙箱中打开;可通过沙箱工具栏下载 Markdown 或 Word。`,
|
||||||
];
|
];
|
||||||
|
if (imageCount > 0) {
|
||||||
|
parts.push(`已生成 ${imageCount} 张报告配图,并嵌入正文对应章节。`);
|
||||||
|
}
|
||||||
if (summaryText.trim()) {
|
if (summaryText.trim()) {
|
||||||
parts.push(`### 报告摘要\n\n${summaryText.trim()}`);
|
parts.push(`### 报告摘要\n\n${summaryText.trim()}`);
|
||||||
}
|
}
|
||||||
@@ -2104,6 +2176,7 @@ function ResearchConversation({
|
|||||||
const hasTrace = active || events.length > 0;
|
const hasTrace = active || events.length > 0;
|
||||||
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
|
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
|
||||||
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
|
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
|
||||||
|
const requestedImageCount = requestedReportImageCount(session.config);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<UserBubble content={session.query} />
|
<UserBubble content={session.query} />
|
||||||
@@ -2120,6 +2193,7 @@ function ResearchConversation({
|
|||||||
error={streamError}
|
error={streamError}
|
||||||
onOpenSandbox={onOpenSandbox}
|
onOpenSandbox={onOpenSandbox}
|
||||||
active={active}
|
active={active}
|
||||||
|
requestedImageCount={requestedImageCount}
|
||||||
/>
|
/>
|
||||||
</AssistantTurn>
|
</AssistantTurn>
|
||||||
)}
|
)}
|
||||||
@@ -2169,6 +2243,15 @@ type TraceSource = {
|
|||||||
snippet?: string;
|
snippet?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TraceImage = {
|
||||||
|
position: number;
|
||||||
|
section: string;
|
||||||
|
status: "generating" | "completed" | "failed";
|
||||||
|
name?: string;
|
||||||
|
alt?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type TraceSnapshot = {
|
type TraceSnapshot = {
|
||||||
groups: { title: string; queries: string[] }[];
|
groups: { title: string; queries: string[] }[];
|
||||||
subtopics: string[];
|
subtopics: string[];
|
||||||
@@ -2179,6 +2262,9 @@ type TraceSnapshot = {
|
|||||||
curationReason?: string;
|
curationReason?: string;
|
||||||
compressedSourceCount?: number;
|
compressedSourceCount?: number;
|
||||||
reportCompleted: boolean;
|
reportCompleted: boolean;
|
||||||
|
imageTotal: number;
|
||||||
|
images: TraceImage[];
|
||||||
|
imagePhaseFinished: boolean;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2198,17 +2284,30 @@ function numberValue(value: unknown): number | undefined {
|
|||||||
return typeof value === "number" && Number.isFinite(value) ? value : 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 {
|
function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||||
const groupQueries = new Map<string, string[]>();
|
const groupQueries = new Map<string, string[]>();
|
||||||
const subtopics: string[] = [];
|
const subtopics: string[] = [];
|
||||||
const queryMap = new Map<string, TraceQuery>();
|
const queryMap = new Map<string, TraceQuery>();
|
||||||
const sourceMap = new Map<string, TraceSource>();
|
const sourceMap = new Map<string, TraceSource>();
|
||||||
|
const imageMap = new Map<number, TraceImage>();
|
||||||
const selectedIds = new Set<string>();
|
const selectedIds = new Set<string>();
|
||||||
const removedIds = new Set<string>();
|
const removedIds = new Set<string>();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
let curationReason: string | undefined;
|
let curationReason: string | undefined;
|
||||||
let compressedSourceCount: number | undefined;
|
let compressedSourceCount: number | undefined;
|
||||||
let reportCompleted = false;
|
let reportCompleted = false;
|
||||||
|
let imageTotal = 0;
|
||||||
|
let imagePhaseFinished = false;
|
||||||
|
|
||||||
const addQuery = (query: string, group = "研究课题") => {
|
const addQuery = (query: string, group = "研究课题") => {
|
||||||
const normalized = query.trim();
|
const normalized = query.trim();
|
||||||
@@ -2223,6 +2322,11 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
|||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const payload = event.payload ?? {};
|
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") {
|
if (event.type === "plan_created") {
|
||||||
const plan = traceRecord(payload.plan);
|
const plan = traceRecord(payload.plan);
|
||||||
for (const subtopic of traceStrings(payload.subtopics).concat(traceStrings(plan.subtopics))) {
|
for (const subtopic of traceStrings(payload.subtopics).concat(traceStrings(plan.subtopics))) {
|
||||||
@@ -2292,9 +2396,39 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
|||||||
reportCompleted = true;
|
reportCompleted = true;
|
||||||
continue;
|
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") {
|
if (event.type === "warning" || event.type === "error") {
|
||||||
const message = traceString(payload.message) ?? traceString(payload.detail);
|
const message = traceString(payload.message) ?? traceString(payload.detail);
|
||||||
if (message && !warnings.includes(message)) warnings.push(message);
|
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,
|
curationReason,
|
||||||
compressedSourceCount,
|
compressedSourceCount,
|
||||||
reportCompleted,
|
reportCompleted,
|
||||||
|
imageTotal,
|
||||||
|
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
|
||||||
|
imagePhaseFinished,
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function phaseAtOrAfter(phase: string, boundary: string): boolean {
|
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));
|
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 [expanded, setExpanded] = useState(true);
|
||||||
const snapshot = useMemo(() => buildTraceSnapshot(events), [events]);
|
const snapshot = useMemo(() => buildTraceSnapshot(events), [events]);
|
||||||
const sourcesById = useMemo(() => new Map(snapshot.sources.map((source) => [source.id, source])), [snapshot.sources]);
|
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 curatingDone = phaseAtOrAfter(phase, "writing") || snapshot.selectedIds.size > 0;
|
||||||
const writingDone = snapshot.reportCompleted || phase === "done";
|
const writingDone = snapshot.reportCompleted || phase === "done";
|
||||||
const streamedQueries = planningDone ? [] : parseStreamedQueries(planText);
|
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 (
|
return (
|
||||||
<div className="overflow-hidden rounded-2xl rounded-tl-md border border-border bg-card shadow-sm">
|
<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>
|
<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>
|
</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>}
|
{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>}
|
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。</p>}
|
||||||
</div>}
|
</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 }) {
|
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 }) {
|
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 ─────────────────────────────────────────────────
|
// ── 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 }) {
|
function AdvancedResearchSettingsDialog({
|
||||||
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>;
|
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>; }
|
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 —— 保留旧输入框全部能力,改造收敛在这一张卡片。
|
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
@@ -65,6 +65,198 @@ export type AdvancedResearchOptions = {
|
|||||||
customOutline: string;
|
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 = {
|
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
|
||||||
language: "zh-CN",
|
language: "zh-CN",
|
||||||
tone: "objective",
|
tone: "objective",
|
||||||
@@ -78,7 +270,7 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
|
|||||||
maxRevisionLoops: "2",
|
maxRevisionLoops: "2",
|
||||||
maxGeneratedImages: "2",
|
maxGeneratedImages: "2",
|
||||||
thinkingEnabled: false,
|
thinkingEnabled: false,
|
||||||
customOutline: "",
|
customOutline: DEFAULT_REPORT_OUTLINE,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Seed the card's editable options from the frozen session config snapshot. */
|
/** 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),
|
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
|
||||||
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
|
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
|
||||||
thinkingEnabled: config?.thinking_enabled === true,
|
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 [generateImages, setGenerateImages] = useState(defaultGenerateImages);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOptions(defaultOptions);
|
|
||||||
setGenerateImages(defaultGenerateImages);
|
|
||||||
}, [defaultOptions, defaultGenerateImages]);
|
|
||||||
|
|
||||||
const modes = useMemo(
|
const modes = useMemo(
|
||||||
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
|
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
|
||||||
[availableModes],
|
[availableModes],
|
||||||
@@ -278,15 +467,11 @@ export function ReportConfigCard({
|
|||||||
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
|
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
|
||||||
)}
|
)}
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<span className="mb-1.5 block text-xs font-medium">自定义报告结构</span>
|
<ReportOutlineEditor
|
||||||
<Textarea
|
id="report-card-outline"
|
||||||
value={options.customOutline}
|
value={options.customOutline}
|
||||||
onChange={(event) => update("customOutline", event.target.value)}
|
onChange={(value) => update("customOutline", value)}
|
||||||
maxLength={4000}
|
|
||||||
className="min-h-20 resize-y text-xs"
|
|
||||||
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"}
|
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground">支持 Markdown 标题或编号列表,作为报告写作结构要求。</p>
|
|
||||||
</div>
|
</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">
|
<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
|
<input
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
|
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
|
||||||
*/
|
*/
|
||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||||
|
|
||||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
|
import { ArtifactAction } from "@/components/ai-elements/artifact";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
@@ -136,18 +137,6 @@ export function ResearchSandboxPanel({
|
|||||||
<div className="relative h-full bg-[#fffdfa] p-4">
|
<div className="relative h-full bg-[#fffdfa] p-4">
|
||||||
{selectedArtifact && previewThreadId ? (
|
{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 && (
|
{documentRewriteActive && onCancelDocumentRewrite && (
|
||||||
<div className="absolute right-36 bottom-7 z-30">
|
<div className="absolute right-36 bottom-7 z-30">
|
||||||
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
|
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
|
||||||
@@ -159,8 +148,24 @@ export function ResearchSandboxPanel({
|
|||||||
className="size-full"
|
className="size-full"
|
||||||
filepath={selectedArtifact}
|
filepath={selectedArtifact}
|
||||||
threadId={previewThreadId}
|
threadId={previewThreadId}
|
||||||
|
researchSessionId={previewThreadId}
|
||||||
onClose={handleArtifactsClose}
|
onClose={handleArtifactsClose}
|
||||||
onDownloadClick={onDownload}
|
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,
|
artifactPreviewKey,
|
||||||
useArtifacts,
|
useArtifacts,
|
||||||
} from "@/components/workspace/artifacts";
|
} from "@/components/workspace/artifacts";
|
||||||
|
import { stripInlineThinkTags } from "@/core/streamdown/inline-think";
|
||||||
|
|
||||||
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
|
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
|
||||||
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
|
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
|
||||||
@@ -72,7 +73,12 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
|
|||||||
setStubMessage((prev) => {
|
setStubMessage((prev) => {
|
||||||
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
|
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
|
||||||
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
|
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 {
|
return {
|
||||||
id: messageId,
|
id: messageId,
|
||||||
type: "ai",
|
type: "ai",
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export interface PositionBusinessChainFlowProps {
|
|||||||
chain: BusinessChain | null;
|
chain: BusinessChain | null;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
className?: string;
|
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[] {
|
function getFlowStages(chain: BusinessChain): FlowStage[] {
|
||||||
@@ -50,6 +55,7 @@ export function PositionBusinessChainFlow({
|
|||||||
chain,
|
chain,
|
||||||
loading = false,
|
loading = false,
|
||||||
className,
|
className,
|
||||||
|
embedded = false,
|
||||||
}: PositionBusinessChainFlowProps) {
|
}: PositionBusinessChainFlowProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
@@ -57,26 +63,40 @@ export function PositionBusinessChainFlow({
|
|||||||
<section
|
<section
|
||||||
className={cn(
|
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",
|
"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,
|
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">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate text-base font-semibold text-slate-900 dark:text-white">业务链条</h2>
|
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
|
||||||
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
|
{!embedded && (
|
||||||
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
|
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
|
||||||
</p>
|
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{chain && (
|
{chain && (
|
||||||
<button
|
<button
|
||||||
type="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="查看完整流程图"
|
title="查看完整流程图"
|
||||||
aria-label="查看完整流程图"
|
aria-label="查看完整流程图"
|
||||||
onClick={() => setDialogOpen(true)}
|
onClick={() => setDialogOpen(true)}
|
||||||
@@ -92,7 +112,7 @@ export function PositionBusinessChainFlow({
|
|||||||
</div>
|
</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}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="flex h-[86vh] w-[96vw] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
|
<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">
|
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
|
||||||
@@ -112,18 +132,20 @@ export function PositionBusinessChainFlow({
|
|||||||
function BusinessChainCanvas({
|
function BusinessChainCanvas({
|
||||||
chain,
|
chain,
|
||||||
expanded = false,
|
expanded = false,
|
||||||
|
embedded = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
chain: BusinessChain;
|
chain: BusinessChain;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
|
embedded?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [view, setView] = useState({ tx: 0, ty: 0 });
|
const [view, setView] = useState({ tx: 0, ty: 0 });
|
||||||
const [panning, setPanning] = useState(false);
|
const [panning, setPanning] = useState(false);
|
||||||
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
|
||||||
const stages = getFlowStages(chain);
|
const stages = getFlowStages(chain);
|
||||||
const nodeWidth = expanded ? 250 : FLOW_NODE_WIDTH;
|
const nodeWidth = expanded ? 250 : embedded ? 158 : FLOW_NODE_WIDTH;
|
||||||
const nodeGap = expanded ? 18 : FLOW_NODE_GAP;
|
const nodeGap = expanded ? 18 : embedded ? 10 : FLOW_NODE_GAP;
|
||||||
let colorIndex = 0;
|
let colorIndex = 0;
|
||||||
|
|
||||||
const endPan = (event: React.PointerEvent<HTMLDivElement>) => {
|
const endPan = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -141,6 +163,7 @@ function BusinessChainCanvas({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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",
|
panning ? "cursor-grabbing" : "cursor-grab",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -193,6 +216,7 @@ function BusinessChainCanvas({
|
|||||||
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
|
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
width={nodeWidth}
|
width={nodeWidth}
|
||||||
|
compact={embedded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -252,17 +276,22 @@ function FlowNode({
|
|||||||
accent,
|
accent,
|
||||||
completed = false,
|
completed = false,
|
||||||
width,
|
width,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
accent: string;
|
accent: string;
|
||||||
completed?: boolean;
|
completed?: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const StatusIcon = completed ? CheckCircle2 : Circle;
|
const StatusIcon = completed ? CheckCircle2 : Circle;
|
||||||
return (
|
return (
|
||||||
<article
|
<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 }}
|
style={{ width, borderLeftWidth: 5, borderLeftColor: accent }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -459,7 +459,14 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
|||||||
// Clarifications collected live from `type:"tool"` frames, deduped by
|
// Clarifications collected live from `type:"tool"` frames, deduped by
|
||||||
// tool_call_id, then reconciled against the complete terminal list.
|
// tool_call_id, then reconciled against the complete terminal list.
|
||||||
const seenClarificationIds = new Set<string>();
|
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) => {
|
const collectClarification = (payload: ClarificationPayload, toolCallId: string) => {
|
||||||
|
awaitingUserClarification = true;
|
||||||
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
|
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
|
||||||
if (seenClarificationIds.has(dedupeKey)) return;
|
if (seenClarificationIds.has(dedupeKey)) return;
|
||||||
seenClarificationIds.add(dedupeKey);
|
seenClarificationIds.add(dedupeKey);
|
||||||
@@ -501,6 +508,25 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isFinalIntentFrame(parsed)) {
|
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;
|
result.status = parsed.status;
|
||||||
if (typeof parsed.content === "string") result.content = parsed.content;
|
if (typeof parsed.content === "string") result.content = parsed.content;
|
||||||
if (parsed.summary && typeof parsed.summary === "object") {
|
if (parsed.summary && typeof parsed.summary === "object") {
|
||||||
@@ -548,6 +574,11 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
|||||||
continue;
|
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
|
// Phase inference from LangGraph messages-tuple frames. Tool calls
|
||||||
// (typically ask_clarification) flip phase to tool_calling; AI text
|
// (typically ask_clarification) flip phase to tool_calling; AI text
|
||||||
// chunks flip to streaming. Everything else leaves phase alone.
|
// 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;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,7 @@ function formatFileSize(bytes: number): string {
|
|||||||
const SHOW_BACKGROUND_SUSPEND = true;
|
const SHOW_BACKGROUND_SUSPEND = true;
|
||||||
|
|
||||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||||
|
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||||
import { useLocalSettings } from "@/core/settings";
|
import { useLocalSettings } from "@/core/settings";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
|
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
|
||||||
@@ -93,6 +94,8 @@ interface ModelOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Step2PanelProps {
|
export interface Step2PanelProps {
|
||||||
|
/** 独立业务页需要与第一步完全相同的输入组件(8BF 专用,默认不改变原会商)。 */
|
||||||
|
useStep1InputAppearance?: boolean;
|
||||||
// ─── State ────────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────────
|
||||||
step2RoundtableDialogues: Step2Dialogue[];
|
step2RoundtableDialogues: Step2Dialogue[];
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
@@ -161,6 +164,7 @@ export interface Step2PanelProps {
|
|||||||
|
|
||||||
export function Step2Panel(props: Step2PanelProps) {
|
export function Step2Panel(props: Step2PanelProps) {
|
||||||
const {
|
const {
|
||||||
|
useStep1InputAppearance = false,
|
||||||
step2RoundtableDialogues,
|
step2RoundtableDialogues,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
isRoundTableRunning,
|
isRoundTableRunning,
|
||||||
@@ -377,18 +381,15 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
|
|
||||||
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
|
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
|
||||||
const [injectFiles, setInjectFiles] = useState<File[]>([]);
|
const [injectFiles, setInjectFiles] = useState<File[]>([]);
|
||||||
const injectFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handlePickInjectFiles = useCallback(
|
const addInjectFiles = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(picked: File[]) => {
|
||||||
const picked = Array.from(e.target.files ?? []);
|
|
||||||
if (picked.length > 0) {
|
if (picked.length > 0) {
|
||||||
setInjectFiles((prev) => {
|
setInjectFiles((prev) => {
|
||||||
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||||
return [...prev, ...picked.filter((f) => !seen.has(`${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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Console + intervention bar */}
|
{useStep1InputAppearance ? (
|
||||||
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
|
/* 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">
|
||||||
{injectFiles.length > 0 && (
|
<PositionIntentInput
|
||||||
<div className="flex flex-wrap gap-1.5">
|
value={customAgentInput}
|
||||||
{injectFiles.map((file, idx) => (
|
onValueChange={setCustomAgentInput}
|
||||||
<span
|
attachedFiles={injectFiles}
|
||||||
key={`${file.name}-${file.size}-${idx}`}
|
onAddFiles={addInjectFiles}
|
||||||
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]"
|
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" />
|
<Sparkles className="size-3.5 text-emerald-100" />
|
||||||
<span className="truncate font-medium" title={file.name}>
|
达成共识生成全案成果 >
|
||||||
{file.name}
|
</button>
|
||||||
</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>
|
|
||||||
</div>
|
</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 对话气泡。包含:
|
* 单条 Step 2 对话气泡。包含:
|
||||||
* - ChainOfThought 折叠卡(若 msg.steps 非空)
|
* - ChainOfThought 折叠卡(若 msg.steps 非空)
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ import { flowNodeMeta, type FlowNodeType } from "@/roundtable-planning/lib/flow-
|
|||||||
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
|
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
|
||||||
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
|
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
|
||||||
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
|
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 { readActionDetail, readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||||
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
|
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
|
||||||
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
|
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
|
||||||
@@ -306,6 +306,24 @@ export default function RoundtablePlanningPage({
|
|||||||
entryRailTaskId,
|
entryRailTaskId,
|
||||||
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
||||||
rootClassName,
|
rootClassName,
|
||||||
|
/** 独立业务页替换第一步消息工作区的纯展示组件;状态和行为仍由本页统一拥有。 */
|
||||||
|
renderStep1,
|
||||||
|
/** 独立业务页替换第一步左侧入口栏。 */
|
||||||
|
renderStep1LeftSidebar,
|
||||||
|
/** 独立业务页在第二步左栏顶部复用自己的任务/意图展示。 */
|
||||||
|
renderStep2LeftSidebarHeader,
|
||||||
|
/** 第一阶段使用内嵌右栏时,隐藏通用的模型/摘要/澄清记录栏。 */
|
||||||
|
hideDefaultStep1RightSidebar = false,
|
||||||
|
/** 独立业务页的工作区品牌区,取代通用步骤导航。 */
|
||||||
|
headerLeftSlot,
|
||||||
|
/** 放在历史记录后面的独立业务操作或状态。 */
|
||||||
|
headerActionAfterHistory,
|
||||||
|
/** 独立业务页可隐藏通用的“保存草稿”按钮。 */
|
||||||
|
hideHeaderSaveDraft = false,
|
||||||
|
/** 独立业务页可调整新建动作的展示文案,行为不变。 */
|
||||||
|
newTaskLabel,
|
||||||
|
/** 独立业务页可隐藏通用的设置入口。 */
|
||||||
|
hideHeaderSettings = false,
|
||||||
}: {
|
}: {
|
||||||
compactChrome?: boolean;
|
compactChrome?: boolean;
|
||||||
lockedChainId?: string | null;
|
lockedChainId?: string | null;
|
||||||
@@ -317,6 +335,22 @@ export default function RoundtablePlanningPage({
|
|||||||
taskContext?: Record<string, unknown> | null;
|
taskContext?: Record<string, unknown> | null;
|
||||||
entryRailTaskId?: string | null;
|
entryRailTaskId?: string | null;
|
||||||
rootClassName?: string;
|
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();
|
const { theme } = useTheme();
|
||||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||||
@@ -344,6 +378,13 @@ export default function RoundtablePlanningPage({
|
|||||||
const draftStorageTaskId = storageTaskId ?? recordTaskIdOf(prefillRef.current);
|
const draftStorageTaskId = storageTaskId ?? recordTaskIdOf(prefillRef.current);
|
||||||
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
|
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
|
||||||
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
|
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 布局抖动)。 */
|
/** 无界直传 chainId 时,链条解析完成前禁止弹选链窗(避免 modal 布局抖动)。 */
|
||||||
const [autoChainLoading, setAutoChainLoading] = useState(
|
const [autoChainLoading, setAutoChainLoading] = useState(
|
||||||
() =>
|
() =>
|
||||||
@@ -355,21 +396,57 @@ export default function RoundtablePlanningPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lockedChainId) {
|
if (!lockedChainId) {
|
||||||
setAutoChainResult(null);
|
setAutoChainResult(null);
|
||||||
|
setLockedChainResolution("idle");
|
||||||
|
setLockedChainUnavailableReason(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
// 映射切换时先清掉旧链结果,避免异步请求尚未返回时误用上一条业务链进入研讨。
|
||||||
|
setAutoChainResult(null);
|
||||||
|
setLockedChainResolution("loading");
|
||||||
|
setLockedChainUnavailableReason(null);
|
||||||
setAutoChainLoading(true);
|
setAutoChainLoading(true);
|
||||||
void Promise.all([getChain(lockedChainId), listAgents().catch(() => [])])
|
void Promise.all([getChain(lockedChainId), listAgents()])
|
||||||
.then(([chain, agents]) => {
|
.then(([chain, agents]) => {
|
||||||
if (cancelled || !chain) return;
|
if (cancelled) return;
|
||||||
const result = buildChainSeatSelection(chain, filterRecommendCandidates(agents));
|
if (!chain) {
|
||||||
if (!cancelled) setAutoChainResult(result);
|
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(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setAutoChainResult(null);
|
if (!cancelled) {
|
||||||
|
setAutoChainResult(null);
|
||||||
|
setLockedChainResolution("unavailable");
|
||||||
|
setLockedChainUnavailableReason("读取已配置业务链或智能体失败,请刷新后重试");
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setAutoChainLoading(false);
|
if (!cancelled) {
|
||||||
|
setAutoChainLoading(false);
|
||||||
|
// getChain 返回 null 时不会进入 then 内的 result 分支,同样标记为不可用。
|
||||||
|
setLockedChainResolution((current) => current === "loading" ? "unavailable" : current);
|
||||||
|
setLockedChainUnavailableReason((current) => current ?? "已配置的业务链暂不可用,请重新选择后重试");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -1963,9 +2040,66 @@ export default function RoundtablePlanningPage({
|
|||||||
triggerToast("追问生成就绪!");
|
triggerToast("追问生成就绪!");
|
||||||
}, [step1, 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 ──────────────────────────────────────────────
|
// ─── Render ──────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-roundtable-step={currentStep}
|
||||||
className={cn(
|
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",
|
"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",
|
isDark && "roundtable-planning--dark",
|
||||||
@@ -2068,13 +2202,15 @@ export default function RoundtablePlanningPage({
|
|||||||
|
|
||||||
{/* HEADER */}
|
{/* 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">
|
<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
|
{useCustomStep1Chrome ? headerLeftSlot : (
|
||||||
currentStep={currentStep}
|
<StepNavigation
|
||||||
intentReady={step1.intentReady}
|
currentStep={currentStep}
|
||||||
hasConsensus={step2.hasConsensus}
|
intentReady={step1.intentReady}
|
||||||
onStepClick={(s) => setCurrentStep(s)}
|
hasConsensus={step2.hasConsensus}
|
||||||
triggerToast={triggerToast}
|
onStepClick={(s) => setCurrentStep(s)}
|
||||||
/>
|
triggerToast={triggerToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{toolbarSlot}
|
{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"
|
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" />
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
新建任务
|
{useCustomStep1Chrome ? (newTaskLabel ?? "新建任务") : "新建任务"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{(!hideHeaderSaveDraft || !useCustomStep1Chrome) && (
|
||||||
id="save-draft-header-btn"
|
<button
|
||||||
onClick={drafts.handleManualSave}
|
id="save-draft-header-btn"
|
||||||
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"
|
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" />
|
>
|
||||||
保存草稿
|
<Save className="w-3.5 h-3.5 text-slate-500" />
|
||||||
</button>
|
保存草稿
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 「导入会商」按钮先隐藏(保留代码 + 弹窗逻辑,便于以后恢复)。 */}
|
{/* 「导入会商」按钮先隐藏(保留代码 + 弹窗逻辑,便于以后恢复)。 */}
|
||||||
{false && (
|
{false && (
|
||||||
@@ -2172,22 +2310,26 @@ export default function RoundtablePlanningPage({
|
|||||||
onCancelRename={drafts.handleCancelRename}
|
onCancelRename={drafts.handleCancelRename}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="h-6 w-[1px] bg-slate-200 mx-1" />
|
{useCustomStep1Chrome && headerActionAfterHistory}
|
||||||
|
|
||||||
<button
|
{(!hideHeaderSettings || !useCustomStep1Chrome) && <div className="h-6 w-[1px] bg-slate-200 mx-1" />}
|
||||||
id="settings-btn"
|
|
||||||
onClick={handleSettingsClick}
|
{(!hideHeaderSettings || !useCustomStep1Chrome) && (
|
||||||
title={
|
<button
|
||||||
inWujieEmbed
|
id="settings-btn"
|
||||||
? "回传各智能体交付结果给宿主"
|
onClick={handleSettingsClick}
|
||||||
: getStoredAuth()?.system_role === "admin"
|
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>
|
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 && (
|
{compactChrome && (
|
||||||
<>
|
<>
|
||||||
<ThemeSwitcher tone="content" />
|
<ThemeSwitcher tone="content" />
|
||||||
@@ -2203,7 +2345,10 @@ export default function RoundtablePlanningPage({
|
|||||||
sidebarExpanded={step2SidebarExpanded}
|
sidebarExpanded={step2SidebarExpanded}
|
||||||
setSidebarExpanded={setStep2SidebarExpanded}
|
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 */}
|
{/* LEFT SIDEBAR */}
|
||||||
<LeftSidebar
|
<LeftSidebar
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
@@ -2229,6 +2374,8 @@ export default function RoundtablePlanningPage({
|
|||||||
lockBusinessChain={lockBusinessChain}
|
lockBusinessChain={lockBusinessChain}
|
||||||
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
|
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
|
||||||
entryRailTaskCardSlot={entryRailTaskCardSlot}
|
entryRailTaskCardSlot={entryRailTaskCardSlot}
|
||||||
|
renderStep1LeftSidebar={renderStep1LeftSidebar}
|
||||||
|
renderStep2LeftSidebarHeader={renderStep2LeftSidebarHeader}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MIDDLE VIEWPORT */}
|
{/* 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"
|
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 && (
|
{currentStep === 1 && (
|
||||||
<Step1Panel
|
renderStep1 ? renderStep1({
|
||||||
|
...step1PanelProps,
|
||||||
|
selectedModel,
|
||||||
|
modelOptions: availableModels.map((model) => ({ label: model.name, value: model.name })),
|
||||||
|
onSelectedModelChange: setSelectedModel,
|
||||||
|
}) : <Step1Panel
|
||||||
|
{...step1PanelProps}
|
||||||
headerLeftSlot={
|
headerLeftSlot={
|
||||||
// 「原任务已有结果」(目的树预览)只在 rwfx-A(6BF)模式显示;xdfx/3qfx 不显示。
|
// 「原任务已有结果」(目的树预览)只在 rwfx-A(6BF)模式显示;xdfx/3qfx 不显示。
|
||||||
prefillRef.current?.taskId &&
|
prefillRef.current?.taskId &&
|
||||||
@@ -2250,57 +2403,12 @@ export default function RoundtablePlanningPage({
|
|||||||
/>
|
/>
|
||||||
) : undefined
|
) : 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 && (
|
{currentStep === 2 && (
|
||||||
<Step2Panel
|
<Step2Panel
|
||||||
|
useStep1InputAppearance={rootClassName?.includes("eightbf-roundtable") ?? false}
|
||||||
step2RoundtableDialogues={step2.step2RoundtableDialogues}
|
step2RoundtableDialogues={step2.step2RoundtableDialogues}
|
||||||
isInitializing={step2.isInitializing}
|
isInitializing={step2.isInitializing}
|
||||||
isRoundTableRunning={step2.isRoundTableRunning}
|
isRoundTableRunning={step2.isRoundTableRunning}
|
||||||
@@ -2386,7 +2494,7 @@ export default function RoundtablePlanningPage({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* RIGHT SIDEBAR */}
|
{/* RIGHT SIDEBAR */}
|
||||||
<RightSidebar
|
{!hideDefaultStep1RightSidebar && <RightSidebar
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
setSelectedModel={setSelectedModel}
|
setSelectedModel={setSelectedModel}
|
||||||
@@ -2396,7 +2504,7 @@ export default function RoundtablePlanningPage({
|
|||||||
timelineEntries={step1.timelineEntries}
|
timelineEntries={step1.timelineEntries}
|
||||||
triggerToast={triggerToast}
|
triggerToast={triggerToast}
|
||||||
onSaveIntent={handleSaveIntent}
|
onSaveIntent={handleSaveIntent}
|
||||||
/>
|
/>}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 深链 taskId 模式:底部「快捷跳转」栏(与单智能体问答共用,按业务取按钮管理配置)。 */}
|
{/* 深链 taskId 模式:底部「快捷跳转」栏(与单智能体问答共用,按业务取按钮管理配置)。 */}
|
||||||
@@ -2792,6 +2900,13 @@ interface LeftSidebarProps {
|
|||||||
entryRailBusinessChainSlot?: ReactNode;
|
entryRailBusinessChainSlot?: ReactNode;
|
||||||
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
|
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
|
||||||
entryRailTaskCardSlot?: ReactNode;
|
entryRailTaskCardSlot?: ReactNode;
|
||||||
|
/** 独立业务页替换第一步左侧工作区;仅提供已解析的意图状态。 */
|
||||||
|
renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode;
|
||||||
|
/** 独立业务页在第二步左栏顶部复用任务与意图区。 */
|
||||||
|
renderStep2LeftSidebarHeader?: (context: {
|
||||||
|
intentReady: object | null;
|
||||||
|
onCollapse: () => void;
|
||||||
|
}) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step2SandboxSidebarSync({
|
function Step2SandboxSidebarSync({
|
||||||
@@ -2844,6 +2959,8 @@ function LeftSidebar({
|
|||||||
lockedChainId,
|
lockedChainId,
|
||||||
entryRailBusinessChainSlot,
|
entryRailBusinessChainSlot,
|
||||||
entryRailTaskCardSlot,
|
entryRailTaskCardSlot,
|
||||||
|
renderStep1LeftSidebar,
|
||||||
|
renderStep2LeftSidebarHeader,
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
||||||
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
||||||
@@ -2861,6 +2978,7 @@ function LeftSidebar({
|
|||||||
<section
|
<section
|
||||||
ref={dzRef}
|
ref={dzRef}
|
||||||
id="left-sidebar"
|
id="left-sidebar"
|
||||||
|
data-sidebar-expanded={isStep2 ? String(sidebarExpanded) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
|
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
|
||||||
isStep2 ? "overflow-y-auto" : "overflow-hidden",
|
isStep2 ? "overflow-y-auto" : "overflow-hidden",
|
||||||
@@ -2882,15 +3000,20 @@ function LeftSidebar({
|
|||||||
{currentStep === 2 ? (
|
{currentStep === 2 ? (
|
||||||
sidebarExpanded ? (
|
sidebarExpanded ? (
|
||||||
<>
|
<>
|
||||||
<IntentSummaryCard
|
{renderStep2LeftSidebarHeader?.({
|
||||||
intentReady={intentReady}
|
intentReady,
|
||||||
title="意图理解"
|
onCollapse: () => setSidebarExpanded(false),
|
||||||
collapsible
|
}) ?? (
|
||||||
sidebarToggle={{
|
<IntentSummaryCard
|
||||||
expanded: sidebarExpanded,
|
intentReady={intentReady}
|
||||||
onToggle: () => setSidebarExpanded((v) => !v),
|
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="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">
|
<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)。
|
// Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
|
||||||
<RoundtableEntryRail
|
renderStep1LeftSidebar?.({ intentReady }) ?? (
|
||||||
selectedChainId={selectedChainId}
|
<RoundtableEntryRail
|
||||||
onSelectChain={onSelectChain}
|
selectedChainId={selectedChainId}
|
||||||
onOpenDraft={onOpenDraft}
|
onSelectChain={onSelectChain}
|
||||||
activeDraftId={activeDraftId}
|
onOpenDraft={onOpenDraft}
|
||||||
taskId={entryRailTaskId ?? null}
|
activeDraftId={activeDraftId}
|
||||||
historyTaskId={historyTaskId ?? null}
|
taskId={entryRailTaskId ?? null}
|
||||||
businessCode={entryRailBusinessCode ?? null}
|
historyTaskId={historyTaskId ?? null}
|
||||||
lockBusinessChain={lockBusinessChain}
|
businessCode={entryRailBusinessCode ?? null}
|
||||||
lockedChainId={lockedChainId ?? null}
|
lockBusinessChain={lockBusinessChain}
|
||||||
businessChainSlot={entryRailBusinessChainSlot}
|
lockedChainId={lockedChainId ?? null}
|
||||||
taskCardSlot={entryRailTaskCardSlot}
|
businessChainSlot={entryRailBusinessChainSlot}
|
||||||
/>
|
taskCardSlot={entryRailTaskCardSlot}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</section>
|
</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`,
|
can replay its trace from the event log. Detailed mode uses stable targets (`introduction`,
|
||||||
`section:N`, `conclusion`) because section writers are concurrent;
|
`section:N`, `conclusion`) because section writers are concurrent;
|
||||||
multi-agent revisions must emit `report_reset` before their next report
|
multi-agent revisions must emit `report_reset` before their next report
|
||||||
version. Do not emit raw model reasoning or one persisted event per provider
|
version. Do not persist raw model reasoning or one persisted event per provider
|
||||||
token. The Vite page owns Word export through
|
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
|
`frontend-web/src/core/scheduled-tasks/markdown-docx.ts`; do not add Python
|
||||||
document-conversion dependencies merely to export text.
|
document-conversion dependencies merely to export text.
|
||||||
All non-streaming Deep Research control-plane calls (`complete()` for query /
|
All non-streaming Deep Research control-plane calls (`complete()` for query /
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
install:
|
install:
|
||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
|
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload --reload-exclude ".deer-flow/*" --reload-exclude ".runtime/*"
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
|
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
|
persisted `report_chunk` events remain reconnect checkpoints. The durable log
|
||||||
is still authoritative: a browser reconnect, slow consumer, or another worker
|
is still authoritative: a browser reconnect, slow consumer, or another worker
|
||||||
falls back to `?after=<seq>` replay rather than starting a second model call.
|
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
|
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
|
reviews are independently capped at 45 seconds (maximum 120 seconds). If query
|
||||||
or section planning times out, the runner emits a visible recoverable warning
|
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
|
DeerFlow's stock configuration includes image recognition but no image-generation
|
||||||
provider. Deep Research therefore keeps illustrations disabled until an operator
|
provider. Deep Research therefore keeps illustrations disabled until an operator
|
||||||
fills `config.yaml -> deep_research.image_generation` with an
|
fills `config.yaml -> deep_research.image_generation` with an
|
||||||
OpenAI-compatible image endpoint, API key, and model. When configured, the
|
image endpoint, API key, and model. `provider: openai_compatible` uses the
|
||||||
capabilities endpoint enables the page's “生成报告配图” switch. A selected job
|
standard `/images/generations` protocol and requires `b64_json`; `provider:
|
||||||
requests at most four base64 image results, writes them as protected
|
dashscope_native` supports Qwen Image 3.0 and Wan 2.7 on DashScope / Token Plan
|
||||||
`research-image-*.png|jpg|webp` artifacts, injects `deep-research://` links into
|
with the native multimodal-generation protocol. Native provider image URLs are
|
||||||
the Markdown report, and emits image SSE events. A failed/unconfigured image
|
downloaded immediately, so both providers produce protected local artifacts.
|
||||||
provider only emits a recoverable warning; the text report still completes.
|
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`
|
Completed reports also expose `GET /api/deep-research/sessions/{id}/messages`
|
||||||
and `POST /api/deep-research/sessions/{id}/chat`. Follow-up answers are grounded
|
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,
|
DeepResearchResult,
|
||||||
)
|
)
|
||||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||||
|
from deerflow.utils.stream_text import InlineThinkTagFilter
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
@@ -157,9 +158,7 @@ class RecordingEventSink:
|
|||||||
elif type == "sources_curated" and self._source_store is not None:
|
elif type == "sources_curated" and self._source_store is not None:
|
||||||
for sid in (payload or {}).get("selectedIds", []):
|
for sid in (payload or {}).get("selectedIds", []):
|
||||||
try:
|
try:
|
||||||
await self._source_store.set_selected(
|
await self._source_store.set_selected(sid, selected=True, reason="curated", user_id=self._user_id)
|
||||||
sid, selected=True, reason="curated", user_id=self._user_id
|
|
||||||
)
|
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -265,14 +264,8 @@ class DeepResearchJobExecutor:
|
|||||||
cancel_token = CancellationToken()
|
cancel_token = CancellationToken()
|
||||||
|
|
||||||
main_task = asyncio.current_task()
|
main_task = asyncio.current_task()
|
||||||
heartbeat = (
|
heartbeat = asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task)) if lease_owner else None
|
||||||
asyncio.create_task(self._heartbeat(job_id, lease_owner, main_task))
|
watcher = asyncio.create_task(self._watch_cancellation(job_id, main_task, lease_owner, cancel_token))
|
||||||
if lease_owner
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
watcher = asyncio.create_task(
|
|
||||||
self._watch_cancellation(job_id, main_task, lease_owner, cancel_token)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Normal sandbox Markdown rewrites share the same durable lease,
|
# 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 {})
|
session_for_model.setdefault("config_snapshot", snapshot.get("config") or {})
|
||||||
|
|
||||||
if not report.strip() or not instruction:
|
if not report.strip() or not instruction:
|
||||||
await self._mark_document_rewrite_failed(
|
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。")
|
||||||
job_id, session_id, lease_owner, "INVALID_SNAPSHOT", "改写任务快照不完整,无法继续执行。"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
events = PersistingEventSink(
|
events = PersistingEventSink(
|
||||||
@@ -421,9 +412,7 @@ class DeepResearchJobExecutor:
|
|||||||
phase="planning",
|
phase="planning",
|
||||||
payload={"type": "requirements_started"},
|
payload={"type": "requirements_started"},
|
||||||
)
|
)
|
||||||
await self._job_store.update_progress(
|
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="planning", progress=20)
|
||||||
job_id, lease_owner=lease_owner, phase="planning", progress=20
|
|
||||||
)
|
|
||||||
requirement_parts: list[str] = []
|
requirement_parts: list[str] = []
|
||||||
# Persist the first fragment too: the HTTP client opens its SSE stream
|
# Persist the first fragment too: the HTTP client opens its SSE stream
|
||||||
# just after queuing the job, so this closes the tiny subscribe race
|
# just after queuing the job, so this closes the tiny subscribe race
|
||||||
@@ -449,6 +438,14 @@ class DeepResearchJobExecutor:
|
|||||||
)
|
)
|
||||||
last_requirements_checkpoint = time.monotonic()
|
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
|
requirements_fallback = False
|
||||||
try:
|
try:
|
||||||
requirements_plan = await stream_full_report_rewrite_requirements(
|
requirements_plan = await stream_full_report_rewrite_requirements(
|
||||||
@@ -458,6 +455,7 @@ class DeepResearchJobExecutor:
|
|||||||
style=style_instruction,
|
style=style_instruction,
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
on_delta=on_requirement_delta,
|
on_delta=on_requirement_delta,
|
||||||
|
on_reasoning=on_requirement_thinking,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
@@ -492,9 +490,7 @@ class DeepResearchJobExecutor:
|
|||||||
"plan": requirements_plan,
|
"plan": requirements_plan,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await self._job_store.update_progress(
|
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="writing", progress=50)
|
||||||
job_id, lease_owner=lease_owner, phase="writing", progress=50
|
|
||||||
)
|
|
||||||
|
|
||||||
draft_parts: list[str] = []
|
draft_parts: list[str] = []
|
||||||
last_checkpoint = time.monotonic()
|
last_checkpoint = time.monotonic()
|
||||||
@@ -529,6 +525,14 @@ class DeepResearchJobExecutor:
|
|||||||
)
|
)
|
||||||
last_checkpoint = time.monotonic()
|
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:
|
try:
|
||||||
rewritten, citation_ids, _usage = await stream_full_report_rewrite(
|
rewritten, citation_ids, _usage = await stream_full_report_rewrite(
|
||||||
session=session_for_model,
|
session=session_for_model,
|
||||||
@@ -538,6 +542,7 @@ class DeepResearchJobExecutor:
|
|||||||
style=style,
|
style=style,
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
on_delta=on_delta,
|
on_delta=on_delta,
|
||||||
|
on_reasoning=on_thinking,
|
||||||
requirements_plan=requirements_plan,
|
requirements_plan=requirements_plan,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -560,9 +565,7 @@ class DeepResearchJobExecutor:
|
|||||||
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
|
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
|
||||||
validation_error = validate_markdown(rewritten)
|
validation_error = validate_markdown(rewritten)
|
||||||
if validation_error:
|
if validation_error:
|
||||||
await self._mark_document_rewrite_failed(
|
await self._mark_document_rewrite_failed(job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation")
|
||||||
job_id, session_id, lease_owner, "VALIDATION_FAILED", validation_error, events=events, stage="validation"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
before_headings = heading_count(report)
|
before_headings = heading_count(report)
|
||||||
@@ -582,13 +585,9 @@ class DeepResearchJobExecutor:
|
|||||||
validation_warnings=warnings,
|
validation_warnings=warnings,
|
||||||
)
|
)
|
||||||
summary["citationCount"] = len(citation_ids)
|
summary["citationCount"] = len(citation_ids)
|
||||||
await events.emit(
|
await events.emit("document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary})
|
||||||
"document_rewrite", phase="reviewing", payload={"type": "comparison_ready", **summary}
|
|
||||||
)
|
|
||||||
await events.emit("document_rewrite", phase="exporting", payload={"type": "commit_started"})
|
await events.emit("document_rewrite", phase="exporting", payload={"type": "commit_started"})
|
||||||
await self._job_store.update_progress(
|
await self._job_store.update_progress(job_id, lease_owner=lease_owner, phase="exporting", progress=90)
|
||||||
job_id, lease_owner=lease_owner, phase="exporting", progress=90
|
|
||||||
)
|
|
||||||
|
|
||||||
committed_hash = __import__("hashlib").sha256(rewritten.encode("utf-8")).hexdigest()
|
committed_hash = __import__("hashlib").sha256(rewritten.encode("utf-8")).hexdigest()
|
||||||
updated = await self._session_store.replace_report_if_unchanged(
|
updated = await self._session_store.replace_report_if_unchanged(
|
||||||
@@ -643,9 +642,7 @@ class DeepResearchJobExecutor:
|
|||||||
expected_report=rewritten,
|
expected_report=rewritten,
|
||||||
report_markdown=report,
|
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(
|
await self._mark_document_rewrite_failed(
|
||||||
job_id,
|
job_id,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -749,6 +746,11 @@ class DeepResearchJobExecutor:
|
|||||||
instruction = str(snapshot.get("instruction") or "").strip()
|
instruction = str(snapshot.get("instruction") or "").strip()
|
||||||
style = str(snapshot.get("style") or "professional").strip()
|
style = str(snapshot.get("style") or "professional").strip()
|
||||||
model_name = str(snapshot.get("model_name") or "").strip() or None
|
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:
|
async def fail(code: str, message: str, *, stage: str = "generation") -> None:
|
||||||
await events.emit(
|
await events.emit(
|
||||||
@@ -808,13 +810,20 @@ class DeepResearchJobExecutor:
|
|||||||
)
|
)
|
||||||
return
|
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_instruction = DOCUMENT_STYLE_INSTRUCTIONS.get(
|
||||||
style,
|
style,
|
||||||
DOCUMENT_STYLE_INSTRUCTIONS["professional"],
|
DOCUMENT_STYLE_INSTRUCTIONS["professional"],
|
||||||
)
|
)
|
||||||
resolved_name = model_name or (
|
resolved_name = model_name or (self._app_config.models[0].name if self._app_config.models else None)
|
||||||
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
|
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))
|
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
|
||||||
try:
|
try:
|
||||||
@@ -849,12 +858,21 @@ class DeepResearchJobExecutor:
|
|||||||
last_requirements_checkpoint = 0.0
|
last_requirements_checkpoint = 0.0
|
||||||
requirements_fallback = False
|
requirements_fallback = False
|
||||||
try:
|
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(
|
async for delta in astream_rewrite_requirements(
|
||||||
model,
|
model,
|
||||||
file_name=actual_path.name,
|
file_name=actual_path.name,
|
||||||
instruction=instruction,
|
instruction=instruction,
|
||||||
style_instruction=style_instruction,
|
style_instruction=style_instruction,
|
||||||
original=original,
|
original=rewrite_source,
|
||||||
|
on_thinking=on_requirement_thinking,
|
||||||
):
|
):
|
||||||
requirement_parts.append(delta)
|
requirement_parts.append(delta)
|
||||||
requirement_draft = "".join(requirement_parts)
|
requirement_draft = "".join(requirement_parts)
|
||||||
@@ -909,7 +927,7 @@ class DeepResearchJobExecutor:
|
|||||||
f"{requirements_plan}\n"
|
f"{requirements_plan}\n"
|
||||||
"【已确认的改写执行计划结束】\n\n"
|
"【已确认的改写执行计划结束】\n\n"
|
||||||
"【原始 Markdown 开始】\n"
|
"【原始 Markdown 开始】\n"
|
||||||
f"{original}\n"
|
f"{rewrite_source}\n"
|
||||||
"【原始 Markdown 结束】"
|
"【原始 Markdown 结束】"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -934,6 +952,7 @@ class DeepResearchJobExecutor:
|
|||||||
|
|
||||||
draft_parts: list[str] = []
|
draft_parts: list[str] = []
|
||||||
last_checkpoint = time.monotonic()
|
last_checkpoint = time.monotonic()
|
||||||
|
inline_think_filter = InlineThinkTagFilter()
|
||||||
try:
|
try:
|
||||||
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
|
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
|
||||||
thinking_delta, text_delta = extract_stream_chunk_parts(chunk)
|
thinking_delta, text_delta = extract_stream_chunk_parts(chunk)
|
||||||
@@ -943,6 +962,13 @@ class DeepResearchJobExecutor:
|
|||||||
phase="writing",
|
phase="writing",
|
||||||
payload={"type": "thinking", "chunk": thinking_delta},
|
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:
|
if not text_delta:
|
||||||
continue
|
continue
|
||||||
draft_parts.append(text_delta)
|
draft_parts.append(text_delta)
|
||||||
@@ -974,6 +1000,26 @@ class DeepResearchJobExecutor:
|
|||||||
await fail(type(exc).__name__, str(exc))
|
await fail(type(exc).__name__, str(exc))
|
||||||
return
|
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()
|
rewritten = "".join(draft_parts).strip()
|
||||||
await events.emit(
|
await events.emit(
|
||||||
"document_rewrite",
|
"document_rewrite",
|
||||||
@@ -984,6 +1030,148 @@ class DeepResearchJobExecutor:
|
|||||||
"charCount": visible_document_char_count(rewritten),
|
"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"})
|
await events.emit("document_rewrite", phase="reviewing", payload={"type": "validation_started"})
|
||||||
validation_error = validate_markdown_document(rewritten)
|
validation_error = validate_markdown_document(rewritten)
|
||||||
if validation_error:
|
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_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("#"))
|
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 []
|
||||||
|
warnings.extend(illustration_warnings)
|
||||||
summary = build_document_rewrite_summary(
|
summary = build_document_rewrite_summary(
|
||||||
source_display_name=actual_path.name,
|
source_display_name=actual_path.name,
|
||||||
instruction=instruction,
|
instruction=instruction,
|
||||||
@@ -1001,6 +1190,8 @@ class DeepResearchJobExecutor:
|
|||||||
rewritten=rewritten,
|
rewritten=rewritten,
|
||||||
validation_warnings=warnings,
|
validation_warnings=warnings,
|
||||||
)
|
)
|
||||||
|
summary["generatedImageCount"] = len(persisted_illustrations)
|
||||||
|
summary["requestedImageCount"] = max_generated_images if generate_images else 0
|
||||||
await events.emit(
|
await events.emit(
|
||||||
"document_rewrite",
|
"document_rewrite",
|
||||||
phase="reviewing",
|
phase="reviewing",
|
||||||
@@ -1126,9 +1317,7 @@ class DeepResearchJobExecutor:
|
|||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
on_event=self._live_publisher,
|
on_event=self._live_publisher,
|
||||||
)
|
)
|
||||||
await events.emit(
|
await events.emit("document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage})
|
||||||
"document_rewrite", phase="done", payload={"type": "error", "message": message, "stage": stage}
|
|
||||||
)
|
|
||||||
await self._job_store.update_progress(
|
await self._job_store.update_progress(
|
||||||
job_id,
|
job_id,
|
||||||
lease_owner=lease_owner,
|
lease_owner=lease_owner,
|
||||||
@@ -1334,11 +1523,7 @@ class DeepResearchJobExecutor:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": (
|
"content": (f"请为以下研究报告写一段 150-250 字的摘要,使用{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n{report_markdown[:24_000]}"),
|
||||||
"请为以下研究报告写一段 150-250 字的摘要,使用"
|
|
||||||
f"{lang_note},一段纯文本,不要标题、不要列表、不要 Markdown 格式:\n\n"
|
|
||||||
f"{report_markdown[:24_000]}"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
on_delta=on_delta,
|
on_delta=on_delta,
|
||||||
@@ -1371,9 +1556,7 @@ class DeepResearchJobExecutor:
|
|||||||
|
|
||||||
# ── terminal-state writers ──────────────────────────────────────────────
|
# ── terminal-state writers ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def _mark_completed(
|
async def _mark_completed(self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult) -> None:
|
||||||
self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult
|
|
||||||
) -> None:
|
|
||||||
# Persist the report projection before exposing a terminal completed
|
# Persist the report projection before exposing a terminal completed
|
||||||
# job. The session row is the durable report itself; marking the job
|
# job. The session row is the durable report itself; marking the job
|
||||||
# complete first and suppressing a session-write failure can otherwise
|
# 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)
|
await asyncio.sleep(_REPORT_PROJECTION_RETRY_DELAY_SECONDS * attempt)
|
||||||
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"研究报告未能保存到会话 {session_id},任务不会标记为完成") from last_error
|
||||||
f"研究报告未能保存到会话 {session_id},任务不会标记为完成"
|
|
||||||
) from last_error
|
|
||||||
|
|
||||||
async def _mark_awaiting(
|
async def _mark_awaiting(self, job_id: str, session_id: str, pause: ResearchAwaitingInput) -> None:
|
||||||
self, job_id: str, session_id: str, pause: ResearchAwaitingInput
|
|
||||||
) -> None:
|
|
||||||
"""Project a persisted plan-review interrupt onto the owning session."""
|
"""Project a persisted plan-review interrupt onto the owning session."""
|
||||||
with suppress(Exception): # noqa: BLE001
|
with suppress(Exception): # noqa: BLE001
|
||||||
await self._session_store.update(
|
await self._session_store.update(
|
||||||
@@ -1465,9 +1644,7 @@ class DeepResearchJobExecutor:
|
|||||||
active_job_id=job_id,
|
active_job_id=job_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _mark_failed(
|
async def _mark_failed(self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str) -> None:
|
||||||
self, job_id: str, session_id: str, lease_owner: str | None, code: str, message: str
|
|
||||||
) -> None:
|
|
||||||
await self._job_store.update_progress(
|
await self._job_store.update_progress(
|
||||||
job_id,
|
job_id,
|
||||||
lease_owner=lease_owner,
|
lease_owner=lease_owner,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ async def stream_full_report_rewrite(
|
|||||||
style: str,
|
style: str,
|
||||||
model_name: str | None,
|
model_name: str | None,
|
||||||
on_delta: Callable[[str], Awaitable[None]],
|
on_delta: Callable[[str], Awaitable[None]],
|
||||||
|
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
|
||||||
requirements_plan: str | None = None,
|
requirements_plan: str | None = None,
|
||||||
) -> tuple[str, list[str], dict[str, Any]]:
|
) -> tuple[str, list[str], dict[str, Any]]:
|
||||||
"""Generate a complete Markdown replacement without starting new research."""
|
"""Generate a complete Markdown replacement without starting new research."""
|
||||||
@@ -126,6 +127,7 @@ async def stream_full_report_rewrite(
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
operation="full_report_rewrite",
|
operation="full_report_rewrite",
|
||||||
on_delta=on_delta,
|
on_delta=on_delta,
|
||||||
|
on_reasoning=on_reasoning,
|
||||||
)
|
)
|
||||||
answer = result.text.strip() or report
|
answer = result.text.strip() or report
|
||||||
answer, cited = _normalise_citations(answer, source_ids)
|
answer, cited = _normalise_citations(answer, source_ids)
|
||||||
@@ -140,6 +142,7 @@ async def stream_full_report_rewrite_requirements(
|
|||||||
style: str,
|
style: str,
|
||||||
model_name: str | None,
|
model_name: str | None,
|
||||||
on_delta: Callable[[str], Awaitable[None]],
|
on_delta: Callable[[str], Awaitable[None]],
|
||||||
|
on_reasoning: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Stream a compact plan before rewriting a research report.
|
"""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",
|
operation="full_report_rewrite_requirements",
|
||||||
on_delta=on_delta,
|
on_delta=on_delta,
|
||||||
|
on_reasoning=on_reasoning,
|
||||||
)
|
)
|
||||||
return result.text.strip()
|
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 os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from deerflow.utils.stream_text import InlineThinkTagFilter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MAX_DOCUMENT_REWRITE_CHARS = 120_000
|
MAX_DOCUMENT_REWRITE_CHARS = 120_000
|
||||||
@@ -89,9 +91,7 @@ def build_rewrite_requirements_context(content: str) -> str:
|
|||||||
if len(headings) >= 30:
|
if len(headings) >= 30:
|
||||||
break
|
break
|
||||||
|
|
||||||
heading_count = sum(
|
heading_count = sum(1 for line in content.splitlines() if re.match(r"^\s{0,3}#{1,6}\s+", line))
|
||||||
1 for line in content.splitlines() if re.match(r"^\s{0,3}#{1,6}\s+", line)
|
|
||||||
)
|
|
||||||
link_count = len(re.findall(r"!?(?:\[[^\]]*\]\([^)]*\))", content))
|
link_count = len(re.findall(r"!?(?:\[[^\]]*\]\([^)]*\))", content))
|
||||||
table_rows = sum(1 for line in content.splitlines() if line.lstrip().startswith("|"))
|
table_rows = sum(1 for line in content.splitlines() if line.lstrip().startswith("|"))
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
@@ -136,16 +136,7 @@ def build_rewrite_requirements_messages(
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
SystemMessage(content=DOCUMENT_REWRITE_REQUIREMENTS_SYSTEM_PROMPT),
|
SystemMessage(content=DOCUMENT_REWRITE_REQUIREMENTS_SYSTEM_PROMPT),
|
||||||
HumanMessage(
|
HumanMessage(content=(f"文件名:{file_name}\n用户改写要求:{instruction}\n选定风格:{style_instruction}\n\n【文档结构信息开始】\n{build_rewrite_requirements_context(original)}\n【文档结构信息结束】")),
|
||||||
content=(
|
|
||||||
f"文件名:{file_name}\n"
|
|
||||||
f"用户改写要求:{instruction}\n"
|
|
||||||
f"选定风格:{style_instruction}\n\n"
|
|
||||||
"【文档结构信息开始】\n"
|
|
||||||
f"{build_rewrite_requirements_context(original)}\n"
|
|
||||||
"【文档结构信息结束】"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -156,18 +147,30 @@ async def astream_rewrite_requirements(
|
|||||||
instruction: str,
|
instruction: str,
|
||||||
style_instruction: str,
|
style_instruction: str,
|
||||||
original: str,
|
original: str,
|
||||||
|
on_thinking: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> AsyncIterator[str]:
|
) -> 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(
|
messages = build_rewrite_requirements_messages(
|
||||||
file_name=file_name,
|
file_name=file_name,
|
||||||
instruction=instruction,
|
instruction=instruction,
|
||||||
style_instruction=style_instruction,
|
style_instruction=style_instruction,
|
||||||
original=original,
|
original=original,
|
||||||
)
|
)
|
||||||
|
inline_think_filter = InlineThinkTagFilter()
|
||||||
async for chunk in model.astream(messages, config={"run_name": "document_rewrite_requirements"}):
|
async for chunk in model.astream(messages, config={"run_name": "document_rewrite_requirements"}):
|
||||||
_, text = extract_stream_chunk_parts(chunk)
|
structured_thinking, text = extract_stream_chunk_parts(chunk)
|
||||||
if text:
|
if structured_thinking and on_thinking is not None:
|
||||||
yield text
|
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:
|
def atomic_write_text(path: Path, content: str) -> None:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from datetime import datetime
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_agent_store
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -125,6 +126,37 @@ async def _validate_public_chain_mapping(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Global business mappings require a public business chain",
|
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_id"] = chain_id
|
||||||
fields["chain_title"] = str(chain.get("title") or "") or None
|
fields["chain_title"] = str(chain.get("title") or "") or None
|
||||||
|
|
||||||
|
|||||||
@@ -158,20 +158,19 @@ def _clarification_blocks_ready(
|
|||||||
*,
|
*,
|
||||||
is_fresh: bool,
|
is_fresh: bool,
|
||||||
) -> 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
|
A tool call and a premature ``[INTENT_READY]`` block may coexist in the
|
||||||
stale tool calls in historical snapshots. Position roundtable instead has
|
same upstream run. The tool call is an explicit request for user input,
|
||||||
a four-part hand-off whose missing context is requested interactively, so
|
so it wins for every Step-1 workspace (including 8BF): do not consume the
|
||||||
a newly dispatched clarification is a strict terminal pause. A historical
|
assistance card before the user can answer it. Stale calls from earlier
|
||||||
tool call from the preceding user-assistance turn must not block the next
|
turns remain harmless because only a fresh call blocks READY.
|
||||||
user answer from reaching 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 (
|
del intent_agent_id
|
||||||
intent_agent_id == POSITION_INTENT_AGENT_ID
|
return bool(clarification_calls) and is_fresh
|
||||||
and bool(clarification_calls)
|
|
||||||
and is_fresh
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _intent_thread_metadata(
|
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
|
clarification_is_fresh = current_rounds > past_rounds
|
||||||
|
|
||||||
# A position-roundtable clarification is an explicit pause.
|
# A fresh clarification is an explicit pause. Some models emit
|
||||||
# Some models emit a premature [INTENT_READY] text after the
|
# a premature [INTENT_READY] block after its tool call in the
|
||||||
# tool call in the same upstream run. For this dedicated flow
|
# same upstream run; the user-assistance card must win so the
|
||||||
# the fresh clarification must win so the user can answer it;
|
# user can answer it before any summary is accepted.
|
||||||
# generic roundtable keeps its historical READY-first order.
|
|
||||||
dispatch_msg = _find_dispatch_message(messages)
|
dispatch_msg = _find_dispatch_message(messages)
|
||||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||||
clarification_calls = [
|
clarification_calls = [
|
||||||
@@ -710,10 +708,10 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
and (tc.get("name") or "").lower() == "ask_clarification"
|
and (tc.get("name") or "").lower() == "ask_clarification"
|
||||||
]
|
]
|
||||||
|
|
||||||
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
|
# --- 终态分支 1:结构化意图已就绪(无新追问时)---
|
||||||
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
|
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
|
||||||
# 仍残留 ask_clarification 的 tool_calls;必须先扫 READY,否则前端
|
# 仍残留 ask_clarification 的 tool_calls;若它是本轮新产生的追问,
|
||||||
# 会错误展示澄清卡片且 intentReady 无法解锁 Step 2。
|
# 必须先等待用户回答,不能让 READY 覆盖可交互的协助卡。
|
||||||
summary: dict[str, Any] | None = None
|
summary: dict[str, Any] | None = None
|
||||||
ready_text: str = ""
|
ready_text: str = ""
|
||||||
for msg in reversed(messages):
|
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 app.gateway.path_utils import aresolve_thread_virtual_path
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
|
from deerflow.utils.stream_text import InlineThinkTagFilter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -143,6 +144,8 @@ class DocumentRewriteRequest(BaseModel):
|
|||||||
instruction: str = Field(..., min_length=1, max_length=4_000)
|
instruction: str = Field(..., min_length=1, max_length=4_000)
|
||||||
style: str | None = Field(default=None)
|
style: str | None = Field(default=None)
|
||||||
model_name: str | None = Field(default=None, alias="modelName")
|
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")
|
expected_hash: str | None = Field(default=None, alias="expectedHash")
|
||||||
|
|
||||||
model_config = {"populate_by_name": True}
|
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:
|
def _artifact_job_matches(job: dict, *, thread_id: str, virtual_path: str) -> bool:
|
||||||
snapshot = job.get("input_snapshot")
|
snapshot = job.get("input_snapshot")
|
||||||
return bool(
|
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)
|
||||||
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:
|
def _artifact_job_response(job: dict) -> dict:
|
||||||
@@ -215,6 +213,8 @@ def _artifact_job_response(job: dict) -> dict:
|
|||||||
"instruction": str(snapshot.get("instruction") or ""),
|
"instruction": str(snapshot.get("instruction") or ""),
|
||||||
"style": str(snapshot.get("style") or "professional"),
|
"style": str(snapshot.get("style") or "professional"),
|
||||||
"modelName": snapshot.get("model_name"),
|
"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 "",
|
"originalContent": snapshot.get("original_content") or "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -266,10 +266,7 @@ async def _stream_durable_artifact_rewrite_job(
|
|||||||
yield _sse(
|
yield _sse(
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"message": str(
|
"message": str(current.get("error_message") or "全文改写任务未完成,原文件未被修改。"),
|
||||||
current.get("error_message")
|
|
||||||
or "全文改写任务未完成,原文件未被修改。"
|
|
||||||
),
|
|
||||||
"stage": str(current.get("phase") or "generation"),
|
"stage": str(current.get("phase") or "generation"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -397,6 +394,8 @@ async def start_durable_document_rewrite_job(
|
|||||||
"instruction": body.instruction.strip(),
|
"instruction": body.instruction.strip(),
|
||||||
"style": body.style or "professional",
|
"style": body.style or "professional",
|
||||||
"model_name": body.model_name,
|
"model_name": body.model_name,
|
||||||
|
"generate_images": body.generate_images,
|
||||||
|
"max_generated_images": body.max_generated_images,
|
||||||
}
|
}
|
||||||
request_hash = hashlib.sha256(
|
request_hash = hashlib.sha256(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@@ -405,6 +404,8 @@ async def start_durable_document_rewrite_job(
|
|||||||
"instruction": body.instruction.strip(),
|
"instruction": body.instruction.strip(),
|
||||||
"style": body.style or "professional",
|
"style": body.style or "professional",
|
||||||
"model_name": body.model_name,
|
"model_name": body.model_name,
|
||||||
|
"generate_images": body.generate_images,
|
||||||
|
"max_generated_images": body.max_generated_images,
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
@@ -468,13 +469,7 @@ async def list_durable_document_rewrite_jobs(
|
|||||||
virtual_path = path.strip()
|
virtual_path = path.strip()
|
||||||
scope = _artifact_rewrite_scope(thread_id, virtual_path)
|
scope = _artifact_rewrite_scope(thread_id, virtual_path)
|
||||||
rows = await job_store.list_by_session(scope, user_id=user_id, limit=limit)
|
rows = await job_store.list_by_session(scope, user_id=user_id, limit=limit)
|
||||||
return {
|
return {"items": [_artifact_job_response(row) for row in rows if _artifact_job_matches(row, thread_id=thread_id, virtual_path=virtual_path)]}
|
||||||
"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)
|
@router.get("/document-rewrite/jobs/{job_id}/stream", response_class=StreamingResponse)
|
||||||
@@ -665,10 +660,14 @@ async def rewrite_document(
|
|||||||
"plan": requirements_plan,
|
"plan": requirements_plan,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
inline_think_filter = InlineThinkTagFilter()
|
||||||
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
|
async for chunk in model.astream(messages, config={"run_name": "document_rewrite"}):
|
||||||
thinking_delta, text_delta = _extract_chunk_parts(chunk)
|
thinking_delta, text_delta = _extract_chunk_parts(chunk)
|
||||||
if thinking_delta:
|
if thinking_delta:
|
||||||
yield _sse("thinking", {"chunk": 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:
|
if text_delta:
|
||||||
draft_parts.append(text_delta)
|
draft_parts.append(text_delta)
|
||||||
yield _sse(
|
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()
|
rewritten = "".join(draft_parts).strip()
|
||||||
yield _sse("validation_started", {})
|
yield _sse("validation_started", {})
|
||||||
validation_error = _markdown_validation_error(rewritten)
|
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_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("#"))
|
heading_count_after = sum(1 for line in rewritten.splitlines() if line.lstrip().startswith("#"))
|
||||||
warnings = (
|
warnings = ["标题数量发生变化,请在前后对比中确认结构。"] if heading_count_before != heading_count_after else []
|
||||||
["标题数量发生变化,请在前后对比中确认结构。"]
|
|
||||||
if heading_count_before != heading_count_after
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
yield _sse(
|
yield _sse(
|
||||||
"validation_completed",
|
"validation_completed",
|
||||||
{
|
{
|
||||||
@@ -891,12 +899,21 @@ async def rewrite_selection(
|
|||||||
async def generate():
|
async def generate():
|
||||||
try:
|
try:
|
||||||
model = create_chat_model(name=body.model_name, thinking_enabled=thinking_enabled, app_config=config)
|
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"}):
|
async for chunk in model.astream(messages, config={"run_name": "writing_rewrite"}):
|
||||||
thinking_delta, text_delta = _extract_chunk_parts(chunk)
|
thinking_delta, text_delta = _extract_chunk_parts(chunk)
|
||||||
if thinking_delta:
|
if thinking_delta:
|
||||||
yield _sse("thinking", {"chunk": 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:
|
if text_delta:
|
||||||
yield _sse("text", {"chunk": 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", {})
|
yield _sse("done", {})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("writing/rewrite failed: thread=%s action=%s", body.thread_id, action)
|
logger.exception("writing/rewrite failed: thread=%s action=%s", body.thread_id, action)
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ models:
|
|||||||
max_retries: 2
|
max_retries: 2
|
||||||
stream_usage: true
|
stream_usage: true
|
||||||
supports_thinking: true
|
supports_thinking: true
|
||||||
supports_reasoning_effort: false
|
supports_reasoning_effort: true
|
||||||
supports_vision: false
|
supports_vision: false
|
||||||
- name: qwen3.6
|
- name: qwen3.6
|
||||||
display_name: qwen3.6
|
display_name: qwen3.6
|
||||||
@@ -207,17 +207,18 @@ vision:
|
|||||||
# max_tokens: 2048
|
# max_tokens: 2048
|
||||||
|
|
||||||
# 深度研究可选的报告配图。默认关闭;仅在部署方配置文生图模型后,前端才会展示开关。
|
# 深度研究可选的报告配图。默认关闭;仅在部署方配置文生图模型后,前端才会展示开关。
|
||||||
# 使用 OpenAI 兼容的 POST {base_url}/images/generations 协议,并强制要求 b64_json
|
# provider: openai_compatible 使用 POST {base_url}/images/generations 并要求 b64_json。
|
||||||
# 返回,以便将图片保存为当前研究会话的受鉴权产物,而不是引用会过期的公网 URL。
|
# provider: dashscope_native 用于千问 Token Plan 的 qwen-image-3.0-pro / wan2.7-image:
|
||||||
|
# 使用原生多模态 generation 接口,服务返回的临时图片 URL 会立即下载并保存为受鉴权产物。
|
||||||
deep_research:
|
deep_research:
|
||||||
image_generation:
|
image_generation:
|
||||||
enabled: true
|
enabled: true
|
||||||
provider: openai_compatible
|
provider: dashscope_native
|
||||||
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation # 例如 https://api.openai.com/v1
|
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
|
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
|
size: 1024x1024
|
||||||
# quality: standard
|
watermark: false
|
||||||
timeout_seconds: 120
|
timeout_seconds: 120
|
||||||
|
|
||||||
# browser_act 相关配置
|
# 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/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. |
|
| `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. |
|
| `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
|
## Required adaptation invariants
|
||||||
|
|
||||||
|
|||||||
+211
-12
@@ -1,14 +1,17 @@
|
|||||||
"""Optional, DeerFlow-configured report image generation.
|
"""Optional, DeerFlow-configured report image generation.
|
||||||
|
|
||||||
The upstream GPT Researcher image skill owns its provider configuration, so it
|
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
|
cannot be safely reused in DeerFlow. This adapter supports both a deployment
|
||||||
configured OpenAI-compatible ``/images/generations`` endpoint. It is never
|
configured OpenAI-compatible ``/images/generations`` endpoint and the native
|
||||||
configured from a browser request: model credentials stay in ``config.yaml``
|
DashScope multimodal-generation endpoint used by Qwen Image 3.0 and Wan 2.7.
|
||||||
and the per-session snapshot contains only the boolean ``generate_images``.
|
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
|
OpenAI-compatible images are requested as base64; DashScope image URLs are
|
||||||
therefore put the exact bytes into the session's protected artifact directory;
|
downloaded immediately. The runner therefore always puts exact bytes into the
|
||||||
reports never depend on an expiring public image URL.
|
session's protected artifact directory, and reports never depend on an expiring
|
||||||
|
public image URL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,11 +19,13 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
from typing import Any, Literal, Protocol, runtime_checkable
|
from typing import Any, Literal, Protocol, runtime_checkable
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
||||||
|
_DASHSCOPE_GENERATION_PATH = "/api/v1/services/aigc/multimodal-generation/generation"
|
||||||
|
|
||||||
|
|
||||||
class ImageGenerationError(RuntimeError):
|
class ImageGenerationError(RuntimeError):
|
||||||
@@ -28,17 +33,17 @@ class ImageGenerationError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class ImageGenerationSettings(BaseModel):
|
class ImageGenerationSettings(BaseModel):
|
||||||
"""Server-only configuration for an OpenAI-compatible image endpoint.
|
"""Server-only configuration for a report image endpoint.
|
||||||
|
|
||||||
Example ``config.yaml`` section::
|
Example ``config.yaml`` section::
|
||||||
|
|
||||||
deep_research:
|
deep_research:
|
||||||
image_generation:
|
image_generation:
|
||||||
enabled: true
|
enabled: true
|
||||||
provider: openai_compatible
|
provider: dashscope_native
|
||||||
base_url: https://api.openai.com/v1
|
base_url: https://token-plan.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
|
||||||
api_key: $DEEP_RESEARCH_IMAGE_API_KEY
|
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
|
``api_key`` is resolved by :class:`AppConfig` while loading YAML, but this
|
||||||
object is deliberately not placed in ``DeepResearchConfig`` and is never
|
object is deliberately not placed in ``DeepResearchConfig`` and is never
|
||||||
@@ -46,12 +51,13 @@ class ImageGenerationSettings(BaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
provider: Literal["openai_compatible"] = "openai_compatible"
|
provider: Literal["openai_compatible", "dashscope_native"] = "openai_compatible"
|
||||||
base_url: str = ""
|
base_url: str = ""
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
model: str = ""
|
model: str = ""
|
||||||
size: str = "1024x1024"
|
size: str = "1024x1024"
|
||||||
quality: str | None = None
|
quality: str | None = None
|
||||||
|
watermark: bool = False
|
||||||
timeout_seconds: float = Field(default=120, ge=1, le=300)
|
timeout_seconds: float = Field(default=120, ge=1, le=300)
|
||||||
|
|
||||||
model_config = {"extra": "ignore"}
|
model_config = {"extra": "ignore"}
|
||||||
@@ -215,11 +221,203 @@ class OpenAICompatibleImageProvider:
|
|||||||
return {"data": data, "mime_type": mime_type, "alt": alt, "prompt": prompt}
|
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:
|
def build_image_provider() -> ImageProvider:
|
||||||
"""Build the optional provider from server config, or a safe no-op."""
|
"""Build the optional provider from server config, or a safe no-op."""
|
||||||
settings = ImageGenerationSettings.from_app_config()
|
settings = ImageGenerationSettings.from_app_config()
|
||||||
if not settings.is_configured:
|
if not settings.is_configured:
|
||||||
return NullImageProvider()
|
return NullImageProvider()
|
||||||
|
if settings.provider == "dashscope_native":
|
||||||
|
return DashScopeNativeImageProvider(settings)
|
||||||
return OpenAICompatibleImageProvider(settings)
|
return OpenAICompatibleImageProvider(settings)
|
||||||
|
|
||||||
|
|
||||||
@@ -229,5 +427,6 @@ __all__ = [
|
|||||||
"ImageProvider",
|
"ImageProvider",
|
||||||
"NullImageProvider",
|
"NullImageProvider",
|
||||||
"OpenAICompatibleImageProvider",
|
"OpenAICompatibleImageProvider",
|
||||||
|
"DashScopeNativeImageProvider",
|
||||||
"build_image_provider",
|
"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.config import DeepResearchConfig, ModelRole
|
||||||
from deerflow.agents.deep_research.events import EventSink
|
from deerflow.agents.deep_research.events import EventSink
|
||||||
from deerflow.agents.deep_research.types import CompletionResult
|
from deerflow.agents.deep_research.types import CompletionResult
|
||||||
|
from deerflow.utils.stream_text import InlineThinkTagFilter
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from langchain.chat_models import BaseChatModel
|
from langchain.chat_models import BaseChatModel
|
||||||
@@ -83,6 +84,16 @@ def _content_to_text(resp: Any) -> str:
|
|||||||
if isinstance(block, str):
|
if isinstance(block, str):
|
||||||
parts.append(block)
|
parts.append(block)
|
||||||
elif isinstance(block, dict):
|
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 ""))
|
parts.append(str(block.get("text") or block.get("content") or ""))
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
return str(content or "")
|
return str(content or "")
|
||||||
@@ -99,7 +110,20 @@ def _reasoning_delta(chunk: Any) -> str:
|
|||||||
"""
|
"""
|
||||||
ak = getattr(chunk, "additional_kwargs", None) or {}
|
ak = getattr(chunk, "additional_kwargs", None) or {}
|
||||||
val = ak.get("reasoning_content") or ak.get("reasoning") 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]:
|
def _extract_usage(resp: Any) -> dict[str, Any]:
|
||||||
@@ -234,9 +258,7 @@ class DeerFlowCompletionBackend:
|
|||||||
parsed = _try_parse_structured(text, response_schema)
|
parsed = _try_parse_structured(text, response_schema)
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
await self._record_usage(model_name, operation, usage)
|
await self._record_usage(model_name, operation, usage)
|
||||||
return CompletionResult(
|
return CompletionResult(text=text, model=model_name, usage=usage, structured=parsed)
|
||||||
text=text, model=model_name, usage=usage, structured=parsed
|
|
||||||
)
|
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
# One repair re-prompt (§9.5).
|
# One repair re-prompt (§9.5).
|
||||||
logger.warning("deep_research llm: JSON parse failed for %s, retrying", operation)
|
logger.warning("deep_research llm: JSON parse failed for %s, retrying", operation)
|
||||||
@@ -306,13 +328,20 @@ class DeerFlowCompletionBackend:
|
|||||||
|
|
||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
usage: dict[str, Any] = {}
|
usage: dict[str, Any] = {}
|
||||||
|
inline_think_filter = InlineThinkTagFilter()
|
||||||
async for chunk in model.astream(to_langchain_messages(messages), **kwargs):
|
async for chunk in model.astream(to_langchain_messages(messages), **kwargs):
|
||||||
if self._cancel is not None:
|
if self._cancel is not None:
|
||||||
self._cancel.raise_if_cancelled()
|
self._cancel.raise_if_cancelled()
|
||||||
reasoning = _reasoning_delta(chunk)
|
reasoning = _reasoning_delta(chunk)
|
||||||
if reasoning and on_reasoning is not None:
|
if reasoning and on_reasoning is not None:
|
||||||
await on_reasoning(reasoning)
|
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:
|
if delta:
|
||||||
text_parts.append(delta)
|
text_parts.append(delta)
|
||||||
if on_delta is not None:
|
if on_delta is not None:
|
||||||
@@ -321,6 +350,14 @@ class DeerFlowCompletionBackend:
|
|||||||
if any(chunk_usage.values()):
|
if any(chunk_usage.values()):
|
||||||
usage = chunk_usage
|
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)
|
await self._record_usage(model_name, operation, usage)
|
||||||
return CompletionResult(text="".join(text_parts), model=model_name, usage=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:
|
async def _record_usage(self, model_name: str, operation: str, usage: dict[str, Any]) -> None:
|
||||||
"""Accumulate per-model totals + emit a ``model_usage`` event."""
|
"""Accumulate per-model totals + emit a ``model_usage`` event."""
|
||||||
bucket = self._usage_totals.setdefault(
|
bucket = self._usage_totals.setdefault(model_name, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
|
||||||
model_name, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
|
||||||
)
|
|
||||||
for k in bucket:
|
for k in bucket:
|
||||||
bucket[k] += int(usage.get(k, 0) or 0)
|
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."""
|
"""Build a compact "respond as JSON matching this schema" instruction."""
|
||||||
props = schema.model_json_schema().get("properties", {})
|
props = schema.model_json_schema().get("properties", {})
|
||||||
fields = ", ".join(f'"{k}"' for k in props)
|
fields = ", ".join(f'"{k}"' for k in props)
|
||||||
return (
|
return f"Respond with ONLY a valid JSON object (no markdown, no prose) containing these keys: {fields}."
|
||||||
"Respond with ONLY a valid JSON object (no markdown, no prose) "
|
|
||||||
f"containing these keys: {fields}."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _inject_schema_hint(messages: list[BaseMessage], hint: str) -> list[BaseMessage]:
|
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)]
|
return [*messages, SystemMessage(content=hint)]
|
||||||
|
|
||||||
|
|
||||||
def _inject_repair_hint(
|
def _inject_repair_hint(messages: list[BaseMessage], bad_text: str, schema: type[BaseModel]) -> list[BaseMessage]:
|
||||||
messages: list[BaseMessage], bad_text: str, schema: type[BaseModel]
|
|
||||||
) -> list[BaseMessage]:
|
|
||||||
"""Re-prompt: 'your last reply was invalid JSON, fix it'."""
|
"""Re-prompt: 'your last reply was invalid JSON, fix it'."""
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
|
|
||||||
return [
|
return [
|
||||||
*messages,
|
*messages,
|
||||||
HumanMessage(
|
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.")),
|
||||||
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."
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+5
@@ -63,6 +63,11 @@ ALL_PHASES: tuple[ResearchPhase, ...] = (
|
|||||||
|
|
||||||
EventType = Literal[
|
EventType = Literal[
|
||||||
"job_status",
|
"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",
|
"phase_changed",
|
||||||
"plan_created",
|
"plan_created",
|
||||||
"queries_planned",
|
"queries_planned",
|
||||||
|
|||||||
+1
@@ -364,6 +364,7 @@ class BasicRunner:
|
|||||||
messages=[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": prompt}],
|
messages=[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": prompt}],
|
||||||
operation="write_report",
|
operation="write_report",
|
||||||
on_delta=emitter.add,
|
on_delta=emitter.add,
|
||||||
|
on_reasoning=emitter.add_thinking,
|
||||||
)
|
)
|
||||||
await emitter.flush()
|
await emitter.flush()
|
||||||
return result.text
|
return result.text
|
||||||
|
|||||||
+3
@@ -338,6 +338,7 @@ class DetailedRunner(BasicRunner):
|
|||||||
],
|
],
|
||||||
operation="write_detailed_introduction",
|
operation="write_detailed_introduction",
|
||||||
on_delta=emitter.add,
|
on_delta=emitter.add,
|
||||||
|
on_reasoning=emitter.add_thinking,
|
||||||
)
|
)
|
||||||
await emitter.flush()
|
await emitter.flush()
|
||||||
return result.text
|
return result.text
|
||||||
@@ -373,6 +374,7 @@ class DetailedRunner(BasicRunner):
|
|||||||
],
|
],
|
||||||
operation="write_detailed_section",
|
operation="write_detailed_section",
|
||||||
on_delta=emitter.add,
|
on_delta=emitter.add,
|
||||||
|
on_reasoning=emitter.add_thinking,
|
||||||
)
|
)
|
||||||
await emitter.flush()
|
await emitter.flush()
|
||||||
return result.text
|
return result.text
|
||||||
@@ -389,6 +391,7 @@ class DetailedRunner(BasicRunner):
|
|||||||
],
|
],
|
||||||
operation="write_detailed_conclusion",
|
operation="write_detailed_conclusion",
|
||||||
on_delta=emitter.add,
|
on_delta=emitter.add,
|
||||||
|
on_reasoning=emitter.add_thinking,
|
||||||
)
|
)
|
||||||
await emitter.flush()
|
await emitter.flush()
|
||||||
return result.text
|
return result.text
|
||||||
|
|||||||
+20
-3
@@ -64,6 +64,7 @@ async def generate_report_images(
|
|||||||
)
|
)
|
||||||
image_paths: dict[str, str] = {}
|
image_paths: dict[str, str] = {}
|
||||||
amended_report = report_markdown
|
amended_report = report_markdown
|
||||||
|
total = len(sections)
|
||||||
|
|
||||||
for position, section in enumerate(sections, 1):
|
for position, section in enumerate(sections, 1):
|
||||||
if cancellation is not None:
|
if cancellation is not None:
|
||||||
@@ -72,7 +73,7 @@ async def generate_report_images(
|
|||||||
await adapters.events.emit(
|
await adapters.events.emit(
|
||||||
"image_generation_started",
|
"image_generation_started",
|
||||||
phase="illustrating",
|
phase="illustrating",
|
||||||
payload={"section": section, "position": position},
|
payload={"section": section, "position": position, "total": total},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
generated = await adapters.images.generate(
|
generated = await adapters.images.generate(
|
||||||
@@ -95,13 +96,26 @@ async def generate_report_images(
|
|||||||
await adapters.events.emit(
|
await adapters.events.emit(
|
||||||
"image_generated",
|
"image_generated",
|
||||||
phase="illustrating",
|
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:
|
except ImageGenerationError as exc:
|
||||||
await adapters.events.emit(
|
await adapters.events.emit(
|
||||||
"warning",
|
"warning",
|
||||||
phase="illustrating",
|
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
|
except Exception: # noqa: BLE001
|
||||||
# Third-party image APIs and artifact storage must never turn a
|
# Third-party image APIs and artifact storage must never turn a
|
||||||
@@ -114,6 +128,9 @@ async def generate_report_images(
|
|||||||
"code": "image_generation_failed",
|
"code": "image_generation_failed",
|
||||||
"message": "报告配图生成失败,已继续保留文字报告",
|
"message": "报告配图生成失败,已继续保留文字报告",
|
||||||
"recoverable": True,
|
"recoverable": True,
|
||||||
|
"section": section,
|
||||||
|
"position": position,
|
||||||
|
"total": total,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
@@ -321,6 +321,7 @@ class MultiAgentRunner(BasicRunner):
|
|||||||
],
|
],
|
||||||
operation="revise_multi_agent_report",
|
operation="revise_multi_agent_report",
|
||||||
on_delta=emitter.add,
|
on_delta=emitter.add,
|
||||||
|
on_reasoning=emitter.add_thinking,
|
||||||
)
|
)
|
||||||
await emitter.flush()
|
await emitter.flush()
|
||||||
return {"report": result.text, "revision_count": int(state.get("revision_count") or 0) + 1}
|
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])
|
await self._emit(self._buffer[: self._chunk_size])
|
||||||
self._buffer = 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:
|
async def flush(self) -> None:
|
||||||
"""Persist the final tail once the model stream has finished."""
|
"""Persist the final tail once the model stream has finished."""
|
||||||
if self._buffer:
|
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
|
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(
|
def _request(
|
||||||
role: str,
|
role: str,
|
||||||
chain: dict[str, object] | None = None,
|
chain: dict[str, object] | None = None,
|
||||||
*,
|
*,
|
||||||
email: str = "lqq@example.com",
|
email: str = "lqq@example.com",
|
||||||
|
agents: dict[str, dict[str, object]] | None = None,
|
||||||
):
|
):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
app=SimpleNamespace(
|
app=SimpleNamespace(
|
||||||
state=SimpleNamespace(
|
state=SimpleNamespace(
|
||||||
business_mapping_store=_MappingStore(),
|
business_mapping_store=_MappingStore(),
|
||||||
roundtable_chain_store=_ChainStore(chain),
|
roundtable_chain_store=_ChainStore(chain),
|
||||||
|
agent_store=_AgentStore(agents),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
state=SimpleNamespace(user=SimpleNamespace(id="admin-id", email=email, system_role=role)),
|
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 error.value.status_code == 400
|
||||||
assert request.app.state.business_mapping_store.calls == []
|
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 import deep_research_job_executor as executor_module
|
||||||
from app.gateway.deep_research_job_executor import DeepResearchJobExecutor
|
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
|
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 == []
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_stream_stays_open_when_durable_replay_has_a_transient_failure(monkeypatch):
|
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."""
|
"""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
|
assert phrase in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_position_intent_clarification_waits_for_the_user_before_ready():
|
def test_fresh_intent_clarification_waits_for_the_user_before_ready():
|
||||||
"""A same-run READY block must not consume the user-assistance pause."""
|
"""A same-run READY block must not consume a fresh assistance pause."""
|
||||||
assert intent_router._clarification_blocks_ready(
|
assert intent_router._clarification_blocks_ready(
|
||||||
intent_router.POSITION_INTENT_AGENT_ID,
|
intent_router.POSITION_INTENT_AGENT_ID,
|
||||||
{"name": "ask_clarification"},
|
{"name": "ask_clarification"},
|
||||||
is_fresh=True,
|
is_fresh=True,
|
||||||
)
|
)
|
||||||
assert not intent_router._clarification_blocks_ready(
|
assert intent_router._clarification_blocks_ready(
|
||||||
intent_router.INTENT_AGENT_ID,
|
intent_router.INTENT_AGENT_ID,
|
||||||
{"name": "ask_clarification"},
|
{"name": "ask_clarification"},
|
||||||
is_fresh=True,
|
is_fresh=True,
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ setsid bash -c '
|
|||||||
uv run uvicorn app.gateway.app:app \
|
uv run uvicorn app.gateway.app:app \
|
||||||
--host "$2" \
|
--host "$2" \
|
||||||
--port "$3" \
|
--port "$3" \
|
||||||
--reload
|
--reload \
|
||||||
|
--reload-exclude ".deer-flow/*" \
|
||||||
|
--reload-exclude ".runtime/*"
|
||||||
' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" "${BACKEND_VENDOR_DIR}" \
|
' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" "${BACKEND_VENDOR_DIR}" \
|
||||||
>"${LOG_FILE}" 2>&1 &
|
>"${LOG_FILE}" 2>&1 &
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user