feat: 新增独立企业深度研究工作台,并接入工作流报告结构与 8BF 深色锁定

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-20 16:43:03 +08:00
1 parent e881c242fe
commit 11f928bc95
24 files changed
+3275 -40

No files matched your search

@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -100,6 +100,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/deep-research",
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
},
{
id: "workspace-enterprise-research",
label: "企业深度研究",
icon: FileSearch,
path: "/page/workspace/enterprise-research",
match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
},
{
id: "workspace-roundtable-planning",
label: "多智能体规划",
@@ -19,6 +19,7 @@ import {
ClipboardList,
Database,
FileBox,
FileSearch,
FileText,
Gauge,
Grid3X3,
@@ -99,6 +100,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Telescope,
path: "/page/strategy/qa/deep-research",
},
{
id: "qa-enterprise-research",
label: "企业深度研究",
icon: FileSearch,
path: "/page/strategy/qa/enterprise-research",
},
],
},
{
@@ -248,8 +248,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
);
return (
<RoundtablePlanningPage
// 仅将 8BF 会商页锁为深蓝色:全局主题状态和切换逻辑保持原样,
// 后续去掉本容器与 forceDarkAppearance 即可恢复随主题切换。
<div className="dark flex h-full min-h-0 flex-col bg-[#061b3b]">
<RoundtablePlanningPage
compactChrome={compactChrome}
forceDarkAppearance
lockedChainId={mapping?.chainId ?? null}
lockBusinessChain
storageTaskId={storageTaskId}
@@ -326,6 +330,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
}
rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
/>
/>
</div>
);
}
@@ -0,0 +1,734 @@
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
BookOpenCheck,
CheckCircle2,
ChevronRight,
ClipboardList,
Database,
Download,
ExternalLink,
FileSearch,
FileText,
Loader2,
Play,
Plus,
Search,
Send,
ShieldCheck,
Square,
Sparkles,
WandSparkles,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { DownloadWordButton } from "@/components/workspace/messages/download-word-button";
import { DocumentRewriteDiffDialog } from "@/components/workspace/messages/document-rewrite-diff-dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { openKnowledgeSource } from "@/core/llmwiki/source-links";
import MarkdownRenderer from "@/strategy-components/MarkdownRenderer";
import {
type LlmWikiSearchResult,
useLlmWikiKnowledgeBases,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
import {
type EnterpriseResearchTask,
type EnterpriseResearchTaskStatus,
type EnterpriseResearchReportEvent,
type EnterpriseResearchReportJob,
collectEnterpriseResearchTask,
cancelEnterpriseResearchReportJob,
createEnterpriseResearchAiPlan,
createEnterpriseResearchTask,
createEnterpriseResearchPlanDraft,
createEnterpriseResearchReportJob,
listEnterpriseResearchTasks,
listEnterpriseResearchReportJobs,
restoreEnterpriseResearchReportVersion,
streamEnterpriseResearchReportJob,
updateEnterpriseResearchPlan,
} from "@/strategy-components/api/enterprise-research";
type TemplateId = "topic" | "event" | "policy";
type TaskStatus = EnterpriseResearchTaskStatus;
type ResearchTemplate = {
id: TemplateId;
name: string;
description: string;
stages: string[];
buildQueries: (subject: string, focus: string) => string[];
};
const TEMPLATES: ResearchTemplate[] = [
{
id: "topic",
name: "通用议题研究",
description: "先梳理事实与背景,再归纳关键分歧、影响与待验证问题。",
stages: ["界定研究问题", "检索内部证据", "归纳观点与分歧", "形成研究任务包"],
buildQueries: (subject, focus) => [subject, `${subject} 背景 关键事实`, focus ? `${subject} ${focus}` : `${subject} 影响 风险 机会`],
},
{
id: "event",
name: "事件影响评估",
description: "适用于突发事件、舆情事件或专项行动的影响研判。",
stages: ["还原事件时间线", "识别相关主体", "评估影响传导", "输出风险与跟踪项"],
buildQueries: (subject, focus) => [
`${subject} 时间线 经过`,
`${subject} 相关主体 公开表态`,
focus ? `${subject} ${focus} 影响 风险` : `${subject} 影响 风险 应对`,
],
},
{
id: "policy",
name: "政策与行业研究",
description: "适用于政策、产业、市场等需比较变化与传导机制的主题。",
stages: ["明确政策或产业边界", "对比历史与现状", "拆解传导机制", "沉淀研判与建议"],
buildQueries: (subject, focus) => [
`${subject} 政策 现状`,
`${subject} 发展趋势 数据`,
focus ? `${subject} ${focus} 行业影响` : `${subject} 影响 挑战 机遇`,
],
},
];
const STATUS_META: Record<TaskStatus, { label: string; className: string }> = {
draft: { label: "草稿", className: "bg-muted text-muted-foreground" },
collecting: { label: "检索中", className: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200" },
ready: { label: "证据已就绪", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-200" },
partial: { label: "部分完成", className: "bg-orange-100 text-orange-800 dark:bg-orange-950/60 dark:text-orange-200" },
failed: { label: "检索失败", className: "bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-200" },
};
function formatTime(value?: string | null) {
if (!value) return "刚刚";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "刚刚";
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(parsed);
}
function getTemplate(id: TemplateId) {
return TEMPLATES.find((item) => item.id === id) ?? TEMPLATES[0];
}
function StatusBadge({ status }: { status: TaskStatus }) {
const meta = STATUS_META[status];
return <Badge className={cn("border-0", meta.className)}>{meta.label}</Badge>;
}
export default function EnterpriseResearchPage() {
const runtime = useLlmWikiRuntime();
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
const [tasks, setTasks] = useState<EnterpriseResearchTask[]>([]);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [subject, setSubject] = useState("");
const [focus, setFocus] = useState("");
const [templateId, setTemplateId] = useState<TemplateId>("topic");
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
const [reportJob, setReportJob] = useState<EnterpriseResearchReportJob | null>(null);
const [reportJobs, setReportJobs] = useState<EnterpriseResearchReportJob[]>([]);
const [reportDraft, setReportDraft] = useState("");
const [jobDraft, setJobDraft] = useState("");
const [reasoning, setReasoning] = useState("");
const [isStartingReport, setIsStartingReport] = useState(false);
const reportActionRef = useRef<EnterpriseResearchReportJob["action"]>("initial");
useEffect(() => {
let alive = true;
void listEnterpriseResearchTasks()
.then((stored) => {
if (!alive) return;
setTasks(stored);
setActiveTaskId(stored[0]?.id ?? null);
})
.catch(() => {
if (alive) toast.error("研究任务读取失败,请稍后重试");
});
return () => { alive = false; };
}, []);
const activeTask = useMemo(
() => tasks.find((task) => task.id === activeTaskId) ?? null,
[activeTaskId, tasks],
);
const template = getTemplate(templateId);
const selectedSource = activeTask?.sources.find((source) => source.chunk_id === selectedSourceId) ?? activeTask?.sources[0] ?? null;
const isWeKnoraReady = runtime.data?.provider === "weknora";
const consumeReportStream = (taskId: string, jobId: string, signal?: AbortSignal) => streamEnterpriseResearchReportJob(taskId, jobId, {
signal,
onEvent: (event: EnterpriseResearchReportEvent) => {
if (event.type === "snapshot") {
setReportJob(event.job);
reportActionRef.current = event.job.action;
if (event.job.action === "followup") setJobDraft(event.job.report_markdown || "");
else setReportDraft(event.job.report_markdown || "");
return;
}
if (event.type === "report_delta") {
if (reportActionRef.current === "followup") setJobDraft((current) => current + event.delta);
else setReportDraft((current) => current + event.delta);
return;
}
if (event.type === "reasoning_delta") {
setReasoning((current) => (current + event.delta).slice(-6_000));
return;
}
if (event.type === "phase") {
setReportJob((current) => current ? { ...current, status: event.status, phase: event.phase } : current);
return;
}
if (event.type === "completed" || event.type === "failed" || event.type === "cancelled") {
if (!event.job) return;
setReportJob(event.job);
setReportJobs((current) => [event.job!, ...current.filter((item) => item.id !== event.job!.id)]);
if (event.job.action === "followup") {
setJobDraft(event.job.report_markdown || "");
} else if (event.type === "completed") {
setReportDraft(event.job.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? {
...task,
report_markdown: event.job?.report_markdown ?? task.report_markdown,
report_summary: event.job?.report_summary ?? task.report_summary,
report_model_name: event.job?.model_name ?? task.report_model_name,
active_report_job_id: null,
} : task));
} else {
// A failed or cancelled rewrite may contain a persisted temporary
// draft. Never expose it as the committed report in local state.
setReportDraft(event.job.original_report || tasks.find((task) => task.id === taskId)?.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
}
if (event.type === "completed") toast.success("企业研究报告已完成");
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
}
},
}).catch((error: unknown) => {
if ((error as { name?: string } | null)?.name !== "AbortError") {
toast.error(error instanceof Error ? error.message : "报告流连接中断");
}
});
useEffect(() => {
if (!activeTaskId) {
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
return;
}
const controller = new AbortController();
let alive = true;
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
setIsStartingReport(false);
void listEnterpriseResearchReportJobs(activeTaskId)
.then((jobs) => {
if (!alive) return;
const latest = jobs[0] ?? null;
setReportJobs(jobs);
setReportJob(latest);
const latestReport = jobs.find((job) => job.status === "completed" && job.action !== "followup");
if (latestReport?.report_markdown) setReportDraft(latestReport.report_markdown);
const latestFollowup = jobs.find((job) => job.status === "completed" && job.action === "followup");
if (latestFollowup?.report_markdown) setJobDraft(latestFollowup.report_markdown);
if (latest && (latest.status === "queued" || latest.status === "running")) {
reportActionRef.current = latest.action;
void consumeReportStream(activeTaskId, latest.id, controller.signal);
}
})
.catch(() => {
if (alive) toast.error("报告任务读取失败,请稍后重试");
});
return () => {
alive = false;
controller.abort();
};
// The stream is intentionally scoped to the selected task only. It must not
// restart whenever token deltas update the task list.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTaskId]);
const resetComposer = () => {
setActiveTaskId(null);
setSubject("");
setFocus("");
setTemplateId("topic");
setSelectedKnowledgeBaseIds([]);
setSelectedSourceId(null);
setReportJob(null);
setReportJobs([]);
setReportDraft("");
setJobDraft("");
setReasoning("");
};
const toggleKnowledgeBase = (id: string, checked: boolean) => {
setSelectedKnowledgeBaseIds((current) => checked ? [...new Set([...current, id])] : current.filter((item) => item !== id));
};
const startResearch = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedSubject = subject.trim();
if (!normalizedSubject) {
toast.error("请先填写研究主题");
return;
}
if (!isWeKnoraReady) {
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
return;
}
const selectedTemplate = getTemplate(templateId);
const queries = [...new Set(selectedTemplate.buildQueries(normalizedSubject, focus.trim()).map((item) => item.trim()).filter(Boolean))];
try {
const created = await createEnterpriseResearchTask({
subject: normalizedSubject,
focus: focus.trim(),
template_id: templateId,
knowledge_base_ids: selectedKnowledgeBaseIds,
queries,
});
const collecting: EnterpriseResearchTask = { ...created, status: "collecting" };
setTasks((current) => [collecting, ...current.filter((item) => item.id !== collecting.id)].slice(0, 20));
setActiveTaskId(created.id);
setSelectedSourceId(null);
const completed = await collectEnterpriseResearchTask(created.id);
setTasks((current) => current.map((item) => item.id === completed.id ? completed : item));
if (completed.sources[0]) setSelectedSourceId(completed.sources[0].chunk_id);
if (completed.status === "failed") toast.error("未检索到可用证据");
else toast.success(`已完成证据收集:${completed.sources.length} 条资料`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建研究任务失败,请稍后重试");
}
};
const createPlanDraft = async (taskId: string) => {
const updated = await createEnterpriseResearchPlanDraft(taskId);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const createAiPlan = async (taskId: string, input: { model_name?: string | null; instruction?: string }) => {
const updated = await createEnterpriseResearchAiPlan(taskId, input);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const savePlan = async (taskId: string, input: { plan_markdown: string; selected_source_ids: string[] }) => {
const updated = await updateEnterpriseResearchPlan(taskId, input);
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
return updated;
};
const startReport = async (
taskId: string,
input: Parameters<typeof createEnterpriseResearchReportJob>[1] = {},
) => {
if (isStartingReport) return;
setIsStartingReport(true);
try {
const job = await createEnterpriseResearchReportJob(taskId, input);
reportActionRef.current = job.action;
setReportJob(job);
setReportJobs((current) => [job, ...current.filter((item) => item.id !== job.id)]);
if (job.action === "followup") setJobDraft(job.report_markdown || "");
else setReportDraft(job.report_markdown || "");
setReasoning("");
void consumeReportStream(taskId, job.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建报告写作任务失败");
} finally {
setIsStartingReport(false);
}
};
const cancelReport = async (taskId: string, jobId: string) => {
const cancelled = await cancelEnterpriseResearchReportJob(taskId, jobId);
setReportJob(cancelled);
setReportJobs((current) => [cancelled, ...current.filter((item) => item.id !== cancelled.id)]);
if (cancelled.action === "followup") setJobDraft("");
else setReportDraft(tasks.find((task) => task.id === taskId)?.report_markdown || "");
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
toast.success("已取消报告任务,已保存的正式报告未受影响");
};
const restoreReport = async (taskId: string, jobId: string) => {
const updated = await restoreEnterpriseResearchReportVersion(taskId, jobId);
const jobs = await listEnterpriseResearchReportJobs(taskId);
setTasks((current) => current.map((task) => task.id === updated.id ? updated : task));
setReportJobs(jobs);
setReportJob(jobs[0] ?? null);
setReportDraft(updated.report_markdown || "");
toast.success("已恢复改写前版本");
};
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<header className="flex shrink-0 items-center justify-between border-b bg-card px-5 py-3.5 sm:px-7">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FileSearch className="size-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold sm:text-lg">企业深度研究</h1>
<Badge variant="outline" className="text-muted-foreground">独立工作台</Badge>
</div>
<p className="hidden text-xs text-muted-foreground sm:block">面向内部知识库的研究任务、证据编排与可追溯交付</p>
</div>
</div>
<Button size="sm" onClick={resetComposer}>
<Plus className="mr-1.5 size-4" />
新建任务
</Button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[272px_minmax(440px,0.9fr)_minmax(420px,1.1fr)]">
<aside className="min-h-0 border-b bg-muted/20 xl:border-r xl:border-b-0">
<div className="flex items-center justify-between px-5 py-4">
<div>
<p className="text-sm font-semibold">研究任务</p>
<p className="mt-0.5 text-xs text-muted-foreground">服务端保留最近 20 条</p>
</div>
<Badge variant="secondary">{tasks.length}</Badge>
</div>
<ScrollArea className="h-[220px] xl:h-[calc(100%-76px)]">
<div className="space-y-1 px-3 pb-4">
{tasks.length ? tasks.map((task) => (
<button
key={task.id}
type="button"
onClick={() => { setActiveTaskId(task.id); setSelectedSourceId(task.sources[0]?.chunk_id ?? null); }}
className={cn(
"w-full rounded-lg border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
activeTaskId === task.id ? "border-primary/35 bg-primary/8" : "border-transparent hover:bg-accent",
)}
>
<div className="flex items-start justify-between gap-2">
<span className="line-clamp-2 text-sm font-medium leading-5">{task.subject}</span>
<StatusBadge status={task.status} />
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>{task.sources.length} 条资料</span><span>·</span><span>{formatTime(task.updated_at || task.created_at)}</span>
</div>
</button>
)) : (
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">尚无任务。创建第一个研究任务开始。</div>
)}
</div>
</ScrollArea>
</aside>
<main className="min-h-0 overflow-y-auto border-b p-5 xl:border-r xl:border-b-0 xl:p-6">
{!activeTaskId ? (
<form className="mx-auto max-w-2xl space-y-5" onSubmit={startResearch}>
<section>
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
</section>
<Card>
<CardHeader className="gap-1">
<CardTitle className="text-base">研究对象与范围</CardTitle>
<CardDescription>用一句话明确需要研究的对象;补充范围可帮助生成更聚焦的检索方向。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<label className="block space-y-2 text-sm font-medium" htmlFor="enterprise-research-subject">
研究主题 <span className="text-destructive">*</span>
<Input id="enterprise-research-subject" value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="例如:新能源汽车产业链的供应风险" maxLength={180} />
</label>
<label className="block space-y-2 text-sm font-medium" htmlFor="enterprise-research-focus">
重点范围 <span className="font-normal text-muted-foreground">(可选)</span>
<Textarea id="enterprise-research-focus" value={focus} onChange={(event) => setFocus(event.target.value)} placeholder="例如:聚焦 2025 年以来的海外供应、关键原材料与对国内产能的影响" className="min-h-22 resize-y" maxLength={800} />
</label>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-1">
<CardTitle className="text-base">选择研究模板</CardTitle>
<CardDescription>模板只决定问题拆解和证据编排方式,不会替换你的原始问题。</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
{TEMPLATES.map((item) => (
<button
type="button"
key={item.id}
onClick={() => setTemplateId(item.id)}
className={cn(
"rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
templateId === item.id ? "border-primary bg-primary/5" : "hover:bg-accent",
)}
>
<span className="block text-sm font-semibold">{item.name}</span>
<span className="mt-1.5 block text-xs leading-5 text-muted-foreground">{item.description}</span>
</button>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="gap-1">
<CardTitle className="flex items-center gap-2 text-base"><Database className="size-4 text-primary" />证据范围</CardTitle>
<CardDescription>{isWeKnoraReady ? "只显示当前账号已授权的知识库。未选择时会检索全部可见知识库。" : "知识服务未启用,无法开始内部证据检索。"}</CardDescription>
</CardHeader>
<CardContent>
{knowledgeBases.isLoading ? <div className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />正在读取知识库…</div> : knowledgeBases.isError ? <div className="flex items-center gap-2 text-sm text-destructive"><AlertCircle className="size-4" />知识库读取失败</div> : (
<div className="space-y-2">
{knowledgeBases.data?.length ? knowledgeBases.data.map((base) => {
const checked = selectedKnowledgeBaseIds.includes(base.id);
return <label key={base.id} className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/60">
<Checkbox checked={checked} onCheckedChange={(next) => toggleKnowledgeBase(base.id, next)} aria-label={`选择知识库 ${base.name}`} />
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{base.name}</span><span className="mt-0.5 block line-clamp-1 text-xs text-muted-foreground">{base.description || `${base.knowledge_count} 份资料`}</span></span>
<span className="text-xs text-muted-foreground">{base.chunk_count} 段</span>
</label>;
}) : <p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">当前账号没有可用知识库,可先前往“知识库管理”添加或发布资料。</p>}
</div>
)}
</CardContent>
</Card>
<div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm"><span className="font-medium">即将检索 {template.stages.length - 1} 个研究方向</span><p className="mt-1 text-xs text-muted-foreground">会生成主题、背景与重点范围三组可见检索词。</p></div>
<Button type="submit" disabled={!isWeKnoraReady || knowledgeBases.isLoading}><Play className="mr-1.5 size-4" />开始证据编排</Button>
</div>
</form>
) : activeTask ? (
<TaskOverview task={activeTask} reportJob={reportJob} isStartingReport={isStartingReport} onNew={resetComposer} onCreatePlanDraft={createPlanDraft} onCreateAiPlan={createAiPlan} onSavePlan={savePlan} />
) : null}
</main>
<section className="min-h-0 overflow-y-auto bg-muted/10 p-5 xl:p-6">
<EvidencePane task={activeTask} source={selectedSource} reportJob={reportJob} reportJobs={reportJobs} reportDraft={reportDraft} jobDraft={jobDraft} reasoning={reasoning} isStartingReport={isStartingReport} onStartReport={startReport} onCancelReport={cancelReport} onRestoreReport={restoreReport} onSourceSelect={setSelectedSourceId} />
</section>
</div>
</div>
);
}
function TaskOverview({
task,
reportJob,
isStartingReport,
onNew,
onCreatePlanDraft,
onCreateAiPlan,
onSavePlan,
}: {
task: EnterpriseResearchTask;
reportJob: EnterpriseResearchReportJob | null;
isStartingReport: boolean;
onNew: () => void;
onCreatePlanDraft: (taskId: string) => Promise<EnterpriseResearchTask>;
onCreateAiPlan: (taskId: string, input: { model_name?: string | null; instruction?: string }) => Promise<EnterpriseResearchTask>;
onSavePlan: (taskId: string, input: { plan_markdown: string; selected_source_ids: string[] }) => Promise<EnterpriseResearchTask>;
}) {
const template = getTemplate(task.template_id as TemplateId);
const [planMarkdown, setPlanMarkdown] = useState(task.plan_markdown ?? "");
const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>(task.selected_source_ids ?? []);
const [isCreatingPlan, setIsCreatingPlan] = useState(false);
const [isSavingPlan, setIsSavingPlan] = useState(false);
const [isCreatingAiPlan, setIsCreatingAiPlan] = useState(false);
const [planModel, setPlanModel] = useState<string | null>(null);
const [planInstruction, setPlanInstruction] = useState("");
const canPlan = task.status === "ready" || task.status === "partial";
const isWriting = isStartingReport || reportJob?.status === "queued" || reportJob?.status === "running";
useEffect(() => {
setPlanMarkdown(task.plan_markdown ?? "");
setSelectedSourceIds(task.selected_source_ids ?? []);
}, [task.id, task.plan_markdown, task.selected_source_ids]);
const toggleSource = (sourceId: string, checked: boolean) => {
setSelectedSourceIds((current) => checked ? [...new Set([...current, sourceId])] : current.filter((item) => item !== sourceId));
};
const handleCreatePlan = async () => {
setIsCreatingPlan(true);
try {
const updated = await onCreatePlanDraft(task.id);
toast.success(`已根据 ${updated.selected_source_ids.length} 条资料生成可编辑研究计划`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成研究计划失败");
} finally {
setIsCreatingPlan(false);
}
};
const handleSavePlan = async () => {
setIsSavingPlan(true);
try {
const updated = await onSavePlan(task.id, { plan_markdown: planMarkdown, selected_source_ids: selectedSourceIds });
toast.success(`研究计划已保存,已选 ${updated.selected_source_ids.length} 条资料`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存研究计划失败");
} finally {
setIsSavingPlan(false);
}
};
const handleCreateAiPlan = async () => {
setIsCreatingAiPlan(true);
try {
const updated = await onCreateAiPlan(task.id, { model_name: planModel, instruction: planInstruction.trim() });
setPlanMarkdown(updated.plan_markdown ?? "");
setSelectedSourceIds(updated.selected_source_ids ?? []);
toast.success("AI 研究计划已生成,可继续人工修改后保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "AI 规划失败");
} finally {
setIsCreatingAiPlan(false);
}
};
return <div className="mx-auto max-w-2xl space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><Badge variant="secondary" className="mb-3"><ClipboardList className="size-3" />研究任务</Badge><h2 className="text-xl font-semibold tracking-tight">{task.subject}</h2>{task.focus ? <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">{task.focus}</p> : null}</div>
<div className="flex items-center gap-2"><StatusBadge status={task.status} /><Button size="sm" variant="outline" onClick={onNew}><Plus className="mr-1 size-4" />新建</Button></div>
</div>
<Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究路径</CardTitle><CardDescription>{template.name} · 每一步保留用户可核验的检索词和证据来源。</CardDescription></CardHeader>
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
const current = task.status === "collecting" && index === 1;
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
})}</ol></CardContent>
</Card>
<Card>
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
</Card>
{canPlan ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
<CardContent className="space-y-4">
{!task.plan_markdown ? <div className="rounded-lg border border-dashed bg-muted/30 p-4"><p className="text-sm font-medium">证据已收集,可生成一份可编辑的研究计划</p><p className="mt-1 text-xs leading-5 text-muted-foreground">初版会按当前研究模板组织目标、核心问题、章节和写作约束;生成报告前仍可修改。</p><Button className="mt-3" size="sm" onClick={handleCreatePlan} disabled={isCreatingPlan}><Sparkles className="mr-1.5 size-4" />{isCreatingPlan ? "正在生成…" : "生成研究计划"}</Button></div> : <>
<div className="space-y-2"><div className="flex items-center justify-between gap-3"><label htmlFor={`enterprise-plan-${task.id}`} className="text-sm font-medium">可编辑研究计划</label><span className="text-xs text-muted-foreground">{planMarkdown.length} / 16,000</span></div><Textarea id={`enterprise-plan-${task.id}`} value={planMarkdown} onChange={(event) => setPlanMarkdown(event.target.value)} className="min-h-80 resize-y font-mono text-xs leading-6" maxLength={16_000} /></div>
<div className="space-y-2"><div className="flex items-center justify-between gap-3"><p className="text-sm font-medium">选用资料</p><span className="text-xs text-muted-foreground">已选 {selectedSourceIds.length} / {task.sources.length}</span></div><ScrollArea className="h-44 rounded-md border"><div className="space-y-1 p-2">{task.sources.map((source, index) => { const checked = selectedSourceIds.includes(source.chunk_id); return <label key={source.chunk_id || `${source.knowledge_id}-${index}`} className="flex cursor-pointer items-start gap-2 rounded-md px-2 py-2 hover:bg-muted"><Checkbox checked={checked} onCheckedChange={(next) => toggleSource(source.chunk_id, next === true)} aria-label={`选用资料 ${source.title || source.filename || index + 1}`} /><span className="min-w-0 text-xs leading-5"><span className="block truncate font-medium">{source.title || source.filename || "未命名资料"}</span><span className="block truncate text-muted-foreground">{source.knowledge_base_name || "知识库"}</span></span></label>; })}</div></ScrollArea></div>
<div className="rounded-lg border bg-muted/20 p-3"><div className="flex flex-wrap items-center gap-2"><ModelNameSelect value={planModel} onChange={setPlanModel} disabled={isCreatingAiPlan || isWriting} triggerClassName="w-40" /><Input value={planInstruction} onChange={(event) => setPlanInstruction(event.target.value)} placeholder="AI 规划补充要求(可选)" className="h-8 min-w-48 flex-1 text-xs" maxLength={2_000} /><Button size="sm" variant="outline" onClick={handleCreateAiPlan} disabled={isCreatingAiPlan || isWriting}><WandSparkles className="mr-1.5 size-4" />{isCreatingAiPlan ? "正在规划…" : "AI 优化计划"}</Button></div></div>
<div className="flex justify-end"><Button size="sm" onClick={handleSavePlan} disabled={isSavingPlan || !planMarkdown.trim() || isWriting}>{isSavingPlan ? "正在保存…" : "保存研究计划"}</Button></div>
{reportJob ? <div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground"><span className="font-medium text-foreground">报告任务:</span>{reportJob.status === "completed" ? "已完成" : reportJob.status === "failed" ? `失败:${reportJob.error_message || "请稍后重试"}` : `正在${reportJob.phase === "understanding" ? "理解研究计划" : reportJob.phase === "reviewing" ? "校验报告" : "流式写入 Markdown"}`};仅使用本任务已保存的计划与已选资料快照。</div> : null}
</>}
</CardContent>
</Card> : null}
</div>;
}
function EvidencePane({
task, source, reportJob, reportJobs, reportDraft, jobDraft, reasoning, isStartingReport,
onStartReport, onCancelReport, onRestoreReport, onSourceSelect,
}: {
task: EnterpriseResearchTask | null;
source: LlmWikiSearchResult | null;
reportJob: EnterpriseResearchReportJob | null;
reportJobs: EnterpriseResearchReportJob[];
reportDraft: string;
jobDraft: string;
reasoning: string;
isStartingReport: boolean;
onStartReport: (taskId: string, input?: Parameters<typeof createEnterpriseResearchReportJob>[1]) => Promise<void>;
onCancelReport: (taskId: string, jobId: string) => Promise<void>;
onRestoreReport: (taskId: string, jobId: string) => Promise<void>;
onSourceSelect: (id: string | null) => void;
}) {
const [modelName, setModelName] = useState<string | null>(null);
const [style, setStyle] = useState<"formal" | "analytical" | "concise">("formal");
const [targetLength, setTargetLength] = useState("5000");
const [rewriteInstruction, setRewriteInstruction] = useState("优化结构、逻辑与表达,减少重复内容,并保留事实与引用");
const [followup, setFollowup] = useState("");
const [diffJob, setDiffJob] = useState<EnterpriseResearchReportJob | null>(null);
if (!task) return <div className="flex h-full min-h-72 flex-col items-center justify-center rounded-xl border border-dashed p-8 text-center"><div className="flex size-11 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"><BookOpenCheck className="size-5" /></div><h2 className="mt-4 text-sm font-semibold">等待研究任务</h2><p className="mt-1 max-w-xs text-sm leading-6 text-muted-foreground">提交主题后,这里会展示证据、研究报告和可追溯引用。</p></div>;
const isCollecting = task.status === "collecting";
const isWriting = isStartingReport || reportJob?.status === "queued" || reportJob?.status === "running";
const currentReport = isWriting && reportJob?.action !== "followup"
? reportDraft
: reportDraft || task.report_markdown || "";
const latestRewrite = reportJobs.find((job) => job.action === "rewrite" && job.status === "completed" && job.original_report);
const latestCommittedJob = reportJobs.find((job) => job.action !== "followup" && job.status === "completed");
const completedFollowups = reportJobs.filter((job) => job.action === "followup" && job.status === "completed");
const selectedSources = task.selected_source_ids.map((id) => task.sources.find((item) => item.chunk_id === id)).filter(Boolean) as LlmWikiSearchResult[];
const citationIndex = new Map(selectedSources.map((item, index) => [item.chunk_id, index + 1]));
const renderedReport = currentReport.replace(/\[资料:([^\]]+)\]/g, (match, id: string) => {
const index = citationIndex.get(id.trim());
return index ? `[[${index}]]` : match;
});
const parsedTarget = Number(targetLength);
const downloadMarkdown = () => {
const blob = new Blob([currentReport], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${task.subject.replace(/[\\/:*?"<>|]/g, "-") || "企业研究报告"}.md`;
anchor.click();
URL.revokeObjectURL(url);
};
const start = (action: "initial" | "rewrite" | "followup", instruction = "") => onStartReport(task.id, {
model_name: modelName,
action,
instruction,
style,
target_length: Number.isFinite(parsedTarget) && parsedTarget >= 800 ? parsedTarget : null,
});
const phaseOrder = ["understanding", "writing", "reviewing", "completed"];
const phaseIndex = reportJob ? phaseOrder.indexOf(reportJob.phase) : -1;
return <div className="space-y-4">
{task.plan_markdown && !currentReport && !isWriting ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-sm">报告写作配置</CardTitle><CardDescription>写作只使用已保存计划和 {task.selected_source_ids.length} 条已选资料。</CardDescription></CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3"><ModelNameSelect value={modelName} onChange={setModelName} triggerClassName="w-full" /><Select value={style} onValueChange={(value) => setStyle(value as typeof style)}><SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="formal">正式报告</SelectItem><SelectItem value="analytical">分析研判</SelectItem><SelectItem value="concise">精炼摘要</SelectItem></SelectContent></Select><Input type="number" min={800} max={30000} value={targetLength} onChange={(event) => setTargetLength(event.target.value)} className="h-8 text-xs" aria-label="报告目标字数" /></div>
<Button className="w-full" onClick={() => void start("initial")}><Play className="mr-1.5 size-4" />开始流式撰写报告</Button>
</CardContent>
</Card> : null}
{isWriting && reportJob ? <Card>
<CardHeader className="gap-1"><div className="flex items-center justify-between gap-3"><CardTitle className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin text-primary" />{reportJob.action === "followup" ? "正在回答报告追问" : reportJob.action === "rewrite" ? "正在全文改写" : "正在撰写研究报告"}</CardTitle><Button size="sm" variant="outline" onClick={() => void onCancelReport(task.id, reportJob.id)}><Square className="mr-1.5 size-3.5" />停止</Button></div><CardDescription>真实报告只在任务成功完成后提交;取消或失败不会覆盖已保存版本。</CardDescription></CardHeader>
<CardContent><ol className="grid gap-2 sm:grid-cols-4">{["理解要求", "流式写入", "校验报告", "保存版本"].map((label, index) => <li key={label} className={cn("rounded-md border px-3 py-2 text-xs", index < phaseIndex ? "border-emerald-200 bg-emerald-50 text-emerald-800" : index === phaseIndex ? "border-primary/40 bg-primary/5 text-primary" : "text-muted-foreground")}><span className="font-medium">{index + 1}. {label}</span></li>)}</ol>{reasoning ? <details className="mt-3 rounded-md border px-3 py-2 text-xs text-muted-foreground"><summary className="cursor-pointer">模型思考过程(不会写入报告)</summary><pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-sans leading-5">{reasoning}</pre></details> : null}</CardContent>
</Card> : null}
{currentReport ? <Card className="overflow-hidden">
<CardHeader className="border-b py-4"><div className="flex flex-wrap items-start justify-between gap-3"><div><CardTitle className="flex items-center gap-2 text-sm"><FileText className="size-4 text-primary" />{task.subject}</CardTitle><CardDescription className="mt-1">{latestCommittedJob?.report_summary || task.report_summary || `${currentReport.length} 字`}</CardDescription></div><div className="flex items-center gap-1"><Button size="icon-sm" variant="ghost" onClick={downloadMarkdown} aria-label="下载 Markdown" title="下载 Markdown"><Download className="size-4" /></Button><DownloadWordButton markdown={currentReport} fileName={task.subject} title={task.subject} aria-label="下载 Word" />{latestRewrite ? <Button size="sm" variant="outline" onClick={() => setDiffJob(latestRewrite)}>查看改写对比</Button> : null}</div></div></CardHeader>
<CardContent className="p-0"><ScrollArea className="h-[430px]"><article className="p-5"><MarkdownRenderer content={renderedReport} onCitationClick={(index) => onSourceSelect(selectedSources[index - 1]?.chunk_id ?? null)} /></article></ScrollArea></CardContent>
</Card> : null}
{currentReport && !isWriting ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-sm">继续完善报告</CardTitle><CardDescription>全文改写会保存原版本;追问只生成回答,不会覆盖报告。</CardDescription></CardHeader>
<CardContent className="space-y-3"><div className="grid gap-2 sm:grid-cols-[160px_120px_1fr]"><ModelNameSelect value={modelName} onChange={setModelName} triggerClassName="w-full" /><Select value={style} onValueChange={(value) => setStyle(value as typeof style)}><SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="formal">正式报告</SelectItem><SelectItem value="analytical">分析研判</SelectItem><SelectItem value="concise">精炼表达</SelectItem></SelectContent></Select><Input type="number" min={800} max={30000} value={targetLength} onChange={(event) => setTargetLength(event.target.value)} className="h-8 text-xs" aria-label="改写目标字数" /></div><Textarea value={rewriteInstruction} onChange={(event) => setRewriteInstruction(event.target.value)} className="min-h-20 text-sm" maxLength={4_000} /><div className="flex justify-end gap-2">{latestRewrite ? <Button variant="outline" onClick={() => void onRestoreReport(task.id, latestRewrite.id)}>恢复最近改写前版本</Button> : null}<Button onClick={() => void start("rewrite", rewriteInstruction)}><WandSparkles className="mr-1.5 size-4" />全文改写</Button></div><div className="flex gap-2 border-t pt-3"><Input value={followup} onChange={(event) => setFollowup(event.target.value)} placeholder="基于当前报告和已选资料继续追问" maxLength={4_000} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && followup.trim()) { event.preventDefault(); void start("followup", followup.trim()); setFollowup(""); } }} /><Button size="icon" disabled={!followup.trim()} onClick={() => { void start("followup", followup.trim()); setFollowup(""); }} aria-label="发送报告追问"><Send className="size-4" /></Button></div></CardContent>
</Card> : null}
{(jobDraft || completedFollowups.length) ? <Card><CardHeader className="gap-1"><CardTitle className="text-sm">报告追问</CardTitle><CardDescription>回答基于当前报告和已选资料,不会自动发起新检索。</CardDescription></CardHeader><CardContent className="space-y-4">{jobDraft ? <div className="rounded-md border bg-muted/20 p-4"><MarkdownRenderer content={jobDraft} /></div> : null}{completedFollowups.slice(0, 3).map((job) => job.id === reportJob?.id && jobDraft ? null : <div key={job.id} className="border-t pt-4"><p className="mb-2 text-xs font-medium text-muted-foreground">{job.instruction}</p><MarkdownRenderer content={job.report_markdown} /></div>)}</CardContent></Card> : null}
<div className="flex items-start justify-between gap-3"><div><p className="text-sm font-semibold">证据工作台</p><p className="mt-1 text-xs text-muted-foreground">{isCollecting ? "正在收集内部证据…" : `已收集 ${task.sources.length} 条去重资料;报告引用可点击定位`}</p></div>{isCollecting ? <Loader2 className="size-4 animate-spin text-primary" /> : <ShieldCheck className="size-5 text-emerald-600" />}</div>
{isCollecting ? <div className="rounded-xl border bg-card p-5 text-sm text-muted-foreground"><div className="flex items-center gap-2"><Loader2 className="size-4 animate-spin" />正在执行 {task.queries.length} 个检索方向</div></div> : task.sources.length ? <div className="grid gap-4 2xl:grid-cols-[minmax(220px,0.8fr)_minmax(260px,1.2fr)]"><div className="rounded-xl border bg-card"><div className="border-b px-4 py-3 text-xs font-medium text-muted-foreground">资料列表</div><ScrollArea className="h-80"><div className="p-2">{task.sources.map((item, index) => <button key={item.chunk_id || `${item.knowledge_id}-${index}`} type="button" onClick={() => onSourceSelect(item.chunk_id)} className={cn("w-full rounded-lg p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", source?.chunk_id === item.chunk_id ? "bg-primary/10" : "hover:bg-muted")}><p className="line-clamp-2 text-sm font-medium leading-5">{index + 1}. {item.title || item.filename || "未命名资料"}</p><p className="mt-1 truncate text-xs text-muted-foreground">{item.knowledge_base_name || "知识库"}</p></button>)}</div></ScrollArea></div><article className="rounded-xl border bg-card p-5"><p className="text-sm font-semibold">{source?.title || source?.filename || "资料预览"}</p><p className="mt-1 text-xs text-muted-foreground">{source?.knowledge_base_name || "知识库"} · 段落 {typeof source?.chunk_index === "number" ? source.chunk_index + 1 : "-"}</p><p className="mt-5 whitespace-pre-wrap text-sm leading-7 text-foreground/90">{source?.content || "请选择一条资料查看内容。"}</p>{source?.url ? <button type="button" className="mt-5 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline" onClick={() => { if (!openKnowledgeSource(source)) toast.error("暂时无法定位该资料原文"); }}><ExternalLink className="size-4" />定位知识库原文</button> : null}</article></div> : <div className="rounded-xl border border-dashed bg-card p-6 text-center"><FileText className="mx-auto size-6 text-muted-foreground" /><h2 className="mt-3 text-sm font-semibold">未发现可用资料</h2></div>}
<DocumentRewriteDiffDialog
open={Boolean(diffJob)}
onOpenChange={(open) => { if (!open) setDiffJob(null); }}
fileName={`${task.subject}.md`}
original={diffJob?.original_report || ""}
rewritten={diffJob?.report_markdown || ""}
/>
</div>;
}
@@ -26,6 +26,7 @@ import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtab
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import DeepResearchPage from "./DeepResearchPage";
import EnterpriseResearchPage from "./EnterpriseResearchPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
@@ -117,6 +118,7 @@ export default function StrategyRoutes() {
}
/>
<Route path="qa/deep-research" element={<DeepResearchPage />} />
<Route path="qa/enterprise-research" element={<EnterpriseResearchPage />} />
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
<Route
@@ -63,6 +63,7 @@ import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage';
import EnterpriseResearchPage from './EnterpriseResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
@@ -563,6 +564,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="enterprise-research"
element={
<WorkspaceLayout {...layoutProps}>
<EnterpriseResearchPage />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
@@ -405,6 +405,10 @@ function DeepResearchWorkbenchInner() {
const [mode, setMode] = useState<ResearchMode>("basic");
const [generateImages, setGenerateImages] = useState(false);
const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(DEFAULT_ADVANCED_OPTIONS);
// A session's initial config is deliberately frozen when collection starts.
// Keep only post-start outline choices separately so another research record
// cannot leak its workflow selection into this record's writing card.
const [reportCardOutlineOverrides, setReportCardOutlineOverrides] = useState<Record<string, string>>({});
const [classicMode, setClassicMode] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -468,6 +472,7 @@ function DeepResearchWorkbenchInner() {
: null;
const streamJobId = activeJobId ?? lastCompletedJobId;
const isFollowUp = session?.status === "completed" && Boolean(session.reportMarkdown);
const reportCardOutlineOverride = session ? reportCardOutlineOverrides[session.id] : undefined;
const availableModes = useMemo<ResearchMode[]>(
() => capabilities.data?.modes ?? DEFAULT_AVAILABLE_MODES,
[capabilities.data?.modes],
@@ -1435,6 +1440,19 @@ function DeepResearchWorkbenchInner() {
document.getElementById("deep-research-composer")?.focus();
}, []);
const updateAdvancedOption = useCallback(
<K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => {
setAdvancedOptions((current) => ({ ...current, [key]: value }) as AdvancedResearchOptions);
if (key !== "customOutline" || !session?.id) return;
setReportCardOutlineOverrides((current) => (
current[session.id] === value
? current
: { ...current, [session.id]: value as string }
));
},
[session?.id],
);
const handleDelete = useCallback(async (id: string) => {
try {
await actions.delete.mutateAsync(id);
@@ -1566,6 +1584,7 @@ function DeepResearchWorkbenchInner() {
availableModes={availableModes}
defaultMode={session.mode}
defaultOptions={advancedOptionsFromConfig(session.config)}
outlineOverride={reportCardOutlineOverride}
defaultGenerateImages={session.config?.generate_images === true}
modelLabel={modelLabel}
materialCount={materialCount}
@@ -1769,9 +1788,7 @@ function DeepResearchWorkbenchInner() {
className="h-7 px-2 text-xs"
disabled={Boolean(isFollowUp) || jobWriting}
appliedContent={advancedOptions.customOutline}
onApply={(content) =>
setAdvancedOptions((current) => ({ ...current, customOutline: content }))
}
onApply={(content) => updateAdvancedOption("customOutline", content)}
/>
</div>
</div>
@@ -1855,7 +1872,7 @@ function DeepResearchWorkbenchInner() {
onClassicModeChange={setClassicMode}
imageGenerationAvailable={imageGenerationAvailable}
imageLimit={capabilities.data?.imageGeneration?.maxImages ?? 4}
onChange={(key, value) => setAdvancedOptions((current) => ({ ...current, [key]: value }) as AdvancedResearchOptions)}
onChange={updateAdvancedOption}
/>
</div>
);
@@ -8,7 +8,7 @@
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
*/
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ChevronRight,
ImagePlus,
@@ -32,6 +32,7 @@ import {
type ResearchMode,
type ResearchTone,
} from "@/strategy-components/api/deep-research";
import { useReportStructures } from "@/strategy-components/api/report-structures";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
const MODE_LABELS: Record<ResearchMode, string> = {
@@ -182,19 +183,16 @@ export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
export type ReportOutlineTemplate = "general" | "progressive" | "custom";
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
const REPORT_STRUCTURE_VALUE_PREFIX = "structure:";
const REPORT_OUTLINE_TEMPLATE_OPTIONS: Array<{ value: ReportOutlineTemplate; label: string }> = [
{ value: "general", label: "通用分析结构" },
{ value: "progressive", label: "递进式论证结构" },
{ value: "custom", label: "自定义结构" },
];
function matchingOutlineTemplate(outline: string): ReportOutlineTemplate {
function matchingReportStructureId(
outline: string,
structures: Array<{ id: string; content: string }>,
): string | null {
const normalized = outline.trim();
if (normalized === DEFAULT_REPORT_OUTLINE.trim()) return "general";
if (normalized === PROGRESSIVE_REPORT_OUTLINE.trim()) return "progressive";
return "custom";
if (!normalized) return null;
return structures.find((structure) => structure.content.trim() === normalized)?.id ?? null;
}
/** Shared by the initial settings dialog and the write-confirmation card. */
@@ -207,19 +205,26 @@ export function ReportOutlineEditor({
value: string;
onChange: (value: string) => void;
}) {
const [customSelected, setCustomSelected] = useState(false);
const template = customSelected ? "custom" : matchingOutlineTemplate(value);
// 与「工作流配置」共用报告结构库;创建/编辑/启停后 React Query 的失效会
// 自动刷新本下拉,避免维护一份会逐渐过时的前端硬编码模板。
const structuresQuery = useReportStructures({ status: "enabled" });
const structures = structuresQuery.data ?? [];
const selectedStructureId = matchingReportStructureId(value, structures);
const templateValue = selectedStructureId
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
: CUSTOM_REPORT_STRUCTURE_VALUE;
const selectTemplate = (next: ReportOutlineTemplate) => {
if (next === "custom") {
setCustomSelected(true);
// “自定义” is an intentional blank starting point. The two presets are
// still available as one-click scaffolds whenever the user wants them.
const selectTemplate = (next: string) => {
if (next === CUSTOM_REPORT_STRUCTURE_VALUE) {
// “自定义” is an intentional blank starting point. Saved structures are
// always available as one-click scaffolds whenever the user wants them.
onChange("");
return;
}
setCustomSelected(false);
onChange(next === "general" ? DEFAULT_REPORT_OUTLINE : PROGRESSIVE_REPORT_OUTLINE);
if (!next.startsWith(REPORT_STRUCTURE_VALUE_PREFIX)) return;
const structureId = next.slice(REPORT_STRUCTURE_VALUE_PREFIX.length);
const structure = structures.find((item) => item.id === structureId);
if (structure) onChange(structure.content);
};
return (
@@ -227,14 +232,23 @@ export function ReportOutlineEditor({
<label htmlFor={`${id}-template`} className="block text-xs font-medium">
报告结构模板
</label>
<Select value={template} onValueChange={(next) => selectTemplate(next as ReportOutlineTemplate)}>
<Select value={templateValue} onValueChange={selectTemplate}>
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_OUTLINE_TEMPLATE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
{structuresQuery.isLoading && (
<SelectItem value="__report_structures_loading__" disabled>正在加载工作流结构…</SelectItem>
)}
{structuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
)}
{structures.map((structure) => (
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
{structure.title}
</SelectItem>
))}
<SelectItem value={CUSTOM_REPORT_STRUCTURE_VALUE}>自定义结构</SelectItem>
</SelectContent>
</Select>
<label htmlFor={id} className="block pt-1 text-xs font-medium">
@@ -252,7 +266,7 @@ export function ReportOutlineEditor({
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据"}
/>
<p className="text-[10px] leading-4 text-muted-foreground">
选择预设模板会替换下方内容;选择“自定义”会清空后从头编辑。支持 Markdown 标题或编号列表。
选择工作流结构会替换下方内容;选择“自定义”会清空后从头编辑。支持 Markdown 标题或编号列表。
</p>
</div>
);
@@ -340,6 +354,12 @@ export function ReportConfigCard({
availableModes,
defaultMode,
defaultOptions,
/**
* A report structure chosen from the collector's workflow configuration
* while this card is open. It supersedes only the outline, leaving choices
* already made in this card intact.
*/
outlineOverride,
defaultGenerateImages,
modelLabel,
materialCount,
@@ -352,6 +372,7 @@ export function ReportConfigCard({
availableModes: ResearchMode[];
defaultMode: ResearchMode;
defaultOptions: AdvancedResearchOptions;
outlineOverride?: string;
defaultGenerateImages: boolean;
modelLabel: string;
/** Number of unique, harvestable materials visible in the collector thread. */
@@ -365,10 +386,26 @@ export function ReportConfigCard({
const [mode, setMode] = useState<ResearchMode>(
availableModes.includes(defaultMode) ? defaultMode : (availableModes[0] ?? "basic"),
);
const [options, setOptions] = useState<AdvancedResearchOptions>(defaultOptions);
const [options, setOptions] = useState<AdvancedResearchOptions>(() => ({
...defaultOptions,
customOutline: outlineOverride ?? defaultOptions.customOutline,
}));
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
const [advancedOpen, setAdvancedOpen] = useState(false);
// The collector composer and this confirmation card are both reachable while
// a session is awaiting report writing. Selecting a structure in the former
// must be reflected here, but should not reset the card's mode, image switch,
// or any other in-progress edits.
useEffect(() => {
if (outlineOverride === undefined) return;
setOptions((current) => (
current.customOutline === outlineOverride
? current
: { ...current, customOutline: outlineOverride }
));
}, [outlineOverride]);
const modes = useMemo(
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
[availableModes],
@@ -302,6 +302,8 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
export default function RoundtablePlanningPage({
compactChrome = false,
/** 仅供独立业务页临时锁定深色视觉;不修改全局主题状态。 */
forceDarkAppearance = false,
/** 独立业务页锁定的公共业务链;不影响原多智能体会商入口。 */
lockedChainId = null,
/** 即使尚未配置业务链,也禁止用户改为推荐/手选链路。 */
@@ -345,6 +347,7 @@ export default function RoundtablePlanningPage({
taskArtifactSubmissionConfig,
}: {
compactChrome?: boolean;
forceDarkAppearance?: boolean;
lockedChainId?: string | null;
lockBusinessChain?: boolean;
storageTaskId?: string | null;
@@ -391,7 +394,9 @@ export default function RoundtablePlanningPage({
const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
const { watchJob } = useRoundtableJobNotifier();
const isDark = theme === "dark";
// 保留 ThemeContext 的主题切换能力;独立业务页可只覆盖自身的视觉模式,
// 不触碰全局 html 主题,后续移除该 prop 即可恢复随系统主题切换。
const isDark = forceDarkAppearance || theme === "dark";
const navigate = useNavigate();
const location = useLocation();
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
@@ -2205,16 +2210,24 @@ export default function RoundtablePlanningPage({
期间全屏覆盖,避免线程创建慢时页面像卡住。问答一旦开始或超时即撤掉。 */}
{showBootLoading && (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-white"
className={cn(
"absolute inset-0 z-50 flex items-center justify-center",
isDark ? "bg-[#061b3b]" : "bg-white",
)}
role="status"
aria-live="polite"
>
<div className="flex flex-col items-center gap-7 px-8">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-lg font-semibold tracking-wide text-slate-800">
<p className={cn(
"text-lg font-semibold tracking-wide",
isDark ? "text-[#e7f0ff]" : "text-slate-800",
)}>
正在准备多智能体会商
</p>
<p className="text-sm text-slate-400">正在初始化任务理解智能体,请稍候…</p>
<p className={cn("text-sm", isDark ? "text-[#8aa0c4]" : "text-slate-400")}>
正在初始化任务理解智能体,请稍候…
</p>
</div>
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 animate-bounce rounded-full bg-red-400 [animation-delay:-0.3s]" />
@@ -0,0 +1,238 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { LlmWikiSearchResult } from "./llmwiki";
const API = () => `${getBackendBaseURL()}/api/enterprise-research`;
export type EnterpriseResearchTaskStatus = "draft" | "collecting" | "ready" | "partial" | "failed";
export interface EnterpriseResearchTask {
id: string;
subject: string;
focus: string;
template_id: string;
knowledge_base_ids: string[];
queries: string[];
selected_source_ids: string[];
sources: LlmWikiSearchResult[];
plan_markdown?: string | null;
report_markdown?: string | null;
report_summary?: string | null;
report_model_name?: string | null;
active_report_job_id?: string | null;
status: EnterpriseResearchTaskStatus;
warning?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export type EnterpriseResearchReportJobStatus = "queued" | "running" | "completed" | "failed" | "cancelled";
export interface EnterpriseResearchReportJob {
id: string;
task_id: string;
status: EnterpriseResearchReportJobStatus;
phase: string;
model_name?: string | null;
action: "initial" | "rewrite" | "followup";
instruction?: string | null;
style?: string | null;
target_length?: number | null;
original_report?: string | null;
report_markdown: string;
report_summary?: string | null;
error_message?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export type EnterpriseResearchReportEvent =
| { type: "snapshot"; job: EnterpriseResearchReportJob }
| { type: "phase"; phase: string; status: EnterpriseResearchReportJobStatus }
| { type: "report_delta"; delta: string }
| { type: "reasoning_delta"; delta: string }
| { type: "completed" | "failed" | "cancelled"; job: EnterpriseResearchReportJob | null }
| { type: "heartbeat"; status: EnterpriseResearchReportJobStatus; phase: string };
export interface CreateEnterpriseResearchTaskInput {
subject: string;
focus: string;
template_id: string;
knowledge_base_ids: string[];
queries: string[];
}
async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) {
const payload = await response.json().catch(() => null as unknown);
const detail = payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail: unknown }).detail)
: `${context}失败(${response.status})`;
throw new Error(detail);
}
return response.json() as Promise<T>;
}
export async function listEnterpriseResearchTasks(limit = 20): Promise<EnterpriseResearchTask[]> {
const payload = await readJson<{ tasks?: EnterpriseResearchTask[] }>(
await apiFetch(`${API()}/tasks?limit=${encodeURIComponent(limit)}`),
"读取研究任务",
);
return payload.tasks ?? [];
}
export async function createEnterpriseResearchTask(input: CreateEnterpriseResearchTaskInput): Promise<EnterpriseResearchTask> {
return readJson<EnterpriseResearchTask>(
await apiFetch(`${API()}/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
"创建研究任务",
);
}
export async function collectEnterpriseResearchTask(taskId: string): Promise<EnterpriseResearchTask> {
return readJson<EnterpriseResearchTask>(
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/collect`, { method: "POST" }),
"收集研究证据",
);
}
export async function createEnterpriseResearchPlanDraft(taskId: string): Promise<EnterpriseResearchTask> {
return readJson<EnterpriseResearchTask>(
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/plan/draft`, { method: "POST" }),
"生成研究计划",
);
}
export async function createEnterpriseResearchAiPlan(
taskId: string,
input: { model_name?: string | null; instruction?: string } = {},
): Promise<EnterpriseResearchTask> {
return readJson<EnterpriseResearchTask>(
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/plan/ai`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
"AI 优化研究计划",
);
}
export async function updateEnterpriseResearchPlan(
taskId: string,
input: { plan_markdown: string; selected_source_ids: string[] },
): Promise<EnterpriseResearchTask> {
return readJson<EnterpriseResearchTask>(
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/plan`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
"保存研究计划",
);
}
export async function createEnterpriseResearchReportJob(
taskId: string,
input: {
model_name?: string | null;
action?: "initial" | "rewrite" | "followup";
instruction?: string;
style?: "formal" | "analytical" | "concise";
target_length?: number | null;
} = {},
): Promise<EnterpriseResearchReportJob> {
return readJson<EnterpriseResearchReportJob>(
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
"创建报告写作任务",
);
}
export async function cancelEnterpriseResearchReportJob(
taskId: string,
jobId: string,
): Promise<EnterpriseResearchReportJob> {
return readJson<EnterpriseResearchReportJob>(
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs/${encodeURIComponent(jobId)}/cancel`, {
method: "POST",
}),
"取消报告任务",
);
}
export async function restoreEnterpriseResearchReportVersion(
taskId: string,
jobId: string,
): Promise<EnterpriseResearchTask> {
return readJson<EnterpriseResearchTask>(
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs/${encodeURIComponent(jobId)}/restore`, {
method: "POST",
}),
"恢复报告版本",
);
}
export async function listEnterpriseResearchReportJobs(taskId: string): Promise<EnterpriseResearchReportJob[]> {
const payload = await readJson<{ jobs?: EnterpriseResearchReportJob[] }>(
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs`),
"读取报告写作任务",
);
return payload.jobs ?? [];
}
/** Parse the endpoint's standard SSE frames without introducing an EventSource auth gap. */
export async function streamEnterpriseResearchReportJob(
taskId: string,
jobId: string,
handlers: { onEvent: (event: EnterpriseResearchReportEvent) => void; signal?: AbortSignal },
): Promise<void> {
const response = await apiFetch(
`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs/${encodeURIComponent(jobId)}/stream`,
{ headers: { Accept: "text/event-stream" }, signal: handlers.signal },
);
if (!response.ok || !response.body) {
await readJson(response, "连接报告流");
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let pending = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
const frames = pending.split("\n\n");
pending = frames.pop() ?? "";
for (const frame of frames) {
const dataLine = frame.split("\n").find((line) => line.startsWith("data: "));
if (!dataLine) continue;
try {
handlers.onEvent(JSON.parse(dataLine.slice(6)) as EnterpriseResearchReportEvent);
} catch {
// One malformed frame must not discard an otherwise healthy stream.
}
}
}
} finally {
reader.releaseLock();
}
}
export async function deleteEnterpriseResearchTask(taskId: string): Promise<void> {
const response = await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" });
if (!response.ok) {
const payload = await response.json().catch(() => null as unknown);
const detail = payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail: unknown }).detail)
: `删除研究任务失败(${response.status})`;
throw new Error(detail);
}
}