Files
deerflow-code/frontend-web/src/pages/deep-research/ReportConfigCard.tsx
T
董宏凯 438772f0b7 feat(frontend): 上线报告协作与多轮 RAG 消息聚合体验
功能说明:\n- 新增报告协作页(Report Collaboration)核心状态层、消息适配与会话面板,支持按节点/阶段选择和协作上下文展示\n- 补齐 report-collaboration API 与类型定义,并接入页面路由/构建路径,形成可运行的协作链路\n- 重构多轮 RAG 与 write/artefact 消息分组逻辑,优化 AgentChat 与 DeepResearch 场景下的消息展示顺序与可读性\n- 新增/更新 DeepResearch 前端页面(Workbench、报告配置、沙箱联动)以承接报告生成与复用能力\n- 增加报表协作相关单测,覆盖事件归并、消息适配与关键 reducer 行为
2026-09-04 23:18:27 +08:00

783 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* 报告配置卡片(用户协助卡片)。
*
* 对话式收集完成后弹出:预填原模式下拉(快速/标准/详细/深度/多智能体)与
* 原高级配置对话框的全部选项(语言/语气/结构/配图/预算等),由用户确认后
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
*/
import { useEffect, useMemo, useState } from "react";
import {
ChevronRight,
ImagePlus,
Loader2,
MessageSquarePlus,
SlidersHorizontal,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
type ResearchMode,
type ResearchTone,
} from "@/strategy-components/api/deep-research";
import {
DEFAULT_REPORT_STRUCTURE_MODE,
normalizeReportStructureMode,
normalizeRetrievalDirections,
normalizeRetrievalSkills,
REPORT_STRUCTURE_MODE_OPTIONS,
type ReportStructureMode,
useReportStructures,
} from "@/strategy-components/api/report-structures";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
const MODE_LABELS: Record<ResearchMode, string> = {
basic: "标准",
quick: "快速",
detailed: "详细",
deep: "深度",
multi_agent: "多智能体",
};
const MODE_HINTS: Record<ResearchMode, string> = {
basic: "完整单遍写作,结构与篇幅均衡",
quick: "短报告单遍成文,最快出结果",
detailed: "按主题分节综合,篇幅更长",
deep: "迭代深挖补充检索后再成文",
multi_agent: "分节撰写并附加修订审稿轮",
};
export type AdvancedResearchOptions = {
language: "zh-CN" | "en-US";
tone: ResearchTone;
maxSearchResults: string;
maxIterations: string;
maxConcurrency: string;
maxContextWords: string;
maxSubtopics: string;
deepBreadth: string;
deepDepth: string;
maxRevisionLoops: string;
maxGeneratedImages: string;
thinkingEnabled: boolean;
customOutline: string;
structureMode: ReportStructureMode;
retrievalDirections: string[];
retrievalSkills: string[];
};
/**
* A deliberately domain-neutral structure which gives lightweight models a
* dependable writing scaffold without forcing market-analysis terminology on
* every research topic. Users can edit or replace it in the advanced dialog.
*/
export const DEFAULT_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
# 一、研究对象与事实基础
## 1.1 概念、范围与背景
界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
## 1.2 已有事实与证据
按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
## 1.3 当前状态与主要特征
归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
# 二、问题形成与作用机制
## 2.1 直接原因与触发条件
分析近期事件、条件变化或关键节点如何推动问题出现或演变。
## 2.2 深层影响因素
从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
## 2.3 因果关系与相互作用
说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
# 三、影响、问题与不同观点
## 3.1 对研究对象的直接影响
从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
## 3.2 对相关主体与外部环境的影响
说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
## 3.3 主要争议与解释差异
并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
# 四、比较、趋势与不确定性
## 4.1 可比案例或横向比较
在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
## 4.2 发展趋势与关键变量
识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
## 4.3 风险情景与不确定性
说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
# 五、结论、建议与后续关注
## 5.1 核心结论
用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
## 5.2 可行建议或应对方向
仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
## 5.3 资料局限与后续关注
列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。`;
/**
* A causal, chapter-to-chapter alternative for topics where the report should
* read as one continuous argument rather than a set of parallel dimensions.
* Each chapter explicitly takes the previous chapter's conclusion as input.
*/
export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
# 引言
用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
# 一、事实基线:问题是什么
## 1.1 概念界定与研究范围
明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
## 1.2 关键事实、数据与证据
按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
## 1.3 基线判断与待解问题
基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
# 二、形成机制:为什么会这样
## 2.1 直接原因与触发条件
从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
## 2.2 深层条件与关键约束
结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
## 2.3 因果链条与关键变量
把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
# 三、结果与影响:会带来什么
## 3.1 直接结果与短期影响
依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
## 3.2 传导效应与中长期影响
继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
## 3.3 权衡、分歧与边界
比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
# 四、趋势与应对:接下来怎么办
## 4.1 未来走势与观察指标
从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
## 4.2 可选方案与适用条件
针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
## 4.3 风险情景与验证重点
说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
# 五、综合结论与后续关注
## 5.1 核心结论
按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
## 5.2 局限与后续研究
列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
# 参考资料
仅列出本报告实际使用的来源,并与正文引用保持一致。
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
const REPORT_STRUCTURE_VALUE_PREFIX = "structure:";
function matchingReportStructureId(
outline: string,
structures: Array<{ id: string; content: string }>,
): string | null {
const normalized = outline.trim();
if (!normalized) return null;
return structures.find((structure) => structure.content.trim() === normalized)?.id ?? null;
}
/**
* The concise structure picker is deliberately usable in the confirmation
* card. Users should not have to open advanced settings merely to choose how
* their report is organised.
*/
export function ReportStructureSelect({
id,
value,
onChange,
onStructureModeChange,
onRetrievalDirectionsChange,
onRetrievalSkillsChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
onStructureModeChange?: (mode: ReportStructureMode) => void;
onRetrievalDirectionsChange?: (directions: string[]) => void;
onRetrievalSkillsChange?: (skills: string[]) => void;
}) {
// 与「工作流配置」共用报告结构库;创建/编辑/启停后 React Query 的失效会
// 自动刷新本下拉,避免维护一份会逐渐过时的前端硬编码模板。
const structuresQuery = useReportStructures({ status: "enabled" });
const structures = structuresQuery.data ?? [];
const selectedStructureId = matchingReportStructureId(value, structures);
const templateValue = selectedStructureId
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
: CUSTOM_REPORT_STRUCTURE_VALUE;
const selectTemplate = (next: string) => {
if (next === CUSTOM_REPORT_STRUCTURE_VALUE) {
// “自定义” is an intentional blank starting point. Saved structures are
// always available as one-click scaffolds whenever the user wants them.
onChange("");
onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE);
onRetrievalDirectionsChange?.([]);
onRetrievalSkillsChange?.([]);
return;
}
if (!next.startsWith(REPORT_STRUCTURE_VALUE_PREFIX)) return;
const structureId = next.slice(REPORT_STRUCTURE_VALUE_PREFIX.length);
const structure = structures.find((item) => item.id === structureId);
if (structure) {
onChange(structure.content);
onStructureModeChange?.(structure.structureMode);
onRetrievalDirectionsChange?.(structure.retrievalDirections);
onRetrievalSkillsChange?.(structure.retrievalSkills);
}
};
return (
<div>
<label htmlFor={`${id}-template`} className="mb-1.5 block text-xs font-medium">
报告结构
</label>
<Select value={templateValue} onValueChange={selectTemplate}>
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
<SelectValue />
</SelectTrigger>
<SelectContent>
{structuresQuery.isLoading && (
<SelectItem value="__report_structures_loading__" disabled>正在加载工作流结构…</SelectItem>
)}
{structuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
)}
{structures.map((structure) => (
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
{structure.title}
</SelectItem>
))}
<SelectItem value={CUSTOM_REPORT_STRUCTURE_VALUE}>自定义结构</SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selectedStructureId
? "已选工作流结构;可在高级配置中继续编辑章节内容。"
: "当前使用自定义结构;可在高级配置中编辑章节内容。"}
</p>
</div>
);
}
export function StructureModeSelect({
id,
value,
onChange,
}: {
id?: string;
value: ReportStructureMode;
onChange: (value: ReportStructureMode) => void;
}) {
const selected = REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === value)
?? REPORT_STRUCTURE_MODE_OPTIONS[1];
return (
<div>
<label htmlFor={id} className="mb-1.5 block text-xs font-medium">
结构约束
</label>
<Select value={value} onValueChange={(next) => onChange(normalizeReportStructureMode(next))}>
<SelectTrigger id={id} className="h-9 text-sm" aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{selected.description}
</p>
</div>
);
}
/** Shared by the initial settings dialog and the write-confirmation card. */
export function ReportOutlineEditor({
id,
value,
onChange,
structureMode,
onStructureModeChange,
onRetrievalDirectionsChange,
onRetrievalSkillsChange,
showStructureSelect = true,
}: {
id: string;
value: string;
onChange: (value: string) => void;
structureMode?: ReportStructureMode;
onStructureModeChange?: (mode: ReportStructureMode) => void;
onRetrievalDirectionsChange?: (directions: string[]) => void;
onRetrievalSkillsChange?: (skills: string[]) => void;
/** The confirmation card already exposes its picker in the primary form. */
showStructureSelect?: boolean;
}) {
return (
<div className="space-y-2">
{showStructureSelect && (
<div className="grid gap-3 sm:grid-cols-2">
<ReportStructureSelect
id={id}
value={value}
onChange={onChange}
onStructureModeChange={onStructureModeChange}
onRetrievalDirectionsChange={onRetrievalDirectionsChange}
onRetrievalSkillsChange={onRetrievalSkillsChange}
/>
{onStructureModeChange && (
<StructureModeSelect
id={`${id}-mode`}
value={structureMode ?? DEFAULT_REPORT_STRUCTURE_MODE}
onChange={onStructureModeChange}
/>
)}
</div>
)}
<label htmlFor={id} className="block pt-1 text-xs font-medium">
报告结构(可编辑)
</label>
<Textarea
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
maxLength={4000}
className="min-h-28 resize-y text-xs"
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据"}
/>
<p className="text-[10px] leading-4 text-muted-foreground">
支持 Markdown 标题或编号列表。选择“自定义结构”会清空后从头编辑。
</p>
</div>
);
}
export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
language: "zh-CN",
tone: "objective",
maxSearchResults: "8",
maxIterations: "3",
maxConcurrency: "3",
maxContextWords: "24000",
maxSubtopics: "8",
deepBreadth: "4",
deepDepth: "2",
maxRevisionLoops: "2",
maxGeneratedImages: "2",
thinkingEnabled: false,
customOutline: DEFAULT_REPORT_OUTLINE,
structureMode: DEFAULT_REPORT_STRUCTURE_MODE,
retrievalDirections: [],
retrievalSkills: [],
};
/** Seed the card's editable options from the frozen session config snapshot. */
export function advancedOptionsFromConfig(config?: Record<string, unknown>): AdvancedResearchOptions {
const numberish = (key: string, fallback: string): string => {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) ? String(value) : fallback;
};
return {
language: config?.language === "en-US" ? "en-US" : "zh-CN",
tone: (typeof config?.tone === "string" ? config.tone : "objective") as ResearchTone,
maxSearchResults: numberish("max_search_results_per_query", DEFAULT_ADVANCED_OPTIONS.maxSearchResults),
maxIterations: numberish("max_iterations", DEFAULT_ADVANCED_OPTIONS.maxIterations),
maxConcurrency: numberish("max_concurrency", DEFAULT_ADVANCED_OPTIONS.maxConcurrency),
maxContextWords: numberish("max_context_words", DEFAULT_ADVANCED_OPTIONS.maxContextWords),
maxSubtopics: numberish("max_subtopics", DEFAULT_ADVANCED_OPTIONS.maxSubtopics),
deepBreadth: numberish("deep_breadth", DEFAULT_ADVANCED_OPTIONS.deepBreadth),
deepDepth: numberish("deep_depth", DEFAULT_ADVANCED_OPTIONS.deepDepth),
maxRevisionLoops: numberish("max_revision_loops", DEFAULT_ADVANCED_OPTIONS.maxRevisionLoops),
maxGeneratedImages: numberish("max_generated_images", DEFAULT_ADVANCED_OPTIONS.maxGeneratedImages),
thinkingEnabled: config?.thinking_enabled === true,
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
? config.custom_outline
: DEFAULT_REPORT_OUTLINE,
structureMode: normalizeReportStructureMode(config?.structure_mode),
retrievalDirections: normalizeRetrievalDirections(config?.retrieval_directions),
retrievalSkills: normalizeRetrievalSkills(config?.retrieval_skills),
};
}
function boundedInteger(value: string, fallback: number, min: number, max: number): number {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
}
/** The card's choices as the job config override sent with entry:"chat". */
export function buildReportCardConfig(
mode: ResearchMode,
generateImages: boolean,
options: AdvancedResearchOptions,
): Record<string, unknown> {
const config: Record<string, unknown> = {
mode,
generate_images: generateImages,
language: options.language,
tone: options.tone,
thinking_enabled: options.thinkingEnabled,
max_search_results_per_query: boundedInteger(options.maxSearchResults, 8, 1, 20),
max_iterations: boundedInteger(options.maxIterations, 3, 1, 10),
max_concurrency: boundedInteger(options.maxConcurrency, 3, 1, 8),
max_context_words: boundedInteger(options.maxContextWords, 24_000, 500, 120_000),
};
if (mode === "detailed" || mode === "multi_agent") {
config.max_subtopics = boundedInteger(options.maxSubtopics, 8, 1, 20);
}
if (mode === "deep") {
config.deep_breadth = boundedInteger(options.deepBreadth, 4, 1, 8);
config.deep_depth = boundedInteger(options.deepDepth, 2, 1, 4);
}
if (mode === "multi_agent") {
config.max_revision_loops = boundedInteger(options.maxRevisionLoops, 2, 0, 5);
}
if (generateImages) config.max_generated_images = boundedInteger(options.maxGeneratedImages, 2, 1, 4);
if (options.customOutline.trim()) config.custom_outline = options.customOutline.trim().slice(0, 4_000);
config.structure_mode = normalizeReportStructureMode(options.structureMode);
config.retrieval_directions = normalizeRetrievalDirections(options.retrievalDirections);
config.retrieval_skills = normalizeRetrievalSkills(options.retrievalSkills);
return config;
}
export function ReportConfigCard({
availableModes,
defaultMode,
defaultOptions,
/**
* A report structure chosen from the collector's workflow configuration
* while this card is open. It supersedes only the outline, leaving choices
* already made in this card intact.
*/
outlineOverride,
structureModeOverride,
retrievalDirectionsOverride,
retrievalSkillsOverride,
defaultGenerateImages,
modelLabel,
materialCount,
imageGenerationAvailable,
imageLimit,
submitting,
onConfirm,
onContinueCollecting,
}: {
availableModes: ResearchMode[];
defaultMode: ResearchMode;
defaultOptions: AdvancedResearchOptions;
outlineOverride?: string;
structureModeOverride?: ReportStructureMode;
retrievalDirectionsOverride?: string[];
retrievalSkillsOverride?: string[];
defaultGenerateImages: boolean;
modelLabel: string;
/** Number of unique, harvestable materials visible in the collector thread. */
materialCount: number;
imageGenerationAvailable: boolean;
imageLimit: number;
submitting: boolean;
onConfirm: (config: Record<string, unknown>, extras?: { writeWithoutMaterials?: boolean }) => void;
onContinueCollecting: () => void;
}) {
const [mode, setMode] = useState<ResearchMode>(
availableModes.includes(defaultMode) ? defaultMode : (availableModes[0] ?? "basic"),
);
const [options, setOptions] = useState<AdvancedResearchOptions>(() => ({
...defaultOptions,
customOutline: outlineOverride ?? defaultOptions.customOutline,
structureMode: structureModeOverride ?? defaultOptions.structureMode,
retrievalDirections: retrievalDirectionsOverride ?? defaultOptions.retrievalDirections,
retrievalSkills: retrievalSkillsOverride ?? defaultOptions.retrievalSkills,
}));
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [writeWithoutMaterials, setWriteWithoutMaterials] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
// The collector composer and this confirmation card are both reachable while
// a session is awaiting report writing. Selecting a structure in the former
// must be reflected here, but should not reset the card's mode, image switch,
// or any other in-progress edits.
useEffect(() => {
if (
outlineOverride === undefined
&& structureModeOverride === undefined
&& retrievalDirectionsOverride === undefined
&& retrievalSkillsOverride === undefined
) return;
setOptions((current) => {
const nextOutline = outlineOverride ?? current.customOutline;
const nextMode = structureModeOverride ?? current.structureMode;
const nextDirections = retrievalDirectionsOverride ?? current.retrievalDirections;
const nextSkills = retrievalSkillsOverride ?? current.retrievalSkills;
if (
current.customOutline === nextOutline
&& current.structureMode === nextMode
&& current.retrievalDirections.join("\n") === nextDirections.join("\n")
&& current.retrievalSkills.join("\n") === nextSkills.join("\n")
) return current;
return {
...current,
customOutline: nextOutline,
structureMode: nextMode,
retrievalDirections: nextDirections,
retrievalSkills: nextSkills,
};
});
}, [outlineOverride, structureModeOverride, retrievalDirectionsOverride, retrievalSkillsOverride]);
const modes = useMemo(
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
[availableModes],
);
return (
<div className="rounded-2xl rounded-tl-md border border-primary/25 bg-card p-4 shadow-sm">
<div className="flex items-start gap-2.5">
<div className="mt-0.5 rounded-full bg-primary/10 p-1.5 text-primary">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">资料收集完成,请确认报告写法</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
下方选项沿用原研究配置并可调整;确认后将基于以上对话收集到的资料撰写报告。
{modelLabel ? <>写作模型:<span className="font-medium text-foreground">{modelLabel}</span>。</> : null}
</p>
<div className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] ${writeWithoutMaterials ? "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200" : "border-primary/15 bg-primary/[0.06] text-primary"}`}>
<span className="font-medium">已收集 {materialCount} 条资料</span>
<span className={writeWithoutMaterials ? "opacity-80" : "text-primary/70"}>
{writeWithoutMaterials ? "本次不送入写作,按无素材模式成稿" : "将作为本次报告的写作依据"}
</span>
</div>
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label className="block">
<span className="mb-1.5 block text-xs font-medium">报告模式</span>
<Select value={mode} onValueChange={(value) => setMode(value as ResearchMode)}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
{modes.map((item) => (
<SelectItem key={item} value={item}>{MODE_LABELS[item]}</SelectItem>
))}
</SelectContent>
</Select>
<span className="mt-1 block text-[11px] text-muted-foreground">{MODE_HINTS[mode]}</span>
</label>
<ReportStructureSelect
id="report-card-structure"
value={options.customOutline}
onChange={(value) => update("customOutline", value)}
onStructureModeChange={(mode) => update("structureMode", mode)}
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
/>
<StructureModeSelect
id="report-card-structure-mode"
value={options.structureMode}
onChange={(mode) => update("structureMode", mode)}
/>
{imageGenerationAvailable && (
<div>
<span className="mb-1.5 block text-xs font-medium">报告配图</span>
<button
type="button"
aria-pressed={generateImages}
onClick={() => setGenerateImages((value) => !value)}
className={`inline-flex h-9 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors ${generateImages ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted"}`}
>
<ImagePlus className="h-3.5 w-3.5" />
{generateImages ? `生成配图(最多 ${imageLimit} 张)` : "不生成配图"}
</button>
</div>
)}
</div>
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4">
<input
type="checkbox"
checked={writeWithoutMaterials}
onChange={(event) => setWriteWithoutMaterials(event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block font-medium text-foreground">无素材写作</span>
忽略对话里已收集的资料,直接走无素材成稿,用来确认这条链路是否生效。已落库的来源不会被删除。
</span>
</label>
<div className="mt-3 overflow-hidden rounded-xl border border-border/70">
<button
type="button"
onClick={() => setAdvancedOpen((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs font-medium hover:bg-muted/40"
aria-expanded={advancedOpen}
>
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
高级配置
<span className="font-normal text-muted-foreground">语言、语气、检索预算与结构细调</span>
<ChevronRight className={`ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform ${advancedOpen ? "rotate-90" : ""}`} />
</button>
{advancedOpen && (
<div className="grid gap-3 border-t border-border/70 p-3 sm:grid-cols-2">
<CardSelect
label="输出语言"
value={options.language}
onChange={(value) => update("language", value as AdvancedResearchOptions["language"])}
options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]}
/>
<CardSelect
label="报告语气"
value={options.tone}
onChange={(value) => update("tone", value as ResearchTone)}
options={[
{ value: "objective", label: "客观" },
{ value: "formal", label: "正式" },
{ value: "analytical", label: "分析" },
{ value: "persuasive", label: "论证" },
]}
/>
<CardNumber label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(v) => update("maxSearchResults", v)} />
<CardNumber label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(v) => update("maxContextWords", v)} />
{(mode === "detailed" || mode === "multi_agent") && (
<CardNumber label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(v) => update("maxSubtopics", v)} />
)}
{mode === "deep" && (
<>
<CardNumber label="深挖广度" hint="1–8 路" value={options.deepBreadth} onChange={(v) => update("deepBreadth", v)} />
<CardNumber label="深挖层级" hint="1–4 层" value={options.deepDepth} onChange={(v) => update("deepDepth", v)} />
</>
)}
{mode === "multi_agent" && (
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
)}
<div className="sm:col-span-2">
<ReportOutlineEditor
id="report-card-outline"
value={options.customOutline}
onChange={(value) => update("customOutline", value)}
structureMode={options.structureMode}
onStructureModeChange={(mode) => update("structureMode", mode)}
onRetrievalDirectionsChange={(directions) => update("retrievalDirections", directions)}
onRetrievalSkillsChange={(skills) => update("retrievalSkills", skills)}
showStructureSelect={false}
/>
</div>
<div className="flex justify-end sm:col-span-2">
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(value, mode, directions, skills) => {
update("customOutline", value);
update("structureMode", mode);
update("retrievalDirections", directions);
update("retrievalSkills", skills);
}}
/>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
<input
type="checkbox"
checked={options.thinkingEnabled}
onChange={(event) => update("thinkingEnabled", event.target.checked)}
className="mt-0.5 accent-primary"
/>
<span>
<span className="block font-medium text-foreground">启用模型深度思考</span>
仅在当前模型支持时生效;会增加写作时延与模型消耗。
</span>
</label>
</div>
)}
</div>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<Button size="sm" variant="outline" onClick={onContinueCollecting} disabled={submitting}>
<MessageSquarePlus className="mr-1.5 h-3.5 w-3.5" />
继续补充资料
</Button>
<Button
size="sm"
disabled={submitting}
onClick={() => onConfirm(
buildReportCardConfig(mode, generateImages, options),
{ writeWithoutMaterials },
)}
>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
开始撰写报告
</Button>
</div>
</div>
);
function update<K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) {
setOptions((current) => ({ ...current, [key]: value }));
}
}
function CardSelect({ label, value, onChange, options }: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium">{label}</span>
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{options.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
</label>
);
}
function CardNumber({ label, hint, value, onChange }: {
label: string;
hint: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium">{label}</span>
<Input value={value} inputMode="numeric" onChange={(event) => onChange(event.target.value)} className="h-8 text-xs" />
<span className="mt-0.5 block text-[10px] text-muted-foreground">{hint}</span>
</label>
);
}