为深度研究报告结构增加检索方向与技能来源、对话式智能新增页与详情排版;澄清中间件在 after_model 可靠结束本轮,避免弱模型出卡后继续生成;任务按钮补充 host 跳转与相关深链能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { FilePenLineIcon, ImagePlusIcon, SparklesIcon } from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
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";
|
|
import { useReportStructures, REPORT_STRUCTURE_MODE_OPTIONS, normalizeReportStructureMode, type ReportStructureMode } from "@/strategy-components/api/report-structures";
|
|
|
|
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
|
|
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
|
|
const DEFAULT_GENERATE_INSTRUCTION = "基于已选资料按所选报告结构生成一篇新的完整报告;保持事实可核验,不补充没有依据的信息。";
|
|
|
|
export interface DocumentRewriteDialogInput {
|
|
instruction: string;
|
|
style: DocumentRewriteStyle;
|
|
modelName: string | null;
|
|
reportOutline: string;
|
|
structureMode: "fixed" | "adaptive";
|
|
generateImages: boolean;
|
|
maxGeneratedImages: number;
|
|
}
|
|
|
|
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
|
|
{
|
|
value: "formal_analysis",
|
|
label: "正式分析",
|
|
description: "客观、严谨,适合报告与研究材料。",
|
|
},
|
|
{
|
|
value: "professional",
|
|
label: "专业正式",
|
|
description: "适合业务文档和说明材料。",
|
|
},
|
|
{
|
|
value: "concise",
|
|
label: "精炼简洁",
|
|
description: "减少重复,保留核心信息。",
|
|
},
|
|
{
|
|
value: "readable",
|
|
label: "清晰易读",
|
|
description: "优化段落衔接和阅读节奏。",
|
|
},
|
|
];
|
|
|
|
export function DocumentRewriteDialog({
|
|
open,
|
|
onOpenChange,
|
|
fileName,
|
|
charCount,
|
|
imageGenerationAvailable = false,
|
|
maxGeneratedImages = 4,
|
|
initialReportOutline,
|
|
operationLabel,
|
|
operationDescription,
|
|
submitLabel,
|
|
operation,
|
|
onStart,
|
|
onRewriteOriginal,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
fileName: string;
|
|
charCount: number;
|
|
imageGenerationAvailable?: boolean;
|
|
maxGeneratedImages?: number;
|
|
/** A saved report structure selected before the dialog was opened. */
|
|
initialReportOutline?: string;
|
|
/** Optional copy for flows that create a new document instead of replacing one. */
|
|
operationLabel?: string;
|
|
operationDescription?: string;
|
|
submitLabel?: string;
|
|
/** ``generate`` creates an independent report; ``rewrite`` edits this report in place. */
|
|
operation?: "rewrite" | "generate";
|
|
onStart: (input: DocumentRewriteDialogInput) => void;
|
|
/** Optional secondary action for generation flows that rewrites the source report in place. */
|
|
onRewriteOriginal?: (input: DocumentRewriteDialogInput) => void;
|
|
}) {
|
|
const isNewReportGeneration = operation === "generate";
|
|
const [instruction, setInstruction] = useState(DEFAULT_REWRITE_INSTRUCTION);
|
|
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
|
|
const [modelName, setModelName] = useState<string | null>(null);
|
|
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
|
|
const [structureMode, setStructureMode] = useState<ReportStructureMode>("adaptive");
|
|
const [generateImages, setGenerateImages] = useState(false);
|
|
const [imageCount, setImageCount] = useState("2");
|
|
const reportStructuresQuery = useReportStructures({ status: "enabled" });
|
|
const reportStructures = reportStructuresQuery.data ?? [];
|
|
const selectedReportStructure = reportStructures.find((item) => item.id === reportStructureId);
|
|
const initialReportStructureId = useMemo(() => {
|
|
const outline = initialReportOutline?.trim();
|
|
if (!outline) return null;
|
|
return reportStructures.find((item) => item.content.trim() === outline)?.id ?? null;
|
|
}, [initialReportOutline, reportStructures]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setInstruction(isNewReportGeneration ? DEFAULT_GENERATE_INSTRUCTION : DEFAULT_REWRITE_INSTRUCTION);
|
|
setStyle("formal_analysis");
|
|
setModelName(null);
|
|
setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
|
|
setStructureMode("adaptive");
|
|
setGenerateImages(false);
|
|
setImageCount("2");
|
|
}, [isNewReportGeneration, open]);
|
|
|
|
// The structures query may resolve after the dialog opens. Only seed the
|
|
// value while the user still has the default selection, so a manual choice
|
|
// is never overwritten by a query refresh.
|
|
useEffect(() => {
|
|
if (!open || !initialReportStructureId) return;
|
|
setReportStructureId((current) => (
|
|
current === KEEP_CURRENT_REPORT_STRUCTURE ? initialReportStructureId : current
|
|
));
|
|
}, [initialReportStructureId, open]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedReportStructure) return;
|
|
setStructureMode(selectedReportStructure.structureMode);
|
|
}, [selectedReportStructure?.id, selectedReportStructure?.structureMode]);
|
|
|
|
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
|
|
const canStart = instruction.trim().length > 0;
|
|
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
|
|
if (!canStart) return;
|
|
handler({
|
|
instruction: instruction.trim(),
|
|
style,
|
|
modelName,
|
|
reportOutline: selectedReportStructure?.content ?? "",
|
|
structureMode,
|
|
generateImages,
|
|
maxGeneratedImages: Math.min(
|
|
Math.max(Number(imageCount) || 1, 1),
|
|
Math.max(Math.min(maxGeneratedImages, 4), 1),
|
|
),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<SparklesIcon className="size-5 text-primary" />
|
|
{operationLabel ?? "AI 全文改写"}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{operationDescription ?? "将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
|
|
<div className="font-medium">{fileName}</div>
|
|
<div className="text-muted-foreground mt-1">
|
|
{isNewReportGeneration ? "来源报告" : "原文"}约 {charCount.toLocaleString()} 字
|
|
</div>
|
|
</div>
|
|
|
|
<label className="grid gap-2 text-sm font-medium">
|
|
{isNewReportGeneration ? "生成要求" : "改写要求"}
|
|
<Textarea
|
|
value={instruction}
|
|
onChange={(event) => setInstruction(event.target.value)}
|
|
placeholder={isNewReportGeneration
|
|
? "例如:突出政策影响,并按所选结构组织章节;仅使用已选资料。"
|
|
: "例如:统一为正式行业分析语气,减少重复,不新增外部事实。"}
|
|
rows={4}
|
|
/>
|
|
</label>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<label className="grid gap-2 text-sm font-medium">
|
|
{isNewReportGeneration ? "报告风格" : "改写风格"}
|
|
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{STYLE_OPTIONS.map((item) => (
|
|
<SelectItem key={item.value} value={item.value}>
|
|
{item.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
|
|
</label>
|
|
|
|
<label className="grid gap-2 text-sm font-medium">
|
|
报告结构
|
|
<Select value={reportStructureId} onValueChange={setReportStructureId}>
|
|
<SelectTrigger aria-label="报告结构">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={KEEP_CURRENT_REPORT_STRUCTURE}>沿用当前结构</SelectItem>
|
|
{reportStructuresQuery.isLoading && (
|
|
<SelectItem value="__report_structures_loading__" disabled>
|
|
正在加载结构…
|
|
</SelectItem>
|
|
)}
|
|
{reportStructuresQuery.isError && (
|
|
<SelectItem value="__report_structures_error__" disabled>
|
|
报告结构库加载失败
|
|
</SelectItem>
|
|
)}
|
|
{reportStructures.map((structure) => (
|
|
<SelectItem key={structure.id} value={structure.id}>
|
|
{structure.title}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-muted-foreground text-xs">
|
|
{selectedReportStructure?.name || "默认保留原文的章节安排。"}
|
|
</span>
|
|
</label>
|
|
|
|
<label className="grid gap-2 text-sm font-medium">
|
|
结构约束
|
|
<Select
|
|
value={structureMode}
|
|
onValueChange={(value) => setStructureMode(normalizeReportStructureMode(value))}
|
|
>
|
|
<SelectTrigger aria-label="结构约束">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-muted-foreground text-xs">
|
|
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === structureMode)?.description}
|
|
</span>
|
|
</label>
|
|
|
|
<label className="grid gap-2 text-sm font-medium">
|
|
模型
|
|
<ModelNameSelect
|
|
value={modelName}
|
|
onChange={setModelName}
|
|
triggerClassName="h-10 text-sm"
|
|
defaultLabel="系统默认模型"
|
|
/>
|
|
<span className="text-muted-foreground text-xs">
|
|
仅影响本次{isNewReportGeneration ? "新报告生成" : "全文改写"}。
|
|
</span>
|
|
</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">
|
|
{isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
|
|
</span>
|
|
<span className="text-muted-foreground text-xs leading-5">
|
|
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<Switch
|
|
aria-label={isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
|
|
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)}>
|
|
取消
|
|
</Button>
|
|
{isNewReportGeneration && onRewriteOriginal && (
|
|
<Button
|
|
variant="outline"
|
|
disabled={!canStart}
|
|
onClick={() => startWith(onRewriteOriginal)}
|
|
>
|
|
<FilePenLineIcon />
|
|
改写原文
|
|
</Button>
|
|
)}
|
|
<Button
|
|
disabled={!canStart}
|
|
onClick={() => startWith(onStart)}
|
|
>
|
|
<SparklesIcon />
|
|
{submitLabel ?? "开始改写"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|