1379 lines
54 KiB
TypeScript
1379 lines
54 KiB
TypeScript
"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";
|