新报告不再覆盖当前文件卡片,写入时打开沙箱;步骤条仅在改写原文时位于卡片上方。同时补齐改写本地回退、模型管理与会商第三步相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
601 lines
24 KiB
TypeScript
601 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
CheckCircle2Icon,
|
||
ChevronDownIcon,
|
||
ChevronsDownIcon,
|
||
FilePenLineIcon,
|
||
LightbulbIcon,
|
||
Loader2Icon,
|
||
Undo2Icon,
|
||
XCircleIcon,
|
||
} from "lucide-react";
|
||
import { useCallback, useEffect, useLayoutEffect, useRef, 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 { updateArtifactContent } from "@/core/artifacts/api";
|
||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||
import {
|
||
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||
type DocumentRewritePresentation,
|
||
useDocumentRewritePresentation,
|
||
} from "@/core/document-rewrite/context";
|
||
import {
|
||
clearArtifactRewriteFallback,
|
||
loadArtifactRewriteFallback,
|
||
sha256DocumentContent,
|
||
} from "@/core/document-rewrite/local-fallback";
|
||
import { getBackendBaseURL } from "@/core/config";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
|
||
|
||
const REWRITE_STEPS = [
|
||
{ key: "snapshot", label: "固定原文版本" },
|
||
{ key: "requirements", label: "理解改写要求" },
|
||
{ key: "writing", label: "流式重写 Markdown" },
|
||
{ key: "illustrations", label: "生成文档配图" },
|
||
{ key: "validation", label: "校验文档" },
|
||
{ key: "comparison", label: "对比原文并生成说明" },
|
||
] as const;
|
||
|
||
const GENERATE_STEPS = [
|
||
{ key: "snapshot", label: "固定来源报告快照" },
|
||
{ key: "requirements", label: "确认用户生成需求" },
|
||
{ key: "writing", label: "流式生成 Markdown" },
|
||
{ key: "illustrations", label: "生成文档配图" },
|
||
{ key: "validation", label: "校验新报告" },
|
||
{ key: "comparison", label: "生成报告说明" },
|
||
] as const;
|
||
|
||
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
|
||
switch (stage) {
|
||
case "snapshot":
|
||
return 0;
|
||
case "requirements":
|
||
return 1;
|
||
case "writing":
|
||
return 2;
|
||
case "illustrating":
|
||
return 3;
|
||
case "validation":
|
||
return 4;
|
||
case "committing":
|
||
case "completed":
|
||
return 6;
|
||
case "conflict":
|
||
case "error":
|
||
case "cancelled":
|
||
return 5;
|
||
default:
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
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);
|
||
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
|
||
const contentRef = useRef<HTMLPreElement>(null);
|
||
const autoScrollEnabledRef = useRef(true);
|
||
const previousActiveRef = useRef(active);
|
||
const lastAutoScrollTopRef = useRef<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (active && text) setOpen(true);
|
||
}, [active, text]);
|
||
|
||
useEffect(() => {
|
||
if (active && !previousActiveRef.current) {
|
||
autoScrollEnabledRef.current = true;
|
||
lastAutoScrollTopRef.current = null;
|
||
setAutoScrollPaused(false);
|
||
}
|
||
previousActiveRef.current = active;
|
||
}, [active]);
|
||
|
||
const scrollToLatestThinking = useCallback(() => {
|
||
if (!autoScrollEnabledRef.current) return;
|
||
const content = contentRef.current;
|
||
if (!content) return;
|
||
content.scrollTop = content.scrollHeight;
|
||
lastAutoScrollTopRef.current = content.scrollTop;
|
||
}, []);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!active || !open || !text) return;
|
||
scrollToLatestThinking();
|
||
}, [active, open, scrollToLatestThinking, text]);
|
||
|
||
useEffect(() => {
|
||
if (!active || !open || !text) return;
|
||
const content = contentRef.current;
|
||
if (!content) return;
|
||
let frame: number | null = null;
|
||
const observer = new MutationObserver(() => {
|
||
if (frame !== null) cancelAnimationFrame(frame);
|
||
frame = requestAnimationFrame(() => {
|
||
frame = null;
|
||
scrollToLatestThinking();
|
||
});
|
||
});
|
||
observer.observe(content, { childList: true, characterData: true, subtree: true });
|
||
return () => {
|
||
observer.disconnect();
|
||
if (frame !== null) cancelAnimationFrame(frame);
|
||
};
|
||
}, [active, open, scrollToLatestThinking, text]);
|
||
|
||
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
|
||
if (!active || !autoScrollEnabledRef.current) return;
|
||
const lastAutoScrollTop = lastAutoScrollTopRef.current;
|
||
if (
|
||
lastAutoScrollTop === null ||
|
||
Math.abs(event.currentTarget.scrollTop - lastAutoScrollTop) > 1
|
||
) {
|
||
autoScrollEnabledRef.current = false;
|
||
setAutoScrollPaused(true);
|
||
}
|
||
};
|
||
|
||
const resumeAutoScroll = () => {
|
||
autoScrollEnabledRef.current = true;
|
||
setAutoScrollPaused(false);
|
||
requestAnimationFrame(scrollToLatestThinking);
|
||
};
|
||
|
||
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>
|
||
<div className="relative mt-1">
|
||
<pre
|
||
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
|
||
onScroll={pauseAutoScrollOnManualScroll}
|
||
ref={contentRef}
|
||
>
|
||
{text}
|
||
</pre>
|
||
{active && autoScrollPaused && (
|
||
<Button
|
||
aria-label="恢复思考内容自动下滑"
|
||
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
|
||
onClick={resumeAutoScroll}
|
||
size="sm"
|
||
title="恢复自动下滑"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<ChevronsDownIcon className="size-3.5" />
|
||
自动下滑
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
);
|
||
}
|
||
|
||
export function DocumentRewriteTimeline({
|
||
item,
|
||
}: {
|
||
item: DocumentRewritePresentation;
|
||
}) {
|
||
const isNewReportGeneration = item.operation === "generate";
|
||
const steps = isNewReportGeneration ? GENERATE_STEPS : REWRITE_STEPS;
|
||
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);
|
||
|
||
const reapplying = Boolean(item.revertedAt);
|
||
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
||
const canUseLocalFallback =
|
||
!reapplying &&
|
||
!activeVersionId &&
|
||
item.source.kind === "artifact" &&
|
||
typeof item.source.path === "string" &&
|
||
item.localFallbackAvailable &&
|
||
typeof item.committedHash === "string";
|
||
|
||
const handleUndo = async () => {
|
||
if ((!activeVersionId && !canUseLocalFallback) || undoing) return;
|
||
if (canUseLocalFallback && item.source.kind === "artifact" && item.source.path && item.committedHash) {
|
||
setUndoing(true);
|
||
try {
|
||
const fallback = loadArtifactRewriteFallback({
|
||
threadId: item.source.id,
|
||
path: item.source.path,
|
||
committedHash: item.committedHash,
|
||
});
|
||
if (!fallback) {
|
||
console.error("本地 AI 全文改写撤销备份不可用,未修改当前文件。");
|
||
return;
|
||
}
|
||
const currentResponse = await apiFetch(
|
||
urlOfArtifact({ filepath: item.source.path, threadId: item.source.id }),
|
||
);
|
||
if (!currentResponse.ok) {
|
||
throw new Error(`读取当前文件失败(${currentResponse.status})。`);
|
||
}
|
||
const currentHash = await sha256DocumentContent(await currentResponse.text());
|
||
if (!currentHash || currentHash !== fallback.committedHash) {
|
||
console.error("当前文件已在改写后发生变化,已跳过本地 AI 全文改写撤销。");
|
||
return;
|
||
}
|
||
const guard = new CustomEvent<{
|
||
source: DocumentRewritePresentation["source"];
|
||
rewrittenContent: string | null;
|
||
unchanged: boolean;
|
||
}>(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, {
|
||
detail: {
|
||
source: item.source,
|
||
rewrittenContent: item.rewrittenContent,
|
||
unchanged: false,
|
||
},
|
||
});
|
||
window.dispatchEvent(guard);
|
||
if (!guard.detail.unchanged) {
|
||
console.error("沙箱中存在改写后的未保存编辑,已跳过本地 AI 全文改写撤销。");
|
||
return;
|
||
}
|
||
await updateArtifactContent({
|
||
filepath: item.source.path,
|
||
threadId: item.source.id,
|
||
content: fallback.originalContent,
|
||
});
|
||
clearArtifactRewriteFallback(item.source.id, item.source.path);
|
||
presentation?.update(item.id, {
|
||
localFallbackAvailable: false,
|
||
revertedAt: new Date().toISOString(),
|
||
});
|
||
window.dispatchEvent(
|
||
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
|
||
detail: { source: item.source, content: fallback.originalContent },
|
||
}),
|
||
);
|
||
} catch (error) {
|
||
console.error("本地 AI 全文改写撤销失败:", error);
|
||
} finally {
|
||
setUndoing(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!activeVersionId) return;
|
||
const baseUrl = getBackendBaseURL();
|
||
const endpoint =
|
||
item.source.kind === "artifact"
|
||
? `${baseUrl}/api/writing/document-rewrite/versions/${encodeURIComponent(activeVersionId)}/undo`
|
||
: `${baseUrl}/api/deep-research/sessions/${encodeURIComponent(item.source.id)}/document-rewrite/versions/${encodeURIComponent(activeVersionId)}/undo`;
|
||
const payload =
|
||
item.source.kind === "artifact"
|
||
? { threadId: item.source.id, path: item.source.path }
|
||
: undefined;
|
||
setUndoing(true);
|
||
try {
|
||
const response = await apiFetch(endpoint, {
|
||
method: "POST",
|
||
headers: payload ? { "Content-Type": "application/json" } : undefined,
|
||
body: payload ? JSON.stringify(payload) : undefined,
|
||
});
|
||
const result = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||
if (!response.ok) {
|
||
throw new Error(
|
||
typeof result.detail === "string" ? result.detail : "撤销改写失败,请稍后重试。",
|
||
);
|
||
}
|
||
const content = typeof result.content === "string" ? result.content : "";
|
||
const nextInverseVersionId =
|
||
typeof result.redoVersionId === "string" ? result.redoVersionId : null;
|
||
presentation?.update(
|
||
item.id,
|
||
reapplying
|
||
? { versionId: nextInverseVersionId, redoVersionId: null, revertedAt: null }
|
||
: { redoVersionId: nextInverseVersionId, revertedAt: new Date().toISOString() },
|
||
);
|
||
window.dispatchEvent(
|
||
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
|
||
detail: { source: item.source, content },
|
||
}),
|
||
);
|
||
toast.success(
|
||
isNewReportGeneration
|
||
? reapplying
|
||
? "已重新应用本次生成的新报告版本。"
|
||
: "已恢复本次生成前的新报告快照。"
|
||
: reapplying
|
||
? "已重新应用本次 AI 全文改写版本。"
|
||
: "已恢复到本次 AI 全文改写前的版本。",
|
||
);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
|
||
} finally {
|
||
setUndoing(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<article className="w-full rounded-2xl border bg-card shadow-sm">
|
||
<header className="flex items-start gap-3 border-b px-5 py-4">
|
||
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-full">
|
||
<FilePenLineIcon className="size-4" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="font-semibold">
|
||
{isNewReportGeneration ? "生成新报告" : "AI 全文改写"} · {item.fileName}
|
||
</h3>
|
||
<span
|
||
className={cn(
|
||
"rounded-full px-2 py-0.5 text-xs",
|
||
completed
|
||
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300"
|
||
: failed
|
||
? "bg-destructive/10 text-destructive"
|
||
: "bg-primary/10 text-primary",
|
||
)}
|
||
>
|
||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
|
||
</span>
|
||
</div>
|
||
<p className="text-muted-foreground mt-1 text-sm">
|
||
{item.instruction}
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="space-y-3 px-5 py-4">
|
||
{steps.map((step, index) => {
|
||
const active = !isTerminal(item.stage) && 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
|
||
className={cn(
|
||
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border",
|
||
done
|
||
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-400/30 dark:bg-emerald-400/10"
|
||
: active
|
||
? "border-primary/30 bg-primary/10 text-primary"
|
||
: "border-border text-muted-foreground",
|
||
)}
|
||
>
|
||
{done ? (
|
||
<CheckCircle2Icon className="size-4" />
|
||
) : active ? (
|
||
<Loader2Icon className="size-4 animate-spin" />
|
||
) : (
|
||
<span className="text-xs">{index + 1}</span>
|
||
)}
|
||
</div>
|
||
<div className="min-w-0 pt-0.5">
|
||
<div className="text-sm font-medium">{step.label}</div>
|
||
{step.key === "snapshot" && item.originalCharCount > 0 && (
|
||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||
已冻结 {item.originalCharCount.toLocaleString()} 字{isNewReportGeneration ? "来源报告" : "原文"}快照。
|
||
</p>
|
||
)}
|
||
{step.key === "requirements" && (
|
||
item.requirementsPlan ? (
|
||
<div
|
||
aria-live={active ? "polite" : undefined}
|
||
className="bg-muted/45 text-muted-foreground mt-2 max-w-3xl rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap"
|
||
role={active ? "status" : undefined}
|
||
>
|
||
{item.requirementsPlan}
|
||
</div>
|
||
) : active ? (
|
||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||
{isNewReportGeneration
|
||
? "正在确认新报告的结构、资料范围与生成要求…"
|
||
: "正在识别改写目标、保留约束与执行方式…"}
|
||
</p>
|
||
) : (
|
||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||
{isNewReportGeneration ? "已根据所选结构确定生成计划。" : "已根据改写要求确定执行计划。"}
|
||
</p>
|
||
)
|
||
)}
|
||
{step.key === "writing" && (
|
||
item.writtenCharCount > 0 ? (
|
||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
|
||
</p>
|
||
) : active ? (
|
||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||
{isNewReportGeneration
|
||
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
||
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
|
||
</p>
|
||
) : null
|
||
)}
|
||
{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(";")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{item.summary && (
|
||
<div className="border-t px-5 py-4 text-sm leading-6">
|
||
<div className="mb-2 font-semibold">{isNewReportGeneration ? "生成说明" : "改写说明"}</div>
|
||
<p>
|
||
{isNewReportGeneration ? "来源报告" : "原文"} {item.summary.originalCharCount.toLocaleString()} 字,
|
||
{isNewReportGeneration ? "新报告" : "改写后"}{" "}
|
||
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
|
||
{item.summary.changedBlockCount} 个内容块。
|
||
</p>
|
||
<p className="text-muted-foreground">
|
||
标题数量 {item.summary.headingCountBefore} → {item.summary.headingCountAfter},
|
||
使用模型:{item.summary.modelName}。
|
||
</p>
|
||
{item.summary.requestedOptimizations?.length ? (
|
||
<p className="text-muted-foreground">
|
||
本次优化:{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 ||
|
||
item.summary.headingChanges.removed.length > 0 ||
|
||
item.summary.headingChanges.levelWarnings.length > 0
|
||
) && (
|
||
<p className="text-muted-foreground">
|
||
{item.summary.headingChanges.reordered ? "已调整章节顺序;" : ""}
|
||
{item.summary.headingChanges.added.length
|
||
? `新增标题:${item.summary.headingChanges.added.join("、")};`
|
||
: ""}
|
||
{item.summary.headingChanges.removed.length
|
||
? `移除标题:${item.summary.headingChanges.removed.join("、")};`
|
||
: ""}
|
||
{item.summary.headingChanges.levelWarnings.join(";")}
|
||
</p>
|
||
)}
|
||
{item.originalContent && item.rewrittenContent && (
|
||
<Button
|
||
className="mt-3 mr-2"
|
||
onClick={() => setDiffOpen(true)}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
查看前后对比
|
||
</Button>
|
||
)}
|
||
{isNewReportGeneration && completed && item.source.kind === "deep_research_report" && (
|
||
<Button
|
||
className="mt-3 mr-2"
|
||
onClick={() => {
|
||
window.dispatchEvent(
|
||
new CustomEvent(DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT, {
|
||
detail: { sessionId: item.source.id },
|
||
}),
|
||
);
|
||
}}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<FilePenLineIcon className="mr-1.5 size-3.5" />
|
||
改写新报告
|
||
</Button>
|
||
)}
|
||
{completed && (activeVersionId || canUseLocalFallback) && (
|
||
<Button
|
||
className="mt-3"
|
||
disabled={undoing}
|
||
onClick={() => void handleUndo()}
|
||
size="sm"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
|
||
{isNewReportGeneration
|
||
? reapplying
|
||
? "重新应用本次生成"
|
||
: "撤销本次生成"
|
||
: reapplying
|
||
? "重新应用此次改写"
|
||
: "撤销此次改写"}
|
||
</Button>
|
||
)}
|
||
{item.revertedAt && (
|
||
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||
已恢复为{isNewReportGeneration ? "生成前" : "改写前"}版本。
|
||
{item.redoVersionId ? `可重新应用本次${isNewReportGeneration ? "生成" : "改写"}。` : ""}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{item.error && (
|
||
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
|
||
<XCircleIcon className="size-4 shrink-0" />
|
||
{item.error}
|
||
</div>
|
||
)}
|
||
<DocumentRewriteDiffDialog
|
||
fileName={item.fileName}
|
||
onOpenChange={setDiffOpen}
|
||
open={diffOpen}
|
||
original={item.originalContent}
|
||
rewritten={item.rewrittenContent ?? ""}
|
||
/>
|
||
</article>
|
||
);
|
||
}
|