功能说明:\n- 新增报告协作页(Report Collaboration)核心状态层、消息适配与会话面板,支持按节点/阶段选择和协作上下文展示\n- 补齐 report-collaboration API 与类型定义,并接入页面路由/构建路径,形成可运行的协作链路\n- 重构多轮 RAG 与 write/artefact 消息分组逻辑,优化 AgentChat 与 DeepResearch 场景下的消息展示顺序与可读性\n- 新增/更新 DeepResearch 前端页面(Workbench、报告配置、沙箱联动)以承接报告生成与复用能力\n- 增加报表协作相关单测,覆盖事件归并、消息适配与关键 reducer 行为
783 lines
33 KiB
TypeScript
783 lines
33 KiB
TypeScript
"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>
|
||
);
|
||
}
|