增强文档改写配图与深度研究流式稳定性,并完善 8BF/会商工作区。
全文改写支持可选配图与思考过程展示,深度研究接入 DashScope 原生出图并剥离内联 think 标签;8BF 会商补齐第一步工作区与任务卡回退,同时限制热重载扫描范围。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
819e537aa3
commit
15a46b903a
48 files changed
+3724
-616
No files matched your search
@@ -19,8 +19,9 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { toast } from "sonner";
|
||||
import { defaultUrlTransform } from "react-markdown";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import {
|
||||
Artifact,
|
||||
@@ -42,6 +43,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
|
||||
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
|
||||
import { useCapabilities as useResearchCapabilities } from "@/strategy-components/api/deep-research";
|
||||
import {
|
||||
listDurableDocumentRewrites,
|
||||
streamDurableDocumentRewriteJob,
|
||||
@@ -149,9 +151,11 @@ export function ArtifactFileDetail({
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
hideFileName = false,
|
||||
onDownloadClick,
|
||||
}: {
|
||||
hideFileName = false,
|
||||
onDownloadClick,
|
||||
toolbarActions,
|
||||
researchSessionId,
|
||||
}: {
|
||||
className?: string;
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
@@ -177,8 +181,15 @@ export function ArtifactFileDetail({
|
||||
* (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的
|
||||
* 「另存为 Word」。默认不传 —— 其它调用方行为不变。
|
||||
*/
|
||||
onDownloadClick?: () => void;
|
||||
}) {
|
||||
onDownloadClick?: () => void;
|
||||
/** Deep-research reports use protected `deep-research://` image references. */
|
||||
researchSessionId?: string;
|
||||
/**
|
||||
* 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它
|
||||
* 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。
|
||||
*/
|
||||
toolbarActions?: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
@@ -291,13 +302,20 @@ export function ArtifactFileDetail({
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
const queryClient = useQueryClient();
|
||||
const documentRewrite = useDocumentRewrite();
|
||||
const imageCapabilities = useResearchCapabilities();
|
||||
const rewritePresentation = useDocumentRewritePresentation();
|
||||
const hydratedRewriteScopeRef = useRef<string | null>(null);
|
||||
const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({
|
||||
presentationId: null,
|
||||
plan: "",
|
||||
});
|
||||
const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({
|
||||
presentationId: null,
|
||||
thinking: "",
|
||||
});
|
||||
const documentRewriteActive = documentRewrite.isActive;
|
||||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||
const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits;
|
||||
const displayContent = documentRewriteActive
|
||||
? documentRewrite.run.draft
|
||||
@@ -405,6 +423,29 @@ export function ArtifactFileDetail({
|
||||
writtenCharCount: event.charCount,
|
||||
});
|
||||
break;
|
||||
case "illustration_started":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "illustrating",
|
||||
illustrationTarget: event.section,
|
||||
maxGeneratedImages: event.total || 1,
|
||||
});
|
||||
break;
|
||||
case "illustration_generated":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "illustrating",
|
||||
illustrationTarget: event.section,
|
||||
generatedImageCount: event.generatedCount ?? event.position,
|
||||
maxGeneratedImages: event.total || 1,
|
||||
});
|
||||
break;
|
||||
case "illustration_completed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "illustrating",
|
||||
generatedImageCount: event.generatedCount,
|
||||
maxGeneratedImages: event.requestedCount || 1,
|
||||
warnings: event.warnings,
|
||||
});
|
||||
break;
|
||||
case "validation_started":
|
||||
rewritePresentation.update(presentationId, { stage: "validation" });
|
||||
break;
|
||||
@@ -463,6 +504,8 @@ export function ArtifactFileDetail({
|
||||
instruction: historical.request.instruction,
|
||||
style: historical.request.style,
|
||||
modelName: historical.request.modelName,
|
||||
generateImages: historical.request.generateImages,
|
||||
maxGeneratedImages: historical.request.maxGeneratedImages,
|
||||
originalContent: historical.request.originalContent,
|
||||
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
|
||||
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
|
||||
@@ -497,6 +540,8 @@ export function ArtifactFileDetail({
|
||||
instruction: latest.request.instruction,
|
||||
style: latest.request.style,
|
||||
modelName: latest.request.modelName,
|
||||
generateImages: latest.request.generateImages,
|
||||
maxGeneratedImages: latest.request.maxGeneratedImages,
|
||||
originalContent,
|
||||
rewrittenContent,
|
||||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||
@@ -537,6 +582,8 @@ export function ArtifactFileDetail({
|
||||
instruction: latest.request.instruction,
|
||||
style: latest.request.style,
|
||||
modelName: latest.request.modelName ?? undefined,
|
||||
generateImages: latest.request.generateImages,
|
||||
maxGeneratedImages: latest.request.maxGeneratedImages,
|
||||
},
|
||||
{
|
||||
jobId: latest.id,
|
||||
@@ -605,6 +652,19 @@ export function ArtifactFileDetail({
|
||||
rewritePresentation,
|
||||
]);
|
||||
|
||||
// Thoughts use the same rAF coalescing as the draft, then are mirrored once
|
||||
// per browser frame into the regular message-list card. They never touch
|
||||
// the artifact Markdown or the durable rewrite checkpoint.
|
||||
useEffect(() => {
|
||||
const presentationId = documentRewritePresentationId;
|
||||
const thinking = documentRewrite.run.thinkingText;
|
||||
if (!presentationId || !rewritePresentation || !thinking) return;
|
||||
const previous = mirroredThinkingTextRef.current;
|
||||
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
|
||||
mirroredThinkingTextRef.current = { presentationId, thinking };
|
||||
rewritePresentation.update(presentationId, { thinkingText: thinking });
|
||||
}, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRewriteUndo = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{
|
||||
@@ -851,6 +911,8 @@ export function ArtifactFileDetail({
|
||||
instruction: string;
|
||||
style: "formal_analysis" | "professional" | "concise" | "readable";
|
||||
modelName: string | null;
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
}) => {
|
||||
if (!canUseFullDocumentRewrite) {
|
||||
toast.error(
|
||||
@@ -872,6 +934,8 @@ export function ArtifactFileDetail({
|
||||
instruction: input.instruction,
|
||||
style: input.style,
|
||||
modelName: input.modelName,
|
||||
generateImages: input.generateImages,
|
||||
maxGeneratedImages: input.maxGeneratedImages,
|
||||
originalContent: draftContent,
|
||||
});
|
||||
setDocumentRewritePresentationId(presentationId ?? null);
|
||||
@@ -882,6 +946,8 @@ export function ArtifactFileDetail({
|
||||
instruction: input.instruction,
|
||||
style: input.style,
|
||||
modelName: input.modelName ?? undefined,
|
||||
generateImages: input.generateImages,
|
||||
maxGeneratedImages: input.maxGeneratedImages,
|
||||
expectedHash,
|
||||
},
|
||||
{
|
||||
@@ -921,6 +987,29 @@ export function ArtifactFileDetail({
|
||||
writtenCharCount: event.charCount,
|
||||
});
|
||||
break;
|
||||
case "illustration_started":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "illustrating",
|
||||
illustrationTarget: event.section,
|
||||
maxGeneratedImages: event.total || 1,
|
||||
});
|
||||
break;
|
||||
case "illustration_generated":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "illustrating",
|
||||
illustrationTarget: event.section,
|
||||
generatedImageCount: event.generatedCount ?? event.position,
|
||||
maxGeneratedImages: event.total || 1,
|
||||
});
|
||||
break;
|
||||
case "illustration_completed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "illustrating",
|
||||
generatedImageCount: event.generatedCount,
|
||||
maxGeneratedImages: event.requestedCount || 1,
|
||||
warnings: event.warnings,
|
||||
});
|
||||
break;
|
||||
case "validation_started":
|
||||
rewritePresentation.update(presentationId, { stage: "validation" });
|
||||
break;
|
||||
@@ -1217,6 +1306,7 @@ export function ArtifactFileDetail({
|
||||
onClick={handleSaveArtifact}
|
||||
/>
|
||||
)}
|
||||
{toolbarActions}
|
||||
{canEditArtifact && (
|
||||
<ArtifactAction
|
||||
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
|
||||
@@ -1394,9 +1484,10 @@ export function ArtifactFileDetail({
|
||||
<ArtifactFilePreview
|
||||
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
|
||||
language={language ?? "text"}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
animated={showPreviewSwitchPulse}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
researchSessionId={researchSessionId}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
@@ -1454,25 +1545,40 @@ export function ArtifactFileDetail({
|
||||
onOpenChange={setDocumentRewriteDialogOpen}
|
||||
fileName={getFileName(filepath)}
|
||||
charCount={(displayContent ?? "").replace(/\s/g, "").length}
|
||||
imageGenerationAvailable={imageGenerationAvailable}
|
||||
maxGeneratedImages={maxGeneratedImages}
|
||||
onStart={handleStartDocumentRewrite}
|
||||
/>
|
||||
</Artifact>
|
||||
);
|
||||
}
|
||||
|
||||
function SandboxPreviewImage({
|
||||
src,
|
||||
alt,
|
||||
threadId,
|
||||
publicShareCode,
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
threadId: string;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
if (!src || typeof src !== "string") {
|
||||
return null;
|
||||
}
|
||||
function SandboxPreviewImage({
|
||||
src,
|
||||
alt,
|
||||
threadId,
|
||||
publicShareCode,
|
||||
researchSessionId,
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
threadId: string;
|
||||
publicShareCode?: string;
|
||||
researchSessionId?: string;
|
||||
}) {
|
||||
if (!src || typeof src !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (src.startsWith("deep-research://")) {
|
||||
return researchSessionId ? (
|
||||
<ProtectedResearchReportImage
|
||||
{...props}
|
||||
alt={alt}
|
||||
sessionId={researchSessionId}
|
||||
src={src}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
let url = src;
|
||||
if (src.startsWith("/mnt/")) {
|
||||
@@ -1496,21 +1602,108 @@ function SandboxPreviewImage({
|
||||
src={url}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetches a private deep-research image with the normal auth client. */
|
||||
function ProtectedResearchReportImage({
|
||||
sessionId,
|
||||
src,
|
||||
alt,
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
sessionId: string;
|
||||
}) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setObjectUrl(null);
|
||||
setLoadFailed(false);
|
||||
if (!src?.startsWith("deep-research://")) {
|
||||
return;
|
||||
}
|
||||
const name = src.slice("deep-research://".length);
|
||||
if (!name) {
|
||||
setLoadFailed(true);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let nextUrl: string | null = null;
|
||||
let disposed = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`,
|
||||
{ method: "GET", signal: controller.signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load report image (${response.status})`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (!blob.size) {
|
||||
throw new Error("Report image is empty");
|
||||
}
|
||||
nextUrl = URL.createObjectURL(blob);
|
||||
if (disposed) return;
|
||||
setObjectUrl(nextUrl);
|
||||
} catch (error) {
|
||||
if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) {
|
||||
// Never let an optional illustration break Markdown rendering, but don't
|
||||
// leave the user with a misleading infinite loading label either.
|
||||
setLoadFailed(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
if (nextUrl) URL.revokeObjectURL(nextUrl);
|
||||
};
|
||||
}, [retryKey, sessionId, src]);
|
||||
|
||||
if (!objectUrl) {
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<span className="my-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
报告配图加载失败
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium underline underline-offset-2 hover:opacity-80"
|
||||
onClick={() => setRetryKey((value) => value + 1)}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="block text-xs text-muted-foreground">正在加载报告配图…</span>;
|
||||
}
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
alt={alt || "研究报告配图"}
|
||||
className={cn("my-3 max-w-full overflow-hidden rounded-lg border", props.className)}
|
||||
src={objectUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
threadId,
|
||||
publicShareCode,
|
||||
animated = false,
|
||||
threadId,
|
||||
publicShareCode,
|
||||
researchSessionId,
|
||||
animated = false,
|
||||
activeCursor = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
threadId: string;
|
||||
publicShareCode?: string;
|
||||
threadId: string;
|
||||
publicShareCode?: string;
|
||||
researchSessionId?: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
@@ -1574,7 +1767,7 @@ export function ArtifactFilePreview({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [content]);
|
||||
}, [content, publicShareCode, researchSessionId, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== "html") {
|
||||
@@ -1639,16 +1832,26 @@ export function ArtifactFilePreview({
|
||||
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
||||
)}
|
||||
>
|
||||
<Streamdown
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{
|
||||
<Streamdown
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
// react-markdown's safe default strips unknown protocols before the
|
||||
// custom `img` renderer sees them. Deep-research images use this
|
||||
// private pseudo URL and are then fetched through the authenticated
|
||||
// artifact endpoint in `SandboxPreviewImage`.
|
||||
urlTransform={(url) =>
|
||||
url.startsWith("deep-research://")
|
||||
? url
|
||||
: defaultUrlTransform(url)
|
||||
}
|
||||
components={{
|
||||
...markdownComponents,
|
||||
img: (props) => (
|
||||
<SandboxPreviewImage
|
||||
{...props}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
researchSessionId={researchSessionId}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { ImagePlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
|
||||
|
||||
@@ -51,16 +52,22 @@ export function DocumentRewriteDialog({
|
||||
onOpenChange,
|
||||
fileName,
|
||||
charCount,
|
||||
imageGenerationAvailable = false,
|
||||
maxGeneratedImages = 4,
|
||||
onStart,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fileName: string;
|
||||
charCount: number;
|
||||
imageGenerationAvailable?: boolean;
|
||||
maxGeneratedImages?: number;
|
||||
onStart: (input: {
|
||||
instruction: string;
|
||||
style: DocumentRewriteStyle;
|
||||
modelName: string | null;
|
||||
generateImages: boolean;
|
||||
maxGeneratedImages: number;
|
||||
}) => void;
|
||||
}) {
|
||||
const [instruction, setInstruction] = useState(
|
||||
@@ -68,11 +75,15 @@ export function DocumentRewriteDialog({
|
||||
);
|
||||
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
const [generateImages, setGenerateImages] = useState(false);
|
||||
const [imageCount, setImageCount] = useState("2");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStyle("formal_analysis");
|
||||
setModelName(null);
|
||||
setGenerateImages(false);
|
||||
setImageCount("2");
|
||||
}, [open]);
|
||||
|
||||
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
|
||||
@@ -136,6 +147,48 @@ export function DocumentRewriteDialog({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{imageGenerationAvailable && (
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
|
||||
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<ImagePlusIcon className="size-4" />
|
||||
</span>
|
||||
<span className="grid gap-0.5">
|
||||
<span className="text-sm font-medium">改写后补充配图</span>
|
||||
<span className="text-muted-foreground text-xs leading-5">
|
||||
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
aria-label="改写后补充配图"
|
||||
checked={generateImages}
|
||||
id="document-rewrite-images"
|
||||
onCheckedChange={setGenerateImages}
|
||||
/>
|
||||
</div>
|
||||
{generateImages && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
|
||||
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
配图数量
|
||||
<Select value={imageCount} onValueChange={setImageCount}>
|
||||
<SelectTrigger className="h-8 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
|
||||
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
@@ -144,7 +197,16 @@ export function DocumentRewriteDialog({
|
||||
disabled={!canStart}
|
||||
onClick={() => {
|
||||
if (!canStart) return;
|
||||
onStart({ instruction: instruction.trim(), style, modelName });
|
||||
onStart({
|
||||
instruction: instruction.trim(),
|
||||
style,
|
||||
modelName,
|
||||
generateImages,
|
||||
maxGeneratedImages: Math.min(
|
||||
Math.max(Number(imageCount) || 1, 1),
|
||||
Math.max(Math.min(maxGeneratedImages, 4), 1),
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SparklesIcon />
|
||||
|
||||
@@ -2,15 +2,22 @@
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
FilePenLineIcon,
|
||||
LightbulbIcon,
|
||||
Loader2Icon,
|
||||
Undo2Icon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import {
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
@@ -26,6 +33,7 @@ const STEPS = [
|
||||
{ key: "snapshot", label: "固定原文版本" },
|
||||
{ key: "requirements", label: "理解改写要求" },
|
||||
{ key: "writing", label: "流式重写 Markdown" },
|
||||
{ key: "illustrations", label: "生成文档配图" },
|
||||
{ key: "validation", label: "校验文档" },
|
||||
{ key: "comparison", label: "对比原文并生成说明" },
|
||||
] as const;
|
||||
@@ -38,15 +46,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
|
||||
return 1;
|
||||
case "writing":
|
||||
return 2;
|
||||
case "validation":
|
||||
case "illustrating":
|
||||
return 3;
|
||||
case "validation":
|
||||
return 4;
|
||||
case "committing":
|
||||
case "completed":
|
||||
return 5;
|
||||
return 6;
|
||||
case "conflict":
|
||||
case "error":
|
||||
case "cancelled":
|
||||
return 4;
|
||||
return 5;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
@@ -56,6 +66,42 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
|
||||
return ["completed", "conflict", "error", "cancelled"].includes(stage);
|
||||
}
|
||||
|
||||
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
|
||||
const [open, setOpen] = useState(active);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && text) setOpen(true);
|
||||
}, [active, text]);
|
||||
|
||||
if (!text.trim()) return null;
|
||||
return (
|
||||
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
aria-label={open ? "收起模型思考" : "展开模型思考"}
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
|
||||
<span>模型思考</span>
|
||||
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||||
</p>
|
||||
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{text}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentRewriteTimeline({
|
||||
item,
|
||||
}: {
|
||||
@@ -64,6 +110,9 @@ export function DocumentRewriteTimeline({
|
||||
const current = stepPosition(item.stage);
|
||||
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
|
||||
const completed = item.stage === "completed";
|
||||
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
|
||||
? "requirements"
|
||||
: "writing";
|
||||
const presentation = useDocumentRewritePresentation();
|
||||
const [undoing, setUndoing] = useState(false);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
@@ -148,7 +197,13 @@ export function DocumentRewriteTimeline({
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
{STEPS.map((step, index) => {
|
||||
const active = !isTerminal(item.stage) && index === current;
|
||||
const done = completed || index < current;
|
||||
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
|
||||
const illustrationFailed =
|
||||
step.key === "illustrations" &&
|
||||
item.generateImages &&
|
||||
completed &&
|
||||
item.generatedImageCount === 0;
|
||||
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
|
||||
return (
|
||||
<div className="flex items-start gap-3" key={step.key}>
|
||||
<div
|
||||
@@ -200,6 +255,30 @@ export function DocumentRewriteTimeline({
|
||||
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
)}
|
||||
{step.key === "illustrations" && (
|
||||
illustrationSkipped ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
本次未启用配图,保留原文已有图片与链接。
|
||||
</p>
|
||||
) : active ? (
|
||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||
{item.illustrationTarget
|
||||
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
|
||||
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
|
||||
</p>
|
||||
) : item.generatedImageCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
已生成并保存 {item.generatedImageCount} 张本地配图。
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
未成功生成配图,文字改写结果仍已保留。
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{step.key === thinkingStep && (
|
||||
<RewriteThinkingTrace active={active} text={item.thinkingText} />
|
||||
)}
|
||||
{step.key === "validation" && item.warnings.length > 0 && (
|
||||
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
{item.warnings.join(";")}
|
||||
@@ -228,6 +307,11 @@ export function DocumentRewriteTimeline({
|
||||
本次优化:{item.summary.requestedOptimizations.join("、")}。
|
||||
</p>
|
||||
) : null}
|
||||
{item.summary.requestedImageCount ? (
|
||||
<p className="text-muted-foreground">
|
||||
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
|
||||
</p>
|
||||
) : null}
|
||||
{item.summary.headingChanges && (
|
||||
item.summary.headingChanges.reordered ||
|
||||
item.summary.headingChanges.added.length > 0 ||
|
||||
|
||||
@@ -523,7 +523,13 @@ function DeepResearchProgressStep({
|
||||
? args.label
|
||||
: "正在撰写研究报告";
|
||||
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
|
||||
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
|
||||
const active = args.status !== "complete";
|
||||
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && thinking) setThinkingOpen(true);
|
||||
}, [active, thinking]);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
@@ -546,6 +552,32 @@ function DeepResearchProgressStep({
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
{thinking && (
|
||||
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
|
||||
<span>模型思考</span>
|
||||
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||||
</p>
|
||||
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{thinking}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user