Files
deerflow-code/frontend-web/src/pages/DeepResearchPage.tsx
T
2026-08-16 19:17:45 +08:00

1379 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* Deep Research page (深度研究).
*
* Two-panel layout:
* - Left: new-research form (query + mode) + session history list.
* - Right: live progress timeline (SSE events) when running, or the
* completed report + source library + export buttons when done.
*
* Route: /page/strategy/deep-research
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import {
AlertCircle,
Bot,
CheckCircle2,
Clock,
ExternalLink,
FileDown,
FileText,
Loader2,
Search,
Settings2,
Telescope,
Trash2,
UserRound,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
type DeepResearchSession,
type DeepResearchMessage,
type DeepResearchSource,
type ProgressEvent,
type ResearchMode,
type ResearchTone,
isTerminalJobStatus,
streamJob,
useCapabilities,
useResearchActions,
useResearchMessages,
useResearchSession,
useResearchSessions,
useResearchSources,
} from "@/strategy-components/api/deep-research";
// ── phase metadata ──────────────────────────────────────────────────────────
const PHASE_LABELS: Record<string, string> = {
initializing: "初始化",
planning: "规划子问题",
collecting: "收集材料",
curating: "筛选素材",
compressing: "压缩上下文",
deepening: "递归深挖",
writing: "撰写报告",
reviewing: "审查报告",
illustrating: "生成报告配图",
exporting: "导出结果",
done: "完成",
};
const STATUS_BADGES: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
draft: { label: "草稿", variant: "outline" },
running: { label: "进行中", variant: "default" },
awaiting_input: { label: "等待输入", variant: "secondary" },
completed: { label: "已完成", variant: "default" },
failed: { label: "失败", variant: "destructive" },
cancelled: { label: "已取消", variant: "outline" },
};
const DEFAULT_AVAILABLE_MODES: ResearchMode[] = ["basic", "quick", "detailed", "deep", "multi_agent"];
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;
};
const DEFAULT_ADVANCED_OPTIONS: AdvancedResearchOptions = {
language: "zh-CN",
tone: "objective",
maxSearchResults: "8",
maxIterations: "3",
maxConcurrency: "4",
maxContextWords: "24000",
maxSubtopics: "8",
deepBreadth: "4",
deepDepth: "2",
maxRevisionLoops: "2",
maxGeneratedImages: "2",
thinkingEnabled: false,
customOutline: "",
};
function boundedInteger(value: string, fallback: number, min: number, max: number): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
}
function buildResearchConfig(
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, 4, 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);
}
return config;
}
function researchExportBaseName(session: DeepResearchSession): string {
const title = (session.title || session.query || "研究报告")
.replace(/[\\/:*?"<>|]/g, "-")
.replace(/\s+/g, " ")
.trim();
return `深度研究-${title.slice(0, 80) || "研究报告"}`;
}
function buildResearchWordMarkdown(session: DeepResearchSession, sources: DeepResearchSource[]): string {
const references = sources
.map((source, index) => {
const title = source.title?.trim() || `来源 ${index + 1}`;
return source.url ? `${index + 1}. ${title}\n ${source.url}` : `${index + 1}. ${title}`;
})
.join("\n");
const metadata = `> 研究课题:${session.query}\n> 导出时间:${new Date().toLocaleString("zh-CN")}\n`;
return [metadata, session.reportMarkdown, references ? `## 参考来源\n\n${references}` : ""].filter(Boolean).join("\n\n");
}
// ── main component ──────────────────────────────────────────────────────────
/**
* Kept temporarily as the implementation reference for older direct links.
* The actual page is now the conversation workbench exported below.
*/
function LegacyDeepResearchPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [mode, setMode] = useState<ResearchMode>("basic");
const [generateImages, setGenerateImages] = useState(false);
const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(
DEFAULT_ADVANCED_OPTIONS,
);
const sessions = useResearchSessions();
const session = useResearchSession(selectedId);
const capabilities = useCapabilities();
const actions = useResearchActions();
const availableModes = useMemo<ResearchMode[]>(
() => capabilities.data?.modes ?? DEFAULT_AVAILABLE_MODES,
[capabilities.data?.modes],
);
useEffect(() => {
if (!availableModes.includes(mode)) {
setMode(availableModes[0] ?? "basic");
}
}, [availableModes, mode]);
const imageGenerationAvailable = capabilities.data?.imageGeneration?.available === true;
useEffect(() => {
if (!imageGenerationAvailable) setGenerateImages(false);
}, [imageGenerationAvailable]);
const handleCreate = useCallback(async () => {
const trimmed = query.trim();
if (!trimmed) return;
try {
const created = await actions.create.mutateAsync({
query: trimmed,
config: buildResearchConfig(mode, generateImages, advancedOptions),
});
setQuery("");
setSelectedId(created.id);
// Auto-start the research job.
await actions.startJob.mutateAsync({ sessionId: created.id, entry: "legacy" });
toast.success("深度研究已启动");
} catch (err) {
toast.error(err instanceof Error ? err.message : "启动研究失败");
}
}, [query, mode, generateImages, advancedOptions, actions]);
const handleStart = useCallback(
async (sessionId: string) => {
try {
await actions.startJob.mutateAsync({ sessionId, entry: "legacy" });
toast.success("深度研究已启动");
} catch (err) {
toast.error(err instanceof Error ? err.message : "启动研究失败");
}
},
[actions],
);
const handleDelete = useCallback(
async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await actions.delete.mutateAsync(id);
if (selectedId === id) setSelectedId(null);
toast.success("已删除");
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除失败");
}
},
[actions, selectedId],
);
return (
<div className="flex h-full overflow-hidden">
{/* ── left panel: form + history ───────────────────────────────── */}
<div className="flex w-80 flex-col border-r border-border bg-card/50">
<div className="border-b border-border p-4">
<div className="mb-3 flex items-center gap-2">
<Telescope className="h-5 w-5 text-primary" />
<h2 className="text-base font-semibold">深度研究</h2>
</div>
<Textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="输入研究课题,例如:人工智能在教育领域的应用与趋势"
className="mb-2 min-h-[80px] resize-none text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void handleCreate();
}
}}
/>
<div className="flex items-center gap-2">
<Select value={mode} onValueChange={(v) => setMode(v as ResearchMode)}>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModes.includes("basic") && <SelectItem value="basic">基础</SelectItem>}
{availableModes.includes("quick") && <SelectItem value="quick">快速</SelectItem>}
{availableModes.includes("detailed") && <SelectItem value="detailed">详细报告</SelectItem>}
{availableModes.includes("deep") && <SelectItem value="deep">深度递归</SelectItem>}
{availableModes.includes("multi_agent") && <SelectItem value="multi_agent">多智能体</SelectItem>}
</SelectContent>
</Select>
<Button
size="sm"
className="flex-1"
disabled={!query.trim() || actions.create.isPending || actions.startJob.isPending}
onClick={() => void handleCreate()}
>
{actions.create.isPending || actions.startJob.isPending ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Search className="mr-1 h-4 w-4" />
)}
开始研究
</Button>
</div>
{imageGenerationAvailable && (
<label className="mt-3 flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={generateImages}
onChange={(event) => setGenerateImages(event.target.checked)}
className="accent-primary"
/>
为报告生成配图(最多 {capabilities.data?.imageGeneration?.maxImages ?? 4} 张)
</label>
)}
<AdvancedResearchSettings
mode={mode}
options={advancedOptions}
onChange={(key, value) =>
setAdvancedOptions((current) => ({ ...current, [key]: value }) as AdvancedResearchOptions)
}
imageGenerationAvailable={imageGenerationAvailable}
maxGeneratedImages={capabilities.data?.imageGeneration?.maxImages ?? 4}
/>
</div>
{/* session list */}
<ScrollArea className="flex-1">
<div className="p-2">
{sessions.isLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中...
</div>
) : sessions.data?.items?.length ? (
<div className="space-y-1">
{sessions.data.items.map((s) => (
<SessionListItem
key={s.id}
session={s}
active={selectedId === s.id}
onClick={() => setSelectedId(s.id)}
onDelete={(e) => void handleDelete(s.id, e)}
/>
))}
</div>
) : (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
暂无研究记录
</div>
)}
</div>
</ScrollArea>
</div>
{/* ── right panel: research view ───────────────────────────────── */}
<div className="flex-1 overflow-hidden">
{selectedId && session.data ? (
<ResearchDetail
session={session.data}
sessionId={selectedId}
onSessionChanged={() => void session.refetch()}
onStart={() => void handleStart(selectedId)}
isStarting={actions.startJob.isPending}
/>
) : (
<EmptyState />
)}
</div>
</div>
);
}
// ── session list item ───────────────────────────────────────────────────────
function AdvancedResearchSettings({
mode,
options,
onChange,
imageGenerationAvailable,
maxGeneratedImages,
}: {
mode: ResearchMode;
options: AdvancedResearchOptions;
onChange: (key: keyof AdvancedResearchOptions, value: string | boolean) => void;
imageGenerationAvailable: boolean;
maxGeneratedImages: number;
}) {
return (
<details className="mt-3 rounded-md border border-border bg-muted/20 text-xs">
<summary className="flex min-h-9 cursor-pointer list-none items-center gap-2 px-2.5 py-2 font-medium text-foreground marker:content-none">
<Settings2 className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<span>高级研究配置</span>
<span className="ml-auto text-[10px] font-normal text-muted-foreground">预算与输出</span>
</summary>
<fieldset className="space-y-3 border-t border-border p-3">
<legend className="sr-only">高级研究配置</legend>
<p className="text-[11px] leading-4 text-muted-foreground">
每次研究会保存一份不可变的配置快照;服务端仍会按安全上限校验预算。
</p>
<div className="grid grid-cols-2 gap-2">
<label className="space-y-1" htmlFor="research-language">
<span className="block font-medium text-foreground">输出语言</span>
<Select value={options.language} onValueChange={(value) => onChange("language", value)}>
<SelectTrigger id="research-language" className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="zh-CN">中文</SelectItem><SelectItem value="en-US">English</SelectItem></SelectContent>
</Select>
</label>
<label className="space-y-1" htmlFor="research-tone">
<span className="block font-medium text-foreground">报告语气</span>
<Select value={options.tone} onValueChange={(value) => onChange("tone", value)}>
<SelectTrigger id="research-tone" className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="objective">客观</SelectItem><SelectItem value="formal">正式</SelectItem>
<SelectItem value="analytical">分析型</SelectItem><SelectItem value="persuasive">论证型</SelectItem>
</SelectContent>
</Select>
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<NumberSetting id="research-results" label="单次查询结果" hint="1–20 条" value={options.maxSearchResults} min={1} max={20} onChange={(value) => onChange("maxSearchResults", value)} />
<NumberSetting id="research-iterations" label="子问题轮数" hint="1–10 轮" value={options.maxIterations} min={1} max={10} onChange={(value) => onChange("maxIterations", value)} />
<NumberSetting id="research-concurrency" label="最大并发" hint="1–8 路" value={options.maxConcurrency} min={1} max={8} onChange={(value) => onChange("maxConcurrency", value)} />
<NumberSetting id="research-context" label="上下文词数" hint="500–120000" value={options.maxContextWords} min={500} max={120000} step={500} onChange={(value) => onChange("maxContextWords", value)} />
</div>
{(mode === "detailed" || mode === "multi_agent") && (
<NumberSetting id="research-subtopics" label="最大研究主题数" hint="1–20 个" value={options.maxSubtopics} min={1} max={20} onChange={(value) => onChange("maxSubtopics", value)} />
)}
{mode === "deep" && (
<div className="grid grid-cols-2 gap-2">
<NumberSetting id="research-deep-breadth" label="递归分支广度" hint="1–8 路" value={options.deepBreadth} min={1} max={8} onChange={(value) => onChange("deepBreadth", value)} />
<NumberSetting id="research-deep-depth" label="递归层级" hint="1–4 层" value={options.deepDepth} min={1} max={4} onChange={(value) => onChange("deepDepth", value)} />
</div>
)}
{mode === "multi_agent" && (
<NumberSetting id="research-revision-loops" label="审稿改写上限" hint="0–5 轮" value={options.maxRevisionLoops} min={0} max={5} onChange={(value) => onChange("maxRevisionLoops", value)} />
)}
{imageGenerationAvailable && (
<NumberSetting id="research-generated-images" label="报告配图上限" hint={`1–${maxGeneratedImages} 张(启用配图时生效)`} value={options.maxGeneratedImages} min={1} max={maxGeneratedImages} onChange={(value) => onChange("maxGeneratedImages", value)} />
)}
<div className="space-y-1">
<label htmlFor="research-custom-outline" className="block font-medium text-foreground">自定义报告结构</label>
<Textarea
id="research-custom-outline"
value={options.customOutline}
onChange={(event) => onChange("customOutline", event.target.value)}
maxLength={4000}
placeholder={"例如:\n# 执行摘要\n## 市场与背景\n## 机会与风险\n## 建议"}
className="min-h-24 resize-y text-xs"
aria-describedby="research-custom-outline-hint"
/>
<p id="research-custom-outline-hint" className="text-[10px] leading-4 text-muted-foreground">
支持 Markdown 标题或编号列表。详细报告和多智能体会按这些一级结构规划;其他模式将其作为写作结构要求。
</p>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-md border border-border bg-background/60 p-2 text-[11px] leading-4">
<input type="checkbox" checked={options.thinkingEnabled} onChange={(event) => onChange("thinkingEnabled", event.target.checked)} className="mt-0.5 accent-primary" />
<span><span className="block font-medium text-foreground">启用模型深度思考</span>仅在当前系统默认模型支持时生效;会增加响应时延与模型消耗。</span>
</label>
</fieldset>
</details>
);
}
function NumberSetting({ id, label, hint, value, min, max, step = 1, onChange }: {
id: string;
label: string;
hint: string;
value: string;
min: number;
max: number;
step?: number;
onChange: (value: string) => void;
}) {
return (
<div className="space-y-1">
<label htmlFor={id} className="block font-medium text-foreground">{label}</label>
<Input id={id} type="number" inputMode="numeric" min={min} max={max} step={step} value={value} onChange={(event) => onChange(event.target.value)} aria-describedby={`${id}-hint`} className="h-8 text-xs" />
<p id={`${id}-hint`} className="text-[10px] text-muted-foreground">{hint}</p>
</div>
);
}
function SessionListItem({
session,
active,
onClick,
onDelete,
}: {
session: DeepResearchSession;
active: boolean;
onClick: () => void;
onDelete: (e: React.MouseEvent) => void;
}) {
const badge = STATUS_BADGES[session.status] ?? STATUS_BADGES.draft;
return (
<div
onClick={onClick}
className={`group cursor-pointer rounded-lg border px-3 py-2.5 transition-colors ${
active
? "border-primary bg-primary/5"
: "border-transparent hover:border-border hover:bg-accent/50"
}`}
>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
{session.title || session.query.slice(0, 30)}
</span>
<button
onClick={onDelete}
className="opacity-0 transition-opacity group-hover:opacity-100"
title="删除"
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</button>
</div>
<div className="flex items-center gap-2">
<Badge variant={badge.variant} className="h-5 text-[10px]">
{badge.label}
</Badge>
{session.status === "running" && (
<Loader2 className="h-3 w-3 animate-spin text-primary" />
)}
<span className="ml-auto truncate text-[10px] text-muted-foreground">
{session.sourceCount > 0 && `${session.sourceCount} 条来源`}
</span>
</div>
</div>
);
}
// ── empty state ─────────────────────────────────────────────────────────────
function EmptyState() {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
<Telescope className="h-16 w-16 text-muted-foreground/30" />
<div>
<h3 className="mb-1 text-lg font-medium">深度研究</h3>
<p className="max-w-sm text-sm text-muted-foreground">
输入一个研究课题,AI 将自动拆解子问题、搜集材料、筛选素材,并撰写一份带引用来源的研究报告。
</p>
</div>
</div>
);
}
// ── research detail (right panel) ───────────────────────────────────────────
function ResearchDetail({
session,
sessionId,
onSessionChanged,
onStart,
isStarting,
}: {
session: DeepResearchSession;
sessionId: string;
onSessionChanged: () => void;
onStart: () => void;
isStarting: boolean;
}) {
const isActive = session.status === "running" || session.status === "awaiting_input";
const canStart = session.status === "draft" || session.status === "failed" || session.status === "cancelled";
const sources = useResearchSources(sessionId);
const messages = useResearchMessages(sessionId);
const actions = useResearchActions();
const handleCancel = useCallback(async () => {
if (!session.activeJobId) return;
try {
await actions.cancelJob.mutateAsync(session.activeJobId);
toast.success("已请求取消");
onSessionChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : "取消失败");
}
}, [session.activeJobId, actions, onSessionChanged]);
const handlePlanReview = useCallback(
async (action: "approve" | "revise", feedback = "") => {
if (!session.activeJobId) return;
try {
await actions.resumeJob.mutateAsync({
jobId: session.activeJobId,
action,
payload: feedback ? { feedback } : {},
});
toast.success(action === "approve" ? "研究计划已批准" : "已提交计划修订意见");
onSessionChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : "提交计划审核失败");
}
},
[session.activeJobId, actions, onSessionChanged],
);
const handleExport = useCallback(
async (format: "markdown" | "html" | "word") => {
try {
if (format === "word") {
if (!session.reportMarkdown) throw new Error("报告尚未生成完成");
await downloadMarkdownAsDocx(
buildResearchWordMarkdown(session, sources.data?.items ?? []),
researchExportBaseName(session),
session.title || session.query,
);
toast.success("Word 文档已导出");
return;
}
const res = await apiFetch(
`${getBackendBaseURL()}/api/deep-research/sessions/${sessionId}/export`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ format }),
},
);
if (!res.ok) throw new Error(`导出失败 (${res.status})`);
const data = await res.json();
// Download as a file.
const blob = new Blob([data.content], {
type: format === "html" ? "text/html" : "text/markdown",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `research-report.${format === "html" ? "html" : "md"}`;
a.click();
URL.revokeObjectURL(url);
toast.success("已导出");
} catch (err) {
toast.error(err instanceof Error ? err.message : "导出失败");
}
},
[session, sessionId, sources.data?.items],
);
const handleFollowUp = useCallback(
async (message: string) => {
try {
await actions.sendChat.mutateAsync({ sessionId, message });
await messages.refetch();
} catch (err) {
toast.error(err instanceof Error ? err.message : "报告追问失败");
}
},
[actions.sendChat, messages, sessionId],
);
const handleSourceSelection = useCallback(
async (source: DeepResearchSource) => {
try {
await actions.setSourceSelected.mutateAsync({
sessionId,
sourceId: source.id,
selected: !source.selected,
});
toast.success(source.selected ? "来源已从后续追问中排除" : "来源已用于后续追问");
} catch (err) {
toast.error(err instanceof Error ? err.message : "更新来源选择失败");
}
},
[actions.setSourceSelected, sessionId],
);
const selectedSourceCount = (sources.data?.items ?? []).filter((source) => source.selected).length;
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-4xl p-6">
{/* header */}
<div className="mb-6">
<div className="mb-2 flex items-center gap-3">
<h1 className="text-xl font-bold">{session.title || session.query}</h1>
{STATUS_BADGES[session.status] && (
<Badge variant={STATUS_BADGES[session.status].variant}>
{STATUS_BADGES[session.status].label}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{session.query}</p>
{session.error && (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-destructive/10 p-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{session.error}
</div>
)}
</div>
<div className="mb-6 flex justify-end">
<div className="max-w-[88%] rounded-2xl rounded-tr-md bg-primary px-4 py-3 text-sm text-primary-foreground shadow-sm">
<div className="mb-1 flex items-center justify-end gap-1.5 text-xs text-primary-foreground/80">
<span>我</span>
<UserRound className="h-3.5 w-3.5" />
</div>
<p className="whitespace-pre-wrap leading-6">{session.query}</p>
</div>
</div>
{/* running state: progress timeline */}
{isActive && session.activeJobId && (
<div className="mb-6 flex items-start gap-3">
<AssistantAvatar />
<div className="min-w-0 flex-1">
<AssistantLabel text="正在进行深度研究" />
<ProgressTimeline
jobId={session.activeJobId}
onTerminal={onSessionChanged}
onCancel={() => void handleCancel()}
onSourceAdded={() => void sources.refetch()}
/>
</div>
</div>
)}
{session.status === "awaiting_input" && session.activeJobId && (
<PlanReviewPanel
plan={session.plan}
isSubmitting={actions.resumeJob.isPending}
onSubmit={handlePlanReview}
/>
)}
{canStart && (
<div className="mb-6 rounded-lg border border-dashed border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">
{session.status === "draft" ? "研究尚未开始" : "可以重新开始研究"}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{session.status === "draft"
? "确认课题后开始收集材料并生成研究报告。"
: "会基于当前课题新建一次研究任务,已有报告和来源不会被删除。"}
</p>
</div>
<Button onClick={onStart} disabled={isStarting}>
{isStarting ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Search className="mr-1 h-4 w-4" />}
{session.status === "draft" ? "开始研究" : "重新开始"}
</Button>
</div>
</div>
)}
{/* report + sources */}
{!isActive && (
<div className="space-y-6">
{session.reportMarkdown ? (
<>
<div className="flex items-start gap-3">
<AssistantAvatar />
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<AssistantLabel text="深度研究助手 · 研究报告" />
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => void handleExport("markdown")}>
<FileText className="mr-1 h-4 w-4" /> Markdown
</Button>
<Button size="sm" variant="outline" onClick={() => void handleExport("word")}>
<FileDown className="mr-1 h-4 w-4" /> Word
</Button>
<Button size="sm" variant="outline" onClick={() => void handleExport("html")}>
<ExternalLink className="mr-1 h-4 w-4" /> HTML
</Button>
</div>
</div>
<article className="prose prose-sm dark:prose-invert max-w-none rounded-2xl rounded-tl-md border border-border bg-card p-6 shadow-sm">
<ReactMarkdown
components={{
img: ({ src, alt }) => (
<ProtectedReportImage sessionId={sessionId} src={src} alt={alt} />
),
}}
>
{session.reportMarkdown}
</ReactMarkdown>
</article>
</div>
</div>
{session.status === "completed" && (
<ResearchChatPanel
messages={messages.data ?? []}
sources={sources.data?.items ?? []}
isSending={actions.sendChat.isPending}
onSend={handleFollowUp}
/>
)}
</>
) : (
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
暂无报告
</div>
)}
</div>
)}
{sources.data && sources.data.items.length > 0 && (
<div className="mt-6">
<div className="mb-3 flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">
{isActive ? "已发现来源" : "参考来源"} <span className="text-sm font-normal text-muted-foreground">({sources.data.total})</span>
</h2>
{!isActive && <Badge variant="outline">后续追问使用 {selectedSourceCount} 条</Badge>}
</div>
{!isActive && (
<p className="mb-3 text-xs leading-5 text-muted-foreground">
调整选择会立即限制后续“报告追问”的证据范围,不会重写已完成的报告;重新研究时会重新收集并筛选来源。
</p>
)}
<div className="space-y-2">
{sources.data.items.map((src, idx) => (
<SourceCard
key={src.id}
source={src}
index={idx + 1}
canEditSelection={!isActive}
isUpdating={actions.setSourceSelected.isPending}
onSelectionChange={() => void handleSourceSelection(src)}
/>
))}
</div>
</div>
)}
</div>
</ScrollArea>
);
}
function ProtectedReportImage({
sessionId,
src,
alt,
}: {
sessionId: string;
src?: string;
alt?: string;
}) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const isResearchArtifact = src?.startsWith("deep-research://") === true;
useEffect(() => {
if (!isResearchArtifact || !src) {
setObjectUrl(null);
return;
}
const name = src.slice("deep-research://".length);
const controller = new AbortController();
let url: string | null = null;
void (async () => {
try {
const response = await apiFetch(
`${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`,
{ method: "GET", signal: controller.signal },
);
if (!response.ok) return;
url = URL.createObjectURL(await response.blob());
setObjectUrl(url);
} catch {
// A missing optional image must not prevent the Markdown report from rendering.
}
})();
return () => {
controller.abort();
if (url) URL.revokeObjectURL(url);
};
}, [isResearchArtifact, sessionId, src]);
// Reports only render images generated by this session. This prevents a
// model-produced external Markdown URL from unexpectedly loading a tracker.
if (!isResearchArtifact) return null;
if (!objectUrl) {
return <span className="block text-xs text-muted-foreground">正在加载报告配图…</span>;
}
return <img src={objectUrl} alt={alt || "研究报告配图"} className="my-4 max-h-[440px] rounded-lg border border-border" />;
}
function ResearchChatPanel({
messages,
sources,
isSending,
onSend,
}: {
messages: DeepResearchMessage[];
sources: DeepResearchSource[];
isSending: boolean;
onSend: (message: string) => Promise<void>;
}) {
const [question, setQuestion] = useState("");
const sourceTitles = useMemo(
() => new Map(sources.map((source) => [source.id, source.title || source.id])),
[sources],
);
const submit = useCallback(async () => {
const trimmed = question.trim();
if (!trimmed || isSending) return;
setQuestion("");
await onSend(trimmed);
}, [isSending, onSend, question]);
return (
<section className="mt-6">
<div className="mb-3">
<h2 className="text-base font-semibold">继续对话</h2>
<p className="mt-1 text-xs text-muted-foreground">仅依据本报告和已收集来源回答,不会自动发起新的信息搜集。</p>
</div>
{messages.length > 0 && (
<div className="mb-5 space-y-4">
{messages.map((message) => (
<div key={message.id} className={message.role === "user" ? "flex justify-end" : "flex items-start gap-3"}>
{message.role !== "user" && <AssistantAvatar />}
<div
className={`max-w-[88%] rounded-2xl px-4 py-3 text-sm shadow-sm ${
message.role === "user"
? "rounded-tr-md bg-primary text-primary-foreground"
: "rounded-tl-md border border-border bg-card"
}`}
>
<div className={`mb-1 text-[11px] ${message.role === "user" ? "text-primary-foreground/75" : "text-muted-foreground"}`}>
{message.role === "user" ? "我" : "深度研究助手"}
</div>
<ReactMarkdown components={{ img: () => null }}>{message.content}</ReactMarkdown>
{message.citationSourceIds.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{message.citationSourceIds.map((sourceId) => (
<Badge key={sourceId} variant="outline" className="h-5 max-w-full truncate text-[10px]">
来源:{sourceTitles.get(sourceId) ?? sourceId}
</Badge>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
<div className="rounded-2xl border border-border bg-card p-3 shadow-sm">
<div className="flex gap-2">
<Textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="例如:这份报告中最需要补充验证的结论是什么?"
className="min-h-[72px] resize-none text-sm"
disabled={isSending}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
void submit();
}
}}
/>
<Button className="self-end" disabled={!question.trim() || isSending} onClick={() => void submit()}>
{isSending ? <Loader2 className="h-4 w-4 animate-spin" /> : "发送"}
</Button>
</div>
<p className="mt-2 text-[11px] text-muted-foreground">Ctrl / ⌘ + Enter 发送</p>
</div>
</section>
);
}
function AssistantAvatar() {
return (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm">
<Bot className="h-4 w-4" aria-hidden="true" />
</div>
);
}
function AssistantLabel({ text }: { text: string }) {
return <div className="mb-1.5 text-xs font-medium text-muted-foreground">{text}</div>;
}
// ── progress timeline ───────────────────────────────────────────────────────
function PlanReviewPanel({
plan,
isSubmitting,
onSubmit,
}: {
plan?: Record<string, unknown>;
isSubmitting: boolean;
onSubmit: (action: "approve" | "revise", feedback?: string) => Promise<void>;
}) {
const [feedback, setFeedback] = useState("");
const sections = Array.isArray(plan?.sections)
? plan.sections.filter((section): section is string => typeof section === "string")
: [];
return (
<div className="mb-6 rounded-lg border border-primary/30 bg-primary/5 p-5">
<h2 className="text-sm font-semibold">请审核研究计划</h2>
<p className="mt-1 text-sm text-muted-foreground">
多智能体将在您批准后,按以下分工收集材料、撰写报告并进行审阅。
</p>
{sections.length > 0 && (
<ol className="mt-3 list-decimal space-y-1 pl-5 text-sm">
{sections.map((section) => <li key={section}>{section}</li>)}
</ol>
)}
<Textarea
value={feedback}
onChange={(event) => setFeedback(event.target.value)}
placeholder="如需修改,请说明希望补充、删减或调整的研究方向"
className="mt-4 min-h-[76px] resize-none text-sm"
/>
<div className="mt-3 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={isSubmitting || !feedback.trim()}
onClick={() => void onSubmit("revise", feedback.trim())}
>
提交修订
</Button>
<Button size="sm" disabled={isSubmitting} onClick={() => void onSubmit("approve")}>
{isSubmitting && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
批准并开始研究
</Button>
</div>
</div>
);
}
function ProgressTimeline({
jobId,
onTerminal,
onCancel,
onSourceAdded,
}: {
jobId: string;
onTerminal: () => void;
onCancel: () => void;
onSourceAdded: () => void;
}) {
const [events, setEvents] = useState<ProgressEvent[]>([]);
const [currentPhase, setCurrentPhase] = useState("initializing");
const [error, setError] = useState<string | null>(null);
const [reportDrafts, setReportDrafts] = useState<Record<string, { content: string; version: number }>>({});
const receivedSequencesRef = useRef<Set<number>>(new Set());
const onTerminalRef = useRef(onTerminal);
const onSourceAddedRef = useRef(onSourceAdded);
onTerminalRef.current = onTerminal;
onSourceAddedRef.current = onSourceAdded;
useEffect(() => {
setEvents([]);
setCurrentPhase("initializing");
setError(null);
setReportDrafts({});
receivedSequencesRef.current = new Set();
const stop = streamJob(
jobId,
{
onEvent: (event) => {
if (receivedSequencesRef.current.has(event.seq)) return;
receivedSequencesRef.current.add(event.seq);
setEvents((prev) => (prev.some((existing) => existing.seq === event.seq) ? prev : [...prev, event]));
if (event.phase) setCurrentPhase(event.phase);
if (event.type === "source_added") onSourceAddedRef.current();
if (event.type === "report_reset") {
setReportDrafts({});
} else if (event.type === "report_chunk") {
const delta = typeof event.payload.delta === "string" ? event.payload.delta : "";
const target = typeof event.payload.target === "string" ? event.payload.target : "report";
const version = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : 1;
if (delta) {
setReportDrafts((previous) => {
const current = previous[target];
if (current && current.version > version) return previous;
return {
...previous,
[target]: {
version,
content: current?.version === version ? current.content + delta : delta,
},
};
});
}
}
if (event.type === "awaiting_input") {
window.setTimeout(() => onTerminalRef.current(), 200);
}
},
onTerminal: () => {
onTerminalRef.current();
},
onError: (detail) => {
setError(detail);
},
},
0,
);
return () => stop();
}, [jobId]);
// Derive display data from events.
const { queries, sourcesFound, phaseEvents } = useMemo(() => {
const queries: string[] = [];
const sourcesFound: { title: string; url: string }[] = [];
const phaseEvents: ProgressEvent[] = [];
for (const ev of events) {
if (ev.type === "query_started") {
queries.push(ev.payload.query as string);
} else if (ev.type === "source_added") {
sourcesFound.push({
title: (ev.payload.title as string) || "未知标题",
url: (ev.payload.url as string) || "",
});
} else if (ev.type === "phase_changed") {
phaseEvents.push(ev);
}
}
return { queries, sourcesFound, phaseEvents };
}, [events]);
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
const visibleDrafts = Object.entries(reportDrafts)
.filter(([, draft]) => draft.content.trim())
.sort(([left], [right]) => left.localeCompare(right, "en", { numeric: true }));
const visibleEvents = events.filter((event) => event.type !== "report_chunk");
return (
<div className="space-y-4">
{/* progress bar */}
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-sm font-medium">{phaseLabel}</span>
</div>
<Button size="sm" variant="outline" onClick={onCancel}>
<XCircle className="mr-1 h-4 w-4" /> 取消
</Button>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${phaseProgress(currentPhase)}%` }}
/>
</div>
<div className="mt-3 flex gap-4 text-xs text-muted-foreground">
<span>{queries.length} 个子问题</span>
<span>{sourcesFound.length} 条来源</span>
{error && <span className="text-destructive">{error}</span>}
</div>
</div>
{visibleDrafts.length > 0 && (
<section className="rounded-2xl rounded-tl-md border border-primary/25 bg-card p-5 shadow-sm" aria-live="polite">
<div className="mb-3 flex items-center gap-2">
<FileText className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">报告草稿正在生成</h3>
<span className="h-2 w-2 animate-pulse rounded-full bg-primary" aria-label="正在流式生成" />
</div>
<div className="space-y-5">
{visibleDrafts.map(([target, draft]) => (
<div key={`${target}-${draft.version}`}>
{target !== "report" && (
<div className="mb-2 text-xs font-medium text-muted-foreground">{reportDraftTargetLabel(target)}</div>
)}
<div className="prose prose-sm dark:prose-invert max-w-none leading-7">
<ReactMarkdown components={{ img: () => null }}>{draft.content}</ReactMarkdown>
</div>
</div>
))}
</div>
</section>
)}
{/* event timeline */}
<div className="rounded-lg border border-border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold">研究过程</h3>
<div className="space-y-2">
{visibleEvents.length === 0 && (
<div className="py-4 text-center text-sm text-muted-foreground">
正在启动...
</div>
)}
{visibleEvents.map((ev) => (
<EventRow key={ev.seq} event={ev} />
))}
</div>
</div>
</div>
);
}
function reportDraftTargetLabel(target: string): string {
if (target === "introduction") return "引言";
if (target === "conclusion") return "结论";
if (target.startsWith("section:")) return `研究章节 ${Number.parseInt(target.slice("section:".length), 10) + 1}`;
return "报告正文";
}
function phaseProgress(phase: string): number {
const map: Record<string, number> = {
initializing: 5,
planning: 15,
collecting: 40,
deepening: 55,
curating: 65,
compressing: 75,
writing: 90,
reviewing: 95,
illustrating: 94,
exporting: 98,
done: 100,
};
return map[phase] ?? 0;
}
// ── event row ───────────────────────────────────────────────────────────────
function EventRow({ event }: { event: ProgressEvent }) {
const icon = EVENT_ICONS[event.type];
const label = EVENT_LABELS[event.type] || event.type;
let detail = "";
if (event.type === "query_started" || event.type === "query_completed") {
detail = String(event.payload.query ?? "");
} else if (event.type === "source_added") {
detail = String(event.payload.title ?? "");
} else if (event.type === "deep_branch_started" || event.type === "deep_branch_completed") {
detail = String(event.payload.query ?? "");
} else if (event.type === "phase_changed") {
detail = `${event.payload.from ?? ""} → ${PHASE_LABELS[event.payload.to as string] ?? event.payload.to}`;
} else if (event.type === "plan_created") {
const plan = asRecord(event.payload.plan);
const queries = Array.isArray(plan?.queries) ? plan.queries.filter((item): item is string => typeof item === "string") : [];
const sections = Array.isArray(plan?.sections) ? plan.sections.filter((item): item is string => typeof item === "string") : [];
detail = queries.length > 0 ? `${queries.length} 个子问题` : sections.length > 0 ? `${sections.length} 个研究章节` : "";
} else if (event.type === "report_completed") {
detail = `${event.payload.sourceCount ?? 0} 条来源`;
} else if (event.type === "report_reset") {
detail = "审稿后正在重新生成报告";
}
return (
<div className="flex items-start gap-3 text-sm">
<div className="mt-0.5 shrink-0">{icon}</div>
<div className="min-w-0 flex-1">
<span className="font-medium">{label}</span>
{detail && (
<span className="ml-2 truncate text-muted-foreground">{detail}</span>
)}
</div>
{event.timestamp && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{new Date(event.timestamp).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</span>
)}
</div>
);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
const EVENT_ICONS: Record<string, React.ReactNode> = {
phase_changed: <Clock className="h-3.5 w-3.5 text-blue-500" />,
plan_created: <FileText className="h-3.5 w-3.5 text-indigo-500" />,
query_started: <Search className="h-3.5 w-3.5 text-cyan-500" />,
query_completed: <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />,
source_added: <ExternalLink className="h-3.5 w-3.5 text-amber-500" />,
sources_curated: <CheckCircle2 className="h-3.5 w-3.5 text-green-600" />,
deep_branch_started: <Search className="h-3.5 w-3.5 text-cyan-500" />,
deep_branch_completed: <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />,
awaiting_input: <Clock className="h-3.5 w-3.5 text-indigo-500" />,
report_reset: <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />,
report_completed: <CheckCircle2 className="h-4 w-4 text-green-600" />,
artifact_created: <FileText className="h-3.5 w-3.5 text-purple-500" />,
image_generation_started: <Loader2 className="h-3.5 w-3.5 text-purple-500" />,
image_generated: <CheckCircle2 className="h-3.5 w-3.5 text-purple-500" />,
warning: <AlertCircle className="h-3.5 w-3.5 text-orange-500" />,
model_usage: <Loader2 className="h-3.5 w-3.5 text-muted-foreground" />,
job_completed: <CheckCircle2 className="h-4 w-4 text-green-600" />,
job_failed: <XCircle className="h-4 w-4 text-destructive" />,
};
const EVENT_LABELS: Record<string, string> = {
phase_changed: "阶段切换",
plan_created: "研究计划已生成",
query_started: "开始搜索",
query_completed: "搜索完成",
source_added: "发现来源",
sources_curated: "素材筛选完成",
deep_branch_started: "开始递归分支",
deep_branch_completed: "递归分支完成",
awaiting_input: "等待计划审核",
report_reset: "报告修订",
report_completed: "报告撰写完成",
artifact_created: "产物已生成",
image_generation_started: "开始生成报告配图",
image_generated: "报告配图已生成",
warning: "警告",
model_usage: "模型调用",
job_completed: "研究完成",
job_failed: "研究失败",
job_status: "状态更新",
};
// ── source card ─────────────────────────────────────────────────────────────
function SourceCard({
source,
index,
canEditSelection,
isUpdating,
onSelectionChange,
}: {
source: DeepResearchSource;
index: number;
canEditSelection: boolean;
isUpdating: boolean;
onSelectionChange: () => void;
}) {
return (
<div className="flex items-start gap-3 rounded-lg border border-border bg-card p-3">
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{source.citationKey || index}
</span>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-start gap-2">
<span className="truncate text-sm font-medium">{source.title}</span>
{canEditSelection ? (
<Button
type="button"
size="sm"
variant={source.selected ? "secondary" : "outline"}
className="h-7 shrink-0 px-2 text-[10px]"
disabled={isUpdating}
aria-pressed={source.selected}
aria-label={`${source.selected ? "排除" : "选用"}来源:${source.title}`}
onClick={onSelectionChange}
>
{isUpdating && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{source.selected ? "用于追问" : "已排除"}
</Button>
) : source.selected ? (
<Badge variant="outline" className="h-4 shrink-0 text-[9px]">已引用</Badge>
) : null}
</div>
{source.snippet && (
<p className="line-clamp-2 text-xs text-muted-foreground">{source.snippet}</p>
)}
{source.url && (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-[10px] text-primary hover:underline"
>
<ExternalLink className="h-2.5 w-2.5" />
{source.url}
</a>
)}
</div>
</div>
);
}
export { default } from "./deep-research/DeepResearchWorkbench";