Merge branch 'master-nj' into 'master'

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

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

No files matched your search

@@ -19,8 +19,9 @@ import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import { toast } from "sonner";
import { defaultUrlTransform } from "react-markdown";
import { Streamdown } from "streamdown";
import {
Artifact,
@@ -42,6 +43,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite";
import { useCapabilities as useResearchCapabilities } from "@/strategy-components/api/deep-research";
import {
listDurableDocumentRewrites,
streamDurableDocumentRewriteJob,
@@ -149,9 +151,11 @@ export function ArtifactFileDetail({
onClose,
allowVirtualMarkdownEdit = false,
disableDesensitize = false,
hideFileName = false,
onDownloadClick,
}: {
hideFileName = false,
onDownloadClick,
toolbarActions,
researchSessionId,
}: {
className?: string;
filepath: string;
threadId: string;
@@ -177,8 +181,15 @@ export function ArtifactFileDetail({
* (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的
* 「另存为 Word」。默认不传 —— 其它调用方行为不变。
*/
onDownloadClick?: () => void;
}) {
onDownloadClick?: () => void;
/** Deep-research reports use protected `deep-research://` image references. */
researchSessionId?: string;
/**
* 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它
* 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。
*/
toolbarActions?: ReactNode;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
@@ -291,13 +302,20 @@ export function ArtifactFileDetail({
const updateArtifact = useUpdateArtifactContent();
const queryClient = useQueryClient();
const documentRewrite = useDocumentRewrite();
const imageCapabilities = useResearchCapabilities();
const rewritePresentation = useDocumentRewritePresentation();
const hydratedRewriteScopeRef = useRef<string | null>(null);
const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({
presentationId: null,
plan: "",
});
const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({
presentationId: null,
thinking: "",
});
const documentRewriteActive = documentRewrite.isActive;
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
@@ -405,6 +423,29 @@ export function ArtifactFileDetail({
writtenCharCount: event.charCount,
});
break;
case "illustration_started":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_generated":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
generatedImageCount: event.generatedCount ?? event.position,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_completed":
rewritePresentation.update(presentationId, {
stage: "illustrating",
generatedImageCount: event.generatedCount,
maxGeneratedImages: event.requestedCount || 1,
warnings: event.warnings,
});
break;
case "validation_started":
rewritePresentation.update(presentationId, { stage: "validation" });
break;
@@ -463,6 +504,8 @@ export function ArtifactFileDetail({
instruction: historical.request.instruction,
style: historical.request.style,
modelName: historical.request.modelName,
generateImages: historical.request.generateImages,
maxGeneratedImages: historical.request.maxGeneratedImages,
originalContent: historical.request.originalContent,
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
@@ -497,6 +540,8 @@ export function ArtifactFileDetail({
instruction: latest.request.instruction,
style: latest.request.style,
modelName: latest.request.modelName,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
originalContent,
rewrittenContent,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
@@ -537,6 +582,8 @@ export function ArtifactFileDetail({
instruction: latest.request.instruction,
style: latest.request.style,
modelName: latest.request.modelName ?? undefined,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
},
{
jobId: latest.id,
@@ -605,6 +652,19 @@ export function ArtifactFileDetail({
rewritePresentation,
]);
// Thoughts use the same rAF coalescing as the draft, then are mirrored once
// per browser frame into the regular message-list card. They never touch
// the artifact Markdown or the durable rewrite checkpoint.
useEffect(() => {
const presentationId = documentRewritePresentationId;
const thinking = documentRewrite.run.thinkingText;
if (!presentationId || !rewritePresentation || !thinking) return;
const previous = mirroredThinkingTextRef.current;
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
mirroredThinkingTextRef.current = { presentationId, thinking };
rewritePresentation.update(presentationId, { thinkingText: thinking });
}, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]);
useEffect(() => {
const handleRewriteUndo = (event: Event) => {
const detail = (event as CustomEvent<{
@@ -851,6 +911,8 @@ export function ArtifactFileDetail({
instruction: string;
style: "formal_analysis" | "professional" | "concise" | "readable";
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
}) => {
if (!canUseFullDocumentRewrite) {
toast.error(
@@ -872,6 +934,8 @@ export function ArtifactFileDetail({
instruction: input.instruction,
style: input.style,
modelName: input.modelName,
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
originalContent: draftContent,
});
setDocumentRewritePresentationId(presentationId ?? null);
@@ -882,6 +946,8 @@ export function ArtifactFileDetail({
instruction: input.instruction,
style: input.style,
modelName: input.modelName ?? undefined,
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
expectedHash,
},
{
@@ -921,6 +987,29 @@ export function ArtifactFileDetail({
writtenCharCount: event.charCount,
});
break;
case "illustration_started":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_generated":
rewritePresentation.update(presentationId, {
stage: "illustrating",
illustrationTarget: event.section,
generatedImageCount: event.generatedCount ?? event.position,
maxGeneratedImages: event.total || 1,
});
break;
case "illustration_completed":
rewritePresentation.update(presentationId, {
stage: "illustrating",
generatedImageCount: event.generatedCount,
maxGeneratedImages: event.requestedCount || 1,
warnings: event.warnings,
});
break;
case "validation_started":
rewritePresentation.update(presentationId, { stage: "validation" });
break;
@@ -1217,6 +1306,7 @@ export function ArtifactFileDetail({
onClick={handleSaveArtifact}
/>
)}
{toolbarActions}
{canEditArtifact && (
<ArtifactAction
icon={documentRewriteActive ? LoaderIcon : SparklesIcon}
@@ -1394,9 +1484,10 @@ export function ArtifactFileDetail({
<ArtifactFilePreview
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
language={language ?? "text"}
threadId={threadId}
publicShareCode={publicShareCode}
animated={showPreviewSwitchPulse}
threadId={threadId}
publicShareCode={publicShareCode}
researchSessionId={researchSessionId}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
@@ -1454,25 +1545,40 @@ export function ArtifactFileDetail({
onOpenChange={setDocumentRewriteDialogOpen}
fileName={getFileName(filepath)}
charCount={(displayContent ?? "").replace(/\s/g, "").length}
imageGenerationAvailable={imageGenerationAvailable}
maxGeneratedImages={maxGeneratedImages}
onStart={handleStartDocumentRewrite}
/>
</Artifact>
);
}
function SandboxPreviewImage({
src,
alt,
threadId,
publicShareCode,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
publicShareCode?: string;
}) {
if (!src || typeof src !== "string") {
return null;
}
function SandboxPreviewImage({
src,
alt,
threadId,
publicShareCode,
researchSessionId,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
publicShareCode?: string;
researchSessionId?: string;
}) {
if (!src || typeof src !== "string") {
return null;
}
if (src.startsWith("deep-research://")) {
return researchSessionId ? (
<ProtectedResearchReportImage
{...props}
alt={alt}
sessionId={researchSessionId}
src={src}
/>
) : null;
}
let url = src;
if (src.startsWith("/mnt/")) {
@@ -1496,21 +1602,108 @@ function SandboxPreviewImage({
src={url}
alt={alt}
/>
);
}
);
}
/** Fetches a private deep-research image with the normal auth client. */
function ProtectedResearchReportImage({
sessionId,
src,
alt,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
sessionId: string;
}) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [retryKey, setRetryKey] = useState(0);
useEffect(() => {
setObjectUrl(null);
setLoadFailed(false);
if (!src?.startsWith("deep-research://")) {
return;
}
const name = src.slice("deep-research://".length);
if (!name) {
setLoadFailed(true);
return;
}
const controller = new AbortController();
let nextUrl: string | null = null;
let disposed = false;
void (async () => {
try {
const response = await apiFetch(
`${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`,
{ method: "GET", signal: controller.signal },
);
if (!response.ok) {
throw new Error(`Unable to load report image (${response.status})`);
}
const blob = await response.blob();
if (!blob.size) {
throw new Error("Report image is empty");
}
nextUrl = URL.createObjectURL(blob);
if (disposed) return;
setObjectUrl(nextUrl);
} catch (error) {
if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) {
// Never let an optional illustration break Markdown rendering, but don't
// leave the user with a misleading infinite loading label either.
setLoadFailed(true);
}
}
})();
return () => {
disposed = true;
controller.abort();
if (nextUrl) URL.revokeObjectURL(nextUrl);
};
}, [retryKey, sessionId, src]);
if (!objectUrl) {
if (loadFailed) {
return (
<span className="my-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-300">
报告配图加载失败
<button
type="button"
className="font-medium underline underline-offset-2 hover:opacity-80"
onClick={() => setRetryKey((value) => value + 1)}
>
重试
</button>
</span>
);
}
return <span className="block text-xs text-muted-foreground">正在加载报告配图…</span>;
}
return (
<img
{...props}
alt={alt || "研究报告配图"}
className={cn("my-3 max-w-full overflow-hidden rounded-lg border", props.className)}
src={objectUrl}
/>
);
}
export function ArtifactFilePreview({
content,
language,
threadId,
publicShareCode,
animated = false,
threadId,
publicShareCode,
researchSessionId,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
threadId: string;
publicShareCode?: string;
threadId: string;
publicShareCode?: string;
researchSessionId?: string;
animated?: boolean;
activeCursor?: boolean;
}) {
@@ -1574,7 +1767,7 @@ export function ArtifactFilePreview({
);
},
};
}, [content]);
}, [content, publicShareCode, researchSessionId, threadId]);
useEffect(() => {
if (language !== "html") {
@@ -1639,16 +1832,26 @@ export function ArtifactFilePreview({
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={{
<Streamdown
className="size-full"
{...streamdownPlugins}
// react-markdown's safe default strips unknown protocols before the
// custom `img` renderer sees them. Deep-research images use this
// private pseudo URL and are then fetched through the authenticated
// artifact endpoint in `SandboxPreviewImage`.
urlTransform={(url) =>
url.startsWith("deep-research://")
? url
: defaultUrlTransform(url)
}
components={{
...markdownComponents,
img: (props) => (
<SandboxPreviewImage
{...props}
threadId={threadId}
publicShareCode={publicShareCode}
threadId={threadId}
publicShareCode={publicShareCode}
researchSessionId={researchSessionId}
/>
),
}}
@@ -1,6 +1,6 @@
"use client";
import { SparklesIcon } from "lucide-react";
import { ImagePlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
@@ -51,16 +52,22 @@ export function DocumentRewriteDialog({
onOpenChange,
fileName,
charCount,
imageGenerationAvailable = false,
maxGeneratedImages = 4,
onStart,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
charCount: number;
imageGenerationAvailable?: boolean;
maxGeneratedImages?: number;
onStart: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
}) => void;
}) {
const [instruction, setInstruction] = useState(
@@ -68,11 +75,15 @@ export function DocumentRewriteDialog({
);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
useEffect(() => {
if (!open) return;
setStyle("formal_analysis");
setModelName(null);
setGenerateImages(false);
setImageCount("2");
}, [open]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
@@ -136,6 +147,48 @@ export function DocumentRewriteDialog({
</label>
</div>
{imageGenerationAvailable && (
<div className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-4">
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<ImagePlusIcon className="size-4" />
</span>
<span className="grid gap-0.5">
<span className="text-sm font-medium">改写后补充配图</span>
<span className="text-muted-foreground text-xs leading-5">
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
</span>
</span>
</label>
<Switch
aria-label="改写后补充配图"
checked={generateImages}
id="document-rewrite-images"
onCheckedChange={setGenerateImages}
/>
</div>
{generateImages && (
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
<label className="flex items-center gap-2 text-sm">
配图数量
<Select value={imageCount} onValueChange={setImageCount}>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
@@ -144,7 +197,16 @@ export function DocumentRewriteDialog({
disabled={!canStart}
onClick={() => {
if (!canStart) return;
onStart({ instruction: instruction.trim(), style, modelName });
onStart({
instruction: instruction.trim(),
style,
modelName,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
Math.max(Math.min(maxGeneratedImages, 4), 1),
),
});
}}
>
<SparklesIcon />
@@ -780,11 +780,10 @@ function buildTurnReferenceBatches(
continue;
}
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
if (nextHumanIndex === -1 && isLoading) {
continue;
}
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
const assistant =
findLastAssistantAnswer(messages, index + 1, endIndex) ??
findFrontendRagAssistant(messages, index + 1, endIndex);
if (!assistant?.id) {
continue;
}
@@ -794,6 +793,14 @@ function buildTurnReferenceBatches(
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false,
}).filter((source) => source.mode !== "none");
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
// those cards available immediately, while keeping normal streaming turns
// on their existing "wait for an answer" behavior.
const isFrontendRagTurn =
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
continue;
}
if (sources.length > 0) {
batches.push({
id: assistant.id,
@@ -832,6 +839,23 @@ function findLastAssistantAnswer(
return null;
}
function findFrontendRagAssistant(
messages: Message[],
start: number,
end: number,
): Message | null {
for (let index = end - 1; index >= start; index -= 1) {
const message = messages[index];
if (
message?.type === "ai" &&
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
) {
return message;
}
}
return null;
}
function hasAssistantAnswerContent(message: Message): boolean {
const content = message.content;
if (typeof content === "string") {
@@ -2,15 +2,22 @@
import {
CheckCircle2Icon,
ChevronDownIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
Undo2Icon,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { apiFetch } from "@/core/api/fetch-client";
import {
DOCUMENT_REWRITE_UNDONE_EVENT,
@@ -26,6 +33,7 @@ const STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
] as const;
@@ -38,15 +46,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
return 1;
case "writing":
return 2;
case "validation":
case "illustrating":
return 3;
case "validation":
return 4;
case "committing":
case "completed":
return 5;
return 6;
case "conflict":
case "error":
case "cancelled":
return 4;
return 5;
default:
return -1;
}
@@ -56,6 +66,42 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
return ["completed", "conflict", "error", "cancelled"].includes(stage);
}
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
const [open, setOpen] = useState(active);
useEffect(() => {
if (active && text) setOpen(true);
}, [active, text]);
if (!text.trim()) return null;
return (
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
<CollapsibleTrigger asChild>
<Button
aria-label={open ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
{text}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
export function DocumentRewriteTimeline({
item,
}: {
@@ -64,6 +110,9 @@ export function DocumentRewriteTimeline({
const current = stepPosition(item.stage);
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
const completed = item.stage === "completed";
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
? "requirements"
: "writing";
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
@@ -148,7 +197,13 @@ export function DocumentRewriteTimeline({
<div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => {
const active = !isTerminal(item.stage) && index === current;
const done = completed || index < current;
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
const illustrationFailed =
step.key === "illustrations" &&
item.generateImages &&
completed &&
item.generatedImageCount === 0;
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
return (
<div className="flex items-start gap-3" key={step.key}>
<div
@@ -200,6 +255,30 @@ export function DocumentRewriteTimeline({
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
</p>
)}
{step.key === "illustrations" && (
illustrationSkipped ? (
<p className="text-muted-foreground mt-0.5 text-xs">
本次未启用配图,保留原文已有图片与链接。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{item.illustrationTarget
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
</p>
) : item.generatedImageCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
已生成并保存 {item.generatedImageCount} 张本地配图。
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
未成功生成配图,文字改写结果仍已保留。
</p>
)
)}
{step.key === thinkingStep && (
<RewriteThinkingTrace active={active} text={item.thinkingText} />
)}
{step.key === "validation" && item.warnings.length > 0 && (
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
{item.warnings.join(";")}
@@ -228,6 +307,11 @@ export function DocumentRewriteTimeline({
本次优化:{item.summary.requestedOptimizations.join("、")}。
</p>
) : null}
{item.summary.requestedImageCount ? (
<p className="text-muted-foreground">
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
</p>
) : null}
{item.summary.headingChanges && (
item.summary.headingChanges.reordered ||
item.summary.headingChanges.added.length > 0 ||
@@ -523,7 +523,13 @@ function DeepResearchProgressStep({
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
return (
<ChainOfThoughtStep
@@ -546,6 +552,32 @@ function DeepResearchProgressStep({
{detail}
</div>
)}
{thinking && (
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
<CollapsibleTrigger asChild>
<Button
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
{thinking}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</ChainOfThoughtStep>
);
}
@@ -6,9 +6,10 @@ import {
ExternalLinkIcon,
FileIcon,
Loader2Icon,
PencilIcon,
SearchIcon,
} from 'lucide-react';
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
import {
memo,
useCallback,
@@ -16,9 +17,10 @@ import {
useEffect,
useMemo,
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
} from 'react';
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
import rehypeKatex from 'rehype-katex';
import { Loader } from '@/components/ai-elements/loader';
@@ -838,7 +840,7 @@ function FrontendRagSourcesPanel({
const [open, setOpen] = useState(false);
if (sources.length === 0) return null;
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full">
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button
type="button"
@@ -944,14 +946,17 @@ function MultiRoundRagContent({
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
if (!trace.isComplete) setOpen(true);
}, [trace.isComplete]);
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
// Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]);
return (
<div className="space-y-3">
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button
type="button"
@@ -965,31 +970,42 @@ function MultiRoundRagContent({
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `已完成 ${trace.rounds.length} 轮检索`
: '正在分析问题并检索资料'}
</div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
</div>
</div>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
/>
</button>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<MultiRoundRagRoundCard
key={round.id}
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
))}
</div>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
className={cn(
'relative pl-5',
index < trace.rounds.length - 1 && 'pb-1',
)}
>
{index < trace.rounds.length - 1 && (
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
</div>
))}
</div>
)}
</section>
<section className="border-border/70 rounded-xl border p-4">
@@ -1014,7 +1030,7 @@ function MultiRoundRagContent({
);
}
function MultiRoundRagRoundCard({
function MultiRoundRagRoundCard({
index,
round,
isLoading,
@@ -1026,78 +1042,228 @@ function MultiRoundRagRoundCard({
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
return (
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
{index}
</span>
<span>{round.title}</span>
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.thought && (
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</section>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{round.query && (
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
<SearchIcon className="size-3 shrink-0" />
<span className="truncate">{round.query}</span>
</div>
)}
{round.retrievals.length > 0 && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">
检索内容 · {round.retrievals.length} 条
</div>
<div className="space-y-2">
{round.retrievals.map((item, resultIndex) => (
<details
key={`${item.id ?? item.title}-${resultIndex}`}
className="border-border/60 rounded-md border px-3 py-2"
open={resultIndex === 0}
>
<summary className="cursor-pointer list-none text-sm font-medium">
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
{item.title}
</summary>
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</div>
{(item.source || item.time) && (
<div className="text-muted-foreground mt-2 text-xs">
{[item.source, item.time].filter(Boolean).join(' · ')}
</div>
)}
</details>
))}
</div>
</section>
)}
</div>
);
}
}) {
const [open, setOpen] = useState(!round.query || round.isSearching);
useEffect(() => {
// A newly created round stays expanded to expose its thought and loading
// query. Once that query has returned, retain only the round header until
// the user asks to inspect the completed detail again.
setOpen(!round.query || round.isSearching);
}, [round.id, round.isSearching, round.query]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate">{round.title}</span>
{round.isSearching ? (
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
检索中
</span>
) : (
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
<div className="pt-3">
{round.thought && (
<MultiRoundRagDetailSection
title="思考内容"
icon={<SparklesIcon className="text-primary size-4" />}
>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</MultiRoundRagDetailSection>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-lg border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{(round.query || round.isSearching) && (
<div
className={cn(
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
round.isSearching && 'animate-pulse motion-reduce:animate-none',
)}
aria-live={round.isSearching ? 'polite' : undefined}
>
{round.isSearching ? (
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">检索词</span>
<span className="min-w-0 truncate font-medium">
{round.query || '正在生成检索词…'}
</span>
</div>
)}
{(round.query || round.isSearching || round.retrievals.length > 0) && (
<MultiRoundRagDetailSection
defaultOpen={!round.isSearching}
title="检索内容"
icon={<FileIcon className="text-primary size-3.5" />}
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
>
{round.isSearching ? (
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
正在召回相关内容…
</div>
) : (
<div className="space-y-2">
{round.retrievals.length > 0 ? (
round.retrievals.map((item, resultIndex) => (
<MultiRoundRagResultCard
key={`${item.id ?? item.title}-${resultIndex}`}
item={item}
index={resultIndex + 1}
/>
))
) : (
<p className="text-muted-foreground py-1 text-sm">
本轮未召回相关资料。
</p>
)}
</div>
)}
</MultiRoundRagDetailSection>
)}
</div>
</CollapsibleContent>
</article>
</Collapsible>
);
}
function MultiRoundRagDetailSection({
title,
icon,
suffix,
defaultOpen = true,
children,
}: {
title: string;
icon: ReactNode;
suffix?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
{icon}
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
{suffix}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
{children}
</CollapsibleContent>
</section>
</Collapsible>
);
}
function MultiRoundRagResultCard({
item,
index,
}: {
item: MultiRoundRagRound['retrievals'][number];
index: number;
}) {
const [open, setOpen] = useState(index === 1);
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
{item.title}
</span>
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</p>
{(sourceMeta || item.url) && (
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{sourceMeta && <span>{sourceMeta}</span>}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
查看原文
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null,
@@ -255,15 +255,15 @@ export function MessageList({
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const hasSingleRoundRag = useMemo(
const hasSingleRoundRag = useMemo(
() =>
messages.some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
@@ -436,11 +436,12 @@ export function MessageList({
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
contentClassName,
)}
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
@@ -54,6 +54,7 @@ import type {
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { createClientId } from "@/lib/client-id";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return createClientId("shortcut");
}
function newShortcutParam(): CompactShortcutRouteParam {
@@ -43,6 +43,7 @@ import {
customThemeTextContrast,
type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme";
import { createClientId } from "@/lib/client-id";
import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12;
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
];
function makeThemeId(): `custom-theme-${string}` {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
return `custom-theme-${suffix}`;
}