Merge branch 'master-nj' into 'master'
增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。 See merge request knowledge-base-bj/deerflow-code!25
This commit is contained in:
commit
33b0af333c
81 files changed
+8436
-1076
No files matched your search
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import {
|
||||
type DomainAgentMapping,
|
||||
normalizeDomainInput,
|
||||
@@ -20,10 +21,7 @@ import {
|
||||
|
||||
function makeRow(): DomainAgentMapping {
|
||||
return {
|
||||
id:
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
id: createClientId(),
|
||||
domain: "",
|
||||
agentId: "",
|
||||
enabled: true,
|
||||
|
||||
@@ -123,6 +123,7 @@ import {
|
||||
advancedOptionsFromConfig,
|
||||
buildReportCardConfig,
|
||||
ReportConfigCard,
|
||||
ReportOutlineEditor,
|
||||
} from "./ReportConfigCard";
|
||||
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
|
||||
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
|
||||
@@ -183,6 +184,8 @@ type WorkbenchRun = {
|
||||
phase: string;
|
||||
planText: string;
|
||||
planReasoning: string;
|
||||
/** Live-only model thinking while report Markdown is being streamed. */
|
||||
reportThinking: string;
|
||||
report: string;
|
||||
reportTargets: Record<string, { content: string; version: number }>;
|
||||
reportVersion: number;
|
||||
@@ -201,6 +204,7 @@ const EMPTY_RUN: WorkbenchRun = {
|
||||
phase: "initializing",
|
||||
planText: "",
|
||||
planReasoning: "",
|
||||
reportThinking: "",
|
||||
report: "",
|
||||
reportTargets: {},
|
||||
reportVersion: 1,
|
||||
@@ -418,6 +422,10 @@ function DeepResearchWorkbenchInner() {
|
||||
presentationId: null,
|
||||
plan: "",
|
||||
});
|
||||
const mirroredReportRewriteThinkingRef = useRef<{ presentationId: string | null; thinking: string }>({
|
||||
presentationId: null,
|
||||
thinking: "",
|
||||
});
|
||||
|
||||
const sessions = useResearchSessions();
|
||||
const sessionQuery = useResearchSession(selectedId);
|
||||
@@ -428,6 +436,10 @@ function DeepResearchWorkbenchInner() {
|
||||
const [localSettings] = useLocalSettings();
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const seenEventKeys = useRef(new Set<string>());
|
||||
// Live provider deltas can legitimately repeat the same text (including a
|
||||
// single space). They have no durable sequence, so never deduplicate them
|
||||
// by payload or a thought token could disappear from the visible trace.
|
||||
const liveEventSerial = useRef(0);
|
||||
const replayedTraceJobs = useRef(new Set<string>());
|
||||
const pendingReportEvents = useRef<ProgressEvent[]>([]);
|
||||
const reportAnimationFrame = useRef<number | null>(null);
|
||||
@@ -679,6 +691,16 @@ function DeepResearchWorkbenchInner() {
|
||||
});
|
||||
}, [reportDocumentRewrite.run.requirementsPlan, rewritePresentation]);
|
||||
|
||||
useEffect(() => {
|
||||
const presentationId = reportRewritePresentationId.current;
|
||||
const thinking = reportDocumentRewrite.run.thinkingText;
|
||||
if (!presentationId || !rewritePresentation || !thinking) return;
|
||||
const previous = mirroredReportRewriteThinkingRef.current;
|
||||
if (previous.presentationId === presentationId && previous.thinking === thinking) return;
|
||||
mirroredReportRewriteThinkingRef.current = { presentationId, thinking };
|
||||
rewritePresentation.update(presentationId, { thinkingText: thinking });
|
||||
}, [reportDocumentRewrite.run.thinkingText, rewritePresentation]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRewriteUndo = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{
|
||||
@@ -854,6 +876,9 @@ function DeepResearchWorkbenchInner() {
|
||||
phase: session?.status === "completed" ? "done" : run.phase,
|
||||
materialCount,
|
||||
error: run.error,
|
||||
thinking: run.reportThinking,
|
||||
events: run.events,
|
||||
requestedImageCount: requestedReportImageCount(session?.config),
|
||||
}),
|
||||
} as Injected);
|
||||
}
|
||||
@@ -870,6 +895,7 @@ function DeepResearchWorkbenchInner() {
|
||||
? buildCompletedReportMessage({
|
||||
sourceCount: materialCount,
|
||||
summaryText: run.summaryText,
|
||||
imageCount: countEmbeddedReportImages(session?.reportMarkdown),
|
||||
})
|
||||
: buildStreamingSummaryMessage(run.summaryText),
|
||||
// MessageList 会将该标记转为既有的 assistant 流式渲染状态,避免摘要只在结束后才显示。
|
||||
@@ -950,7 +976,9 @@ function DeepResearchWorkbenchInner() {
|
||||
pendingFollowUp,
|
||||
followUpStream,
|
||||
run.error,
|
||||
run.events,
|
||||
run.phase,
|
||||
run.reportThinking,
|
||||
run.summaryText,
|
||||
session,
|
||||
streamJobId,
|
||||
@@ -980,6 +1008,7 @@ function DeepResearchWorkbenchInner() {
|
||||
if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return;
|
||||
if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId);
|
||||
seenEventKeys.current = new Set();
|
||||
liveEventSerial.current = 0;
|
||||
pendingReportEvents.current = [];
|
||||
setRun({ ...EMPTY_RUN, jobId: streamJobId });
|
||||
// 进行中的写作仍自动打开沙箱;历史完成记录只回放步骤与摘要,等待用户点击
|
||||
@@ -997,6 +1026,7 @@ function DeepResearchWorkbenchInner() {
|
||||
let reportTargets = current.reportTargets;
|
||||
let planText = current.planText;
|
||||
let planReasoning = current.planReasoning;
|
||||
let reportThinking = current.reportThinking;
|
||||
let summaryText = current.summaryText;
|
||||
let phase = current.phase;
|
||||
for (const event of queued) {
|
||||
@@ -1009,6 +1039,10 @@ function DeepResearchWorkbenchInner() {
|
||||
planReasoning += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
||||
continue;
|
||||
}
|
||||
if (event.type === "report_thinking") {
|
||||
reportThinking += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
||||
continue;
|
||||
}
|
||||
if (event.type === "summary_delta") {
|
||||
summaryText += typeof event.payload.delta === "string" ? event.payload.delta : "";
|
||||
continue;
|
||||
@@ -1020,6 +1054,7 @@ function DeepResearchWorkbenchInner() {
|
||||
if (event.type === "report_reset") {
|
||||
report = "";
|
||||
reportTargets = {};
|
||||
reportThinking = "";
|
||||
reportVersion = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion + 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1035,7 +1070,7 @@ function DeepResearchWorkbenchInner() {
|
||||
reportVersion = Math.max(reportVersion, version);
|
||||
}
|
||||
}
|
||||
return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, summaryText };
|
||||
return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, reportThinking, summaryText };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1043,12 +1078,15 @@ function DeepResearchWorkbenchInner() {
|
||||
streamJobId,
|
||||
{
|
||||
onEvent: (event) => {
|
||||
const key = eventKey(event);
|
||||
const key = event.seq === 0 && !event.liveId
|
||||
? `live-arrival:${++liveEventSerial.current}`
|
||||
: eventKey(event);
|
||||
if (seenEventKeys.current.has(key)) return;
|
||||
seenEventKeys.current.add(key);
|
||||
if (
|
||||
event.type === "plan_delta" ||
|
||||
event.type === "plan_reasoning" ||
|
||||
event.type === "report_thinking" ||
|
||||
event.type === "report_delta" ||
|
||||
event.type === "report_chunk" ||
|
||||
event.type === "report_reset" ||
|
||||
@@ -1521,6 +1559,10 @@ function DeepResearchWorkbenchInner() {
|
||||
case "config":
|
||||
return (
|
||||
<ReportConfigCard
|
||||
// A new durable config-card message is a new editing session. Do
|
||||
// not remount for ordinary collector/event refreshes: that would
|
||||
// discard the user's in-progress “生成配图” toggle.
|
||||
key={`${session.id}:${payload.messageId ?? "report-config"}`}
|
||||
availableModes={availableModes}
|
||||
defaultMode={session.mode}
|
||||
defaultOptions={advancedOptionsFromConfig(session.config)}
|
||||
@@ -1842,6 +1884,7 @@ type WritingProgressToolCall = {
|
||||
label: string;
|
||||
detail: string;
|
||||
status: "complete" | "active";
|
||||
thinking?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1854,12 +1897,23 @@ function buildWritingProgressToolCalls({
|
||||
phase,
|
||||
materialCount,
|
||||
error,
|
||||
thinking,
|
||||
events,
|
||||
requestedImageCount,
|
||||
}: {
|
||||
phase: string;
|
||||
materialCount: number;
|
||||
error: string | null;
|
||||
thinking: string;
|
||||
events: ProgressEvent[];
|
||||
requestedImageCount: number;
|
||||
}): WritingProgressToolCall[] {
|
||||
const rank = chatPhaseRank(phase);
|
||||
const imageSnapshot = buildTraceSnapshot(events);
|
||||
const imageStageVisible = requestedImageCount > 0 || imageSnapshot.imageTotal > 0 || imageSnapshot.images.length > 0;
|
||||
const imageTotal = imageSnapshot.imageTotal || requestedImageCount;
|
||||
const generatedImages = imageSnapshot.images.filter((image) => image.status === "completed").length;
|
||||
const failedImages = imageSnapshot.images.filter((image) => image.status === "failed").length;
|
||||
const stages = [
|
||||
{
|
||||
id: "harvest",
|
||||
@@ -1879,6 +1933,18 @@ function buildWritingProgressToolCalls({
|
||||
boundary: chatPhaseRank("reviewing"),
|
||||
detail: "正在逐字写入右侧沙箱中的 report.md",
|
||||
},
|
||||
...(imageStageVisible ? [{
|
||||
id: "illustrate",
|
||||
label: "生成报告配图",
|
||||
boundary: chatPhaseRank("exporting"),
|
||||
detail: generatedImages > 0
|
||||
? `已生成 ${generatedImages} 张报告配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}`
|
||||
: failedImages > 0
|
||||
? `配图未生成,已保留完整文字报告${imageTotal ? `(已处理 ${failedImages}/${imageTotal} 张)` : ""}`
|
||||
: imageTotal > 0
|
||||
? `正在生成报告配图,已处理 ${generatedImages + failedImages}/${imageTotal} 张`
|
||||
: "正在为报告关键章节生成配图",
|
||||
}] : []),
|
||||
{
|
||||
id: "summary",
|
||||
label: "生成报告摘要",
|
||||
@@ -1901,6 +1967,7 @@ function buildWritingProgressToolCalls({
|
||||
label: stage.label,
|
||||
detail,
|
||||
status: complete ? "complete" : "active",
|
||||
...(stage.id === "write" && thinking.trim() ? { thinking } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1910,14 +1977,19 @@ function buildWritingProgressToolCalls({
|
||||
function buildCompletedReportMessage({
|
||||
sourceCount,
|
||||
summaryText,
|
||||
imageCount,
|
||||
}: {
|
||||
sourceCount: number;
|
||||
summaryText: string;
|
||||
imageCount: number;
|
||||
}): string {
|
||||
const parts = [
|
||||
"## 研究报告已完成",
|
||||
`已基于 ${sourceCount} 条资料完成报告,正文已在右侧沙箱中打开;可通过沙箱工具栏下载 Markdown 或 Word。`,
|
||||
];
|
||||
if (imageCount > 0) {
|
||||
parts.push(`已生成 ${imageCount} 张报告配图,并嵌入正文对应章节。`);
|
||||
}
|
||||
if (summaryText.trim()) {
|
||||
parts.push(`### 报告摘要\n\n${summaryText.trim()}`);
|
||||
}
|
||||
@@ -2104,6 +2176,7 @@ function ResearchConversation({
|
||||
const hasTrace = active || events.length > 0;
|
||||
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
|
||||
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
|
||||
const requestedImageCount = requestedReportImageCount(session.config);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UserBubble content={session.query} />
|
||||
@@ -2120,6 +2193,7 @@ function ResearchConversation({
|
||||
error={streamError}
|
||||
onOpenSandbox={onOpenSandbox}
|
||||
active={active}
|
||||
requestedImageCount={requestedImageCount}
|
||||
/>
|
||||
</AssistantTurn>
|
||||
)}
|
||||
@@ -2169,6 +2243,15 @@ type TraceSource = {
|
||||
snippet?: string;
|
||||
};
|
||||
|
||||
type TraceImage = {
|
||||
position: number;
|
||||
section: string;
|
||||
status: "generating" | "completed" | "failed";
|
||||
name?: string;
|
||||
alt?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type TraceSnapshot = {
|
||||
groups: { title: string; queries: string[] }[];
|
||||
subtopics: string[];
|
||||
@@ -2179,6 +2262,9 @@ type TraceSnapshot = {
|
||||
curationReason?: string;
|
||||
compressedSourceCount?: number;
|
||||
reportCompleted: boolean;
|
||||
imageTotal: number;
|
||||
images: TraceImage[];
|
||||
imagePhaseFinished: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
@@ -2198,17 +2284,30 @@ function numberValue(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function countEmbeddedReportImages(markdown?: string): number {
|
||||
return (markdown?.match(/!\[[^\]]*\]\(deep-research:\/\/[^)\s]+\)/g) ?? []).length;
|
||||
}
|
||||
|
||||
function requestedReportImageCount(config?: Record<string, unknown>): number {
|
||||
if (config?.generate_images !== true) return 0;
|
||||
const configured = numberValue(config.max_generated_images);
|
||||
return Math.max(1, Math.min(4, Math.floor(configured ?? 2)));
|
||||
}
|
||||
|
||||
function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
const groupQueries = new Map<string, string[]>();
|
||||
const subtopics: string[] = [];
|
||||
const queryMap = new Map<string, TraceQuery>();
|
||||
const sourceMap = new Map<string, TraceSource>();
|
||||
const imageMap = new Map<number, TraceImage>();
|
||||
const selectedIds = new Set<string>();
|
||||
const removedIds = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
let curationReason: string | undefined;
|
||||
let compressedSourceCount: number | undefined;
|
||||
let reportCompleted = false;
|
||||
let imageTotal = 0;
|
||||
let imagePhaseFinished = false;
|
||||
|
||||
const addQuery = (query: string, group = "研究课题") => {
|
||||
const normalized = query.trim();
|
||||
@@ -2223,6 +2322,11 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
|
||||
for (const event of events) {
|
||||
const payload = event.payload ?? {};
|
||||
if (event.type === "phase_changed") {
|
||||
const from = traceString(payload.from);
|
||||
const to = traceString(payload.to);
|
||||
if (from === "illustrating" || to === "exporting") imagePhaseFinished = true;
|
||||
}
|
||||
if (event.type === "plan_created") {
|
||||
const plan = traceRecord(payload.plan);
|
||||
for (const subtopic of traceStrings(payload.subtopics).concat(traceStrings(plan.subtopics))) {
|
||||
@@ -2292,9 +2396,39 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
reportCompleted = true;
|
||||
continue;
|
||||
}
|
||||
if (event.type === "image_generation_started" || event.type === "image_generated") {
|
||||
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
|
||||
const total = numberValue(payload.total);
|
||||
if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total));
|
||||
imageTotal = Math.max(imageTotal, position);
|
||||
const prior = imageMap.get(position);
|
||||
imageMap.set(position, {
|
||||
position,
|
||||
section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`,
|
||||
status: event.type === "image_generated" ? "completed" : prior?.status === "completed" ? "completed" : "generating",
|
||||
name: traceString(payload.name) ?? prior?.name,
|
||||
alt: traceString(payload.alt) ?? prior?.alt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.type === "warning" || event.type === "error") {
|
||||
const message = traceString(payload.message) ?? traceString(payload.detail);
|
||||
if (message && !warnings.includes(message)) warnings.push(message);
|
||||
if (traceString(payload.code) === "image_generation_failed") {
|
||||
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
|
||||
const total = numberValue(payload.total);
|
||||
if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total));
|
||||
imageTotal = Math.max(imageTotal, position);
|
||||
const prior = imageMap.get(position);
|
||||
imageMap.set(position, {
|
||||
position,
|
||||
section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`,
|
||||
status: "failed",
|
||||
name: prior?.name,
|
||||
alt: prior?.alt,
|
||||
error: message ?? "报告配图生成失败",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2308,12 +2442,15 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
curationReason,
|
||||
compressedSourceCount,
|
||||
reportCompleted,
|
||||
imageTotal,
|
||||
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
|
||||
imagePhaseFinished,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function phaseAtOrAfter(phase: string, boundary: string): boolean {
|
||||
const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "reviewing", "illustrating", "exporting", "done"];
|
||||
const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "summarizing", "reviewing", "illustrating", "exporting", "done"];
|
||||
return Math.max(0, order.indexOf(phase)) >= Math.max(0, order.indexOf(boundary));
|
||||
}
|
||||
|
||||
@@ -2345,7 +2482,7 @@ function ReasoningStream({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean }) {
|
||||
const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active, requestedImageCount }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean; requestedImageCount: number }) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const snapshot = useMemo(() => buildTraceSnapshot(events), [events]);
|
||||
const sourcesById = useMemo(() => new Map(snapshot.sources.map((source) => [source.id, source])), [snapshot.sources]);
|
||||
@@ -2358,6 +2495,14 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
|
||||
const curatingDone = phaseAtOrAfter(phase, "writing") || snapshot.selectedIds.size > 0;
|
||||
const writingDone = snapshot.reportCompleted || phase === "done";
|
||||
const streamedQueries = planningDone ? [] : parseStreamedQueries(planText);
|
||||
const imageStageVisible = requestedImageCount > 0 || snapshot.imageTotal > 0 || snapshot.images.length > 0;
|
||||
const imageTotal = snapshot.imageTotal || requestedImageCount;
|
||||
const completedImages = snapshot.images.filter((image) => image.status === "completed").length;
|
||||
const failedImages = snapshot.images.filter((image) => image.status === "failed").length;
|
||||
const processedImages = completedImages + failedImages;
|
||||
const imageDone = imageStageVisible && (snapshot.imagePhaseFinished || phaseAtOrAfter(phase, "exporting") || (!active && phase === "done"));
|
||||
const imageCurrent = imageStageVisible && !imageDone && (phase === "illustrating" || snapshot.images.some((image) => image.status === "generating"));
|
||||
const imageProgress = imageTotal ? Math.min(100, Math.round((processedImages / imageTotal) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl rounded-tl-md border border-border bg-card shadow-sm">
|
||||
@@ -2390,6 +2535,12 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
|
||||
<button type="button" onClick={onOpenSandbox} className="inline-flex items-center gap-1.5 rounded-md bg-primary/8 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/12"><FileText className="h-3.5 w-3.5" />{writingDone ? "查看已保存的 report.md" : "打开正在写入的 report.md"}<ChevronRight className="h-3.5 w-3.5" /></button>
|
||||
</TraceStage>
|
||||
|
||||
{imageStageVisible && <TraceStage number={5} title="生成报告配图" detail={imageDone ? completedImages > 0 ? `已生成 ${completedImages} 张配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}。` : "配图未生成,已保留完整文字报告。" : imageCurrent ? `正在生成报告配图,已处理 ${processedImages}/${imageTotal || "?"} 张。` : "报告写完后会为关键章节生成并嵌入配图。"} done={imageDone} current={imageCurrent}>
|
||||
{imageTotal > 0 && <div className="mb-3 h-2 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-primary transition-[width] duration-300" style={{ width: `${imageDone ? 100 : imageProgress}%` }} /></div>}
|
||||
{snapshot.images.length > 0 ? <div className="space-y-2">{snapshot.images.map((image) => <div key={image.position} className="flex min-w-0 items-center gap-2 rounded-lg border border-border/70 bg-muted/[0.12] px-3 py-2"><ImagePlus className={`h-3.5 w-3.5 shrink-0 ${image.status === "completed" ? "text-emerald-600" : image.status === "failed" ? "text-amber-600" : "animate-pulse text-primary"}`} /><div className="min-w-0 flex-1"><p className="truncate text-xs font-medium">第 {image.position} 张 · {image.section}</p><p className="mt-0.5 text-[11px] text-muted-foreground">{image.status === "completed" ? `${image.name ?? "已保存并嵌入报告"}` : image.status === "failed" ? image.error ?? "生成失败,已保留文字报告" : "正在调用图片模型…"}</p></div><span className={`shrink-0 text-[11px] ${image.status === "completed" ? "text-emerald-700 dark:text-emerald-300" : image.status === "failed" ? "text-amber-700 dark:text-amber-300" : "text-primary"}`}>{image.status === "completed" ? "已生成" : image.status === "failed" ? "未生成" : "生成中"}</span></div>)}</div> : <div className="rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">{imageCurrent ? "正在准备第一张报告配图…" : `最多生成 ${imageTotal || requestedImageCount} 张,并在完成后插入对应章节。`}</div>}
|
||||
{completedImages > 0 && <button type="button" onClick={onOpenSandbox} className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-primary/8 px-2.5 py-1.5 text-xs font-medium text-primary hover:bg-primary/12"><ImagePlus className="h-3.5 w-3.5" />查看已嵌入报告的配图<ChevronRight className="h-3.5 w-3.5" /></button>}
|
||||
</TraceStage>}
|
||||
|
||||
{snapshot.warnings.length > 0 && <div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-200"><p className="font-medium">过程提示</p>{snapshot.warnings.map((warning) => <p key={warning} className="mt-1">{warning}</p>)}</div>}
|
||||
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。</p>}
|
||||
</div>}
|
||||
@@ -2398,7 +2549,8 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
|
||||
});
|
||||
|
||||
function CompletionSummary({ session, sourceCount, onOpenSandbox, onExport }: { session: DeepResearchSession; sourceCount: number; onOpenSandbox: () => void; onExport: (format: "word" | "markdown" | "html") => void }) {
|
||||
return <div className="rounded-2xl rounded-tl-md border border-emerald-500/20 bg-card p-4 shadow-sm"><div className="flex items-start gap-2"><div className="mt-0.5 rounded-full bg-emerald-500/12 p-1 text-emerald-600"><CheckCircle2 className="h-3.5 w-3.5" /></div><div><p className="text-sm font-medium">研究报告已完成</p><p className="mt-1 text-sm leading-6 text-muted-foreground">已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。</p></div></div><div className="mt-4 flex flex-wrap gap-2"><Button size="sm" onClick={onOpenSandbox}><FolderOpen className="mr-1.5 h-3.5 w-3.5" />查看报告</Button><Button size="sm" variant="outline" onClick={() => onExport("word")}><FileDown className="mr-1.5 h-3.5 w-3.5" />Word</Button><Button size="sm" variant="outline" onClick={() => onExport("markdown")}><FileCode2 className="mr-1.5 h-3.5 w-3.5" />Markdown</Button><Button size="sm" variant="outline" onClick={() => onExport("html")}><FileType2 className="mr-1.5 h-3.5 w-3.5" />HTML</Button></div></div>;
|
||||
const imageCount = countEmbeddedReportImages(session.reportMarkdown);
|
||||
return <div className="rounded-2xl rounded-tl-md border border-emerald-500/20 bg-card p-4 shadow-sm"><div className="flex items-start gap-2"><div className="mt-0.5 rounded-full bg-emerald-500/12 p-1 text-emerald-600"><CheckCircle2 className="h-3.5 w-3.5" /></div><div><p className="text-sm font-medium">研究报告已完成</p><p className="mt-1 text-sm leading-6 text-muted-foreground">已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。</p>{imageCount > 0 && <p className="mt-1.5 flex items-center gap-1.5 text-sm leading-6 text-emerald-700 dark:text-emerald-300"><ImagePlus className="h-3.5 w-3.5" />已生成 {imageCount} 张报告配图,已嵌入正文对应章节。</p>}</div></div><div className="mt-4 flex flex-wrap gap-2"><Button size="sm" onClick={onOpenSandbox}><FolderOpen className="mr-1.5 h-3.5 w-3.5" />查看报告</Button><Button size="sm" variant="outline" onClick={() => onExport("word")}><FileDown className="mr-1.5 h-3.5 w-3.5" />Word</Button><Button size="sm" variant="outline" onClick={() => onExport("markdown")}><FileCode2 className="mr-1.5 h-3.5 w-3.5" />Markdown</Button><Button size="sm" variant="outline" onClick={() => onExport("html")}><FileType2 className="mr-1.5 h-3.5 w-3.5" />HTML</Button></div></div>;
|
||||
}
|
||||
|
||||
function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record<string, unknown> | undefined; submitting: boolean; onSubmit: (action: "approve" | "revise", feedback?: string) => void }) {
|
||||
@@ -2409,8 +2561,132 @@ function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record<string, u
|
||||
|
||||
// ── advanced settings dialog ─────────────────────────────────────────────────
|
||||
|
||||
function AdvancedResearchSettingsDialog({ open, onOpenChange, mode, options, classicMode, onClassicModeChange, imageGenerationAvailable, imageLimit, onChange }: { open: boolean; onOpenChange: (open: boolean) => void; mode: ResearchMode; options: AdvancedResearchOptions; classicMode: boolean; onClassicModeChange: (value: boolean) => void; imageGenerationAvailable: boolean; imageLimit: number; onChange: <K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => void }) {
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto"><DialogHeader><DialogTitle>高级研究配置</DialogTitle><DialogDescription>本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。</DialogDescription></DialogHeader><div className="rounded-xl border border-border p-3"><label className="flex cursor-pointer gap-3"><input type="checkbox" checked={classicMode} onChange={(event) => onClassicModeChange(event.target.checked)} className="mt-0.5 accent-primary" /><span><span className="block text-sm font-medium">经典模式</span><span className="mt-1 block text-xs leading-5 text-muted-foreground">回退到旧全流程:输入框直接选模式并自动开始研究,不经过收集对话与报告配置卡片。仅影响下一次新建的研究。</span></span></label></div><div className="grid gap-5 py-2 sm:grid-cols-2"><SettingSelect label="输出语言" value={options.language} onValueChange={(value) => onChange("language", value as AdvancedResearchOptions["language"])} options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]} /><SettingSelect label="报告语气" value={options.tone} onValueChange={(value) => onChange("tone", value as ResearchTone)} options={[{ value: "objective", label: "客观" }, { value: "formal", label: "正式" }, { value: "analytical", label: "分析" }, { value: "persuasive", label: "说服" }]} /><NumberSetting label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(value) => onChange("maxSearchResults", value)} /><NumberSetting label="子问题轮数" hint="1–10 轮" value={options.maxIterations} onChange={(value) => onChange("maxIterations", value)} /><NumberSetting label="最大并发" hint="1–8 路" value={options.maxConcurrency} onChange={(value) => onChange("maxConcurrency", value)} /><NumberSetting label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(value) => onChange("maxContextWords", value)} />{(mode === "detailed" || mode === "multi_agent") && <NumberSetting label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(value) => onChange("maxSubtopics", value)} />}{mode === "deep" && <><NumberSetting label="深挖广度" hint="1–8" value={options.deepBreadth} onChange={(value) => onChange("deepBreadth", value)} /><NumberSetting label="深挖层级" hint="1–4" value={options.deepDepth} onChange={(value) => onChange("deepDepth", value)} /></>}{mode === "multi_agent" && <NumberSetting label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(value) => onChange("maxRevisionLoops", value)} />}</div><div className="space-y-2"><label htmlFor="research-outline" className="text-sm font-medium">自定义报告结构</label><Textarea id="research-outline" value={options.customOutline} onChange={(event) => onChange("customOutline", event.target.value)} className="min-h-28 resize-y" placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险"} /><p className="text-xs text-muted-foreground">支持 Markdown 标题或编号列表,会作为报告写作结构要求。</p></div><div className="grid gap-3 sm:grid-cols-2"><label className="flex cursor-pointer gap-3 rounded-xl border p-3"><input type="checkbox" checked={options.thinkingEnabled} onChange={(event) => onChange("thinkingEnabled", event.target.checked)} className="mt-0.5 accent-primary" /><span><span className="block text-sm font-medium">启用模型深度思考</span><span className="mt-1 block text-xs leading-5 text-muted-foreground">仅在当前系统默认模型支持时生效,会增加响应时延与模型消耗。</span></span></label>{imageGenerationAvailable && <div className="rounded-xl border p-3"><NumberSetting label="最多报告配图" hint={`1–${imageLimit} 张`} value={options.maxGeneratedImages} onChange={(value) => onChange("maxGeneratedImages", value)} /></div>}</div><DialogFooter><Button onClick={() => onOpenChange(false)}>保存配置</Button></DialogFooter></DialogContent></Dialog>;
|
||||
function AdvancedResearchSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
options,
|
||||
classicMode,
|
||||
onClassicModeChange,
|
||||
imageGenerationAvailable,
|
||||
imageLimit,
|
||||
onChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: ResearchMode;
|
||||
options: AdvancedResearchOptions;
|
||||
classicMode: boolean;
|
||||
onClassicModeChange: (value: boolean) => void;
|
||||
imageGenerationAvailable: boolean;
|
||||
imageLimit: number;
|
||||
onChange: <K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[88vh] w-[calc(100vw-2rem)] max-w-none overflow-y-auto sm:w-[600px] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>高级研究配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<label className="flex cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={classicMode}
|
||||
onChange={(event) => onClassicModeChange(event.target.checked)}
|
||||
className="mt-0.5 accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">经典模式</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||
回退到旧全流程:输入框直接选模式并自动开始研究,不经过收集对话与报告配置卡片。仅影响下一次新建的研究。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 py-2 sm:grid-cols-2">
|
||||
<SettingSelect
|
||||
label="输出语言"
|
||||
value={options.language}
|
||||
onValueChange={(value) => onChange("language", value as AdvancedResearchOptions["language"])}
|
||||
options={[
|
||||
{ value: "zh-CN", label: "中文" },
|
||||
{ value: "en-US", label: "English" },
|
||||
]}
|
||||
/>
|
||||
<SettingSelect
|
||||
label="报告语气"
|
||||
value={options.tone}
|
||||
onValueChange={(value) => onChange("tone", value as ResearchTone)}
|
||||
options={[
|
||||
{ value: "objective", label: "客观" },
|
||||
{ value: "formal", label: "正式" },
|
||||
{ value: "analytical", label: "分析" },
|
||||
{ value: "persuasive", label: "说服" },
|
||||
]}
|
||||
/>
|
||||
<NumberSetting label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} onChange={(value) => onChange("maxSearchResults", value)} />
|
||||
<NumberSetting label="子问题轮数" hint="1–10 轮" value={options.maxIterations} onChange={(value) => onChange("maxIterations", value)} />
|
||||
<NumberSetting label="最大并发" hint="1–8 路" value={options.maxConcurrency} onChange={(value) => onChange("maxConcurrency", value)} />
|
||||
<NumberSetting label="上下文词数" hint="500–120000" value={options.maxContextWords} onChange={(value) => onChange("maxContextWords", value)} />
|
||||
{(mode === "detailed" || mode === "multi_agent") && (
|
||||
<NumberSetting label="最多子主题" hint="1–20 个" value={options.maxSubtopics} onChange={(value) => onChange("maxSubtopics", value)} />
|
||||
)}
|
||||
{mode === "deep" && (
|
||||
<>
|
||||
<NumberSetting label="深挖广度" hint="1–8" value={options.deepBreadth} onChange={(value) => onChange("deepBreadth", value)} />
|
||||
<NumberSetting label="深挖层级" hint="1–4" value={options.deepDepth} onChange={(value) => onChange("deepDepth", value)} />
|
||||
</>
|
||||
)}
|
||||
{mode === "multi_agent" && (
|
||||
<NumberSetting label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(value) => onChange("maxRevisionLoops", value)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReportOutlineEditor
|
||||
id="research-outline"
|
||||
value={options.customOutline}
|
||||
onChange={(value) => onChange("customOutline", value)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex cursor-pointer gap-3 rounded-xl border p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.thinkingEnabled}
|
||||
onChange={(event) => onChange("thinkingEnabled", event.target.checked)}
|
||||
className="mt-0.5 accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">启用模型深度思考</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||
仅在当前系统默认模型支持时生效,会增加响应时延与模型消耗。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{imageGenerationAvailable && (
|
||||
<div className="rounded-xl border p-3">
|
||||
<NumberSetting
|
||||
label="最多报告配图"
|
||||
hint={`1–${imageLimit} 张`}
|
||||
value={options.maxGeneratedImages}
|
||||
onChange={(value) => onChange("maxGeneratedImages", value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>保存配置</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberSetting({ label, hint, value, onChange }: { label: string; hint: string; value: string; onChange: (value: string) => void }) { return <label className="block"><span className="mb-2 block text-sm font-medium">{label}</span><Input value={value} inputMode="numeric" onChange={(event) => onChange(event.target.value)} /><span className="mt-1 block text-xs text-muted-foreground">{hint}</span></label>; }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ImagePlus,
|
||||
@@ -65,6 +65,198 @@ export type AdvancedResearchOptions = {
|
||||
customOutline: 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 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
|
||||
|
||||
export type ReportOutlineTemplate = "general" | "progressive" | "custom";
|
||||
|
||||
const REPORT_OUTLINE_TEMPLATE_OPTIONS: Array<{ value: ReportOutlineTemplate; label: string }> = [
|
||||
{ value: "general", label: "通用分析结构" },
|
||||
{ value: "progressive", label: "递进式论证结构" },
|
||||
{ value: "custom", label: "自定义结构" },
|
||||
];
|
||||
|
||||
function matchingOutlineTemplate(outline: string): ReportOutlineTemplate {
|
||||
const normalized = outline.trim();
|
||||
if (normalized === DEFAULT_REPORT_OUTLINE.trim()) return "general";
|
||||
if (normalized === PROGRESSIVE_REPORT_OUTLINE.trim()) return "progressive";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
/** Shared by the initial settings dialog and the write-confirmation card. */
|
||||
export function ReportOutlineEditor({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [customSelected, setCustomSelected] = useState(false);
|
||||
const template = customSelected ? "custom" : matchingOutlineTemplate(value);
|
||||
|
||||
const selectTemplate = (next: ReportOutlineTemplate) => {
|
||||
if (next === "custom") {
|
||||
setCustomSelected(true);
|
||||
// “自定义” is an intentional blank starting point. The two presets are
|
||||
// still available as one-click scaffolds whenever the user wants them.
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
setCustomSelected(false);
|
||||
onChange(next === "general" ? DEFAULT_REPORT_OUTLINE : PROGRESSIVE_REPORT_OUTLINE);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={`${id}-template`} className="block text-xs font-medium">
|
||||
报告结构模板
|
||||
</label>
|
||||
<Select value={template} onValueChange={(next) => selectTemplate(next as ReportOutlineTemplate)}>
|
||||
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_OUTLINE_TEMPLATE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label htmlFor={id} className="block pt-1 text-xs font-medium">
|
||||
报告结构(可编辑)
|
||||
</label>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setCustomSelected(true);
|
||||
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",
|
||||
@@ -78,7 +270,7 @@ export const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
|
||||
maxRevisionLoops: "2",
|
||||
maxGeneratedImages: "2",
|
||||
thinkingEnabled: false,
|
||||
customOutline: "",
|
||||
customOutline: DEFAULT_REPORT_OUTLINE,
|
||||
};
|
||||
|
||||
/** Seed the card's editable options from the frozen session config snapshot. */
|
||||
@@ -100,7 +292,9 @@ export function advancedOptionsFromConfig(config?: Record<string, unknown>): Adv
|
||||
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 : "",
|
||||
customOutline: typeof config?.custom_outline === "string" && config.custom_outline.trim()
|
||||
? config.custom_outline
|
||||
: DEFAULT_REPORT_OUTLINE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,11 +368,6 @@ export function ReportConfigCard({
|
||||
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOptions(defaultOptions);
|
||||
setGenerateImages(defaultGenerateImages);
|
||||
}, [defaultOptions, defaultGenerateImages]);
|
||||
|
||||
const modes = useMemo(
|
||||
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
|
||||
[availableModes],
|
||||
@@ -278,15 +467,11 @@ export function ReportConfigCard({
|
||||
<CardNumber label="修订轮数" hint="0–5 轮" value={options.maxRevisionLoops} onChange={(v) => update("maxRevisionLoops", v)} />
|
||||
)}
|
||||
<div className="sm:col-span-2">
|
||||
<span className="mb-1.5 block text-xs font-medium">自定义报告结构</span>
|
||||
<Textarea
|
||||
<ReportOutlineEditor
|
||||
id="report-card-outline"
|
||||
value={options.customOutline}
|
||||
onChange={(event) => update("customOutline", event.target.value)}
|
||||
maxLength={4000}
|
||||
className="min-h-20 resize-y text-xs"
|
||||
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"}
|
||||
onChange={(value) => update("customOutline", value)}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">支持 Markdown 标题或编号列表,作为报告写作结构要求。</p>
|
||||
</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
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
* 切预览 + 脉冲、复制/另存、展示层脱敏)。
|
||||
*/
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { FileTextIcon, XIcon } from "lucide-react";
|
||||
import { FileTextIcon, Loader2Icon, SparklesIcon, XIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { ArtifactAction } from "@/components/ai-elements/artifact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
@@ -136,18 +137,6 @@ export function ResearchSandboxPanel({
|
||||
<div className="relative h-full bg-[#fffdfa] p-4">
|
||||
{selectedArtifact && previewThreadId ? (
|
||||
<>
|
||||
{onDocumentRewriteStart && (
|
||||
<div className="absolute top-7 right-36 z-30">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={documentRewriteActive}
|
||||
onClick={() => setRewriteDialogOpen(true)}
|
||||
>
|
||||
{documentRewriteActive ? "正在改写" : "AI 全文改写"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{documentRewriteActive && onCancelDocumentRewrite && (
|
||||
<div className="absolute right-36 bottom-7 z-30">
|
||||
<Button size="sm" variant="outline" onClick={onCancelDocumentRewrite}>
|
||||
@@ -159,8 +148,24 @@ export function ResearchSandboxPanel({
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={previewThreadId}
|
||||
researchSessionId={previewThreadId}
|
||||
onClose={handleArtifactsClose}
|
||||
onDownloadClick={onDownload}
|
||||
toolbarActions={
|
||||
onDocumentRewriteStart ? (
|
||||
<ArtifactAction
|
||||
icon={documentRewriteActive ? Loader2Icon : SparklesIcon}
|
||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||
tooltip={
|
||||
documentRewriteActive
|
||||
? "正在生成临时草稿,原报告尚未被覆盖"
|
||||
: "使用 AI 重写完整 report.md"
|
||||
}
|
||||
disabled={documentRewriteActive}
|
||||
onClick={() => setRewriteDialogOpen(true)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
artifactPreviewKey,
|
||||
useArtifacts,
|
||||
} from "@/components/workspace/artifacts";
|
||||
import { stripInlineThinkTags } from "@/core/streamdown/inline-think";
|
||||
|
||||
/** 报告在沙箱中的固定虚拟路径(与 runner 实际写入的 artifacts 路径一致)。 */
|
||||
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
|
||||
@@ -72,7 +73,12 @@ export function useReportSandbox({ threadKey, report, writing }: UseReportSandbo
|
||||
setStubMessage((prev) => {
|
||||
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
|
||||
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
|
||||
const content = report.trim() ? report : prevContent;
|
||||
// Backstop the report sandbox against providers that expose reasoning
|
||||
// through raw `<think>` tags in ordinary text chunks. The backend
|
||||
// filters them at the source; this also protects historical/replayed
|
||||
// streams generated by an older service.
|
||||
const visibleReport = stripInlineThinkTags(report);
|
||||
const content = visibleReport.trim() ? visibleReport : prevContent;
|
||||
return {
|
||||
id: messageId,
|
||||
type: "ai",
|
||||
|
||||
Reference in new issue
Block a user