Merge branch 'master-nj' into 'master'

增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。

See merge request knowledge-base-bj/deerflow-code!25
This commit is contained in:
董宏凯 committed 2026-08-18 09:55:17 +00:00
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",