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

+5 -3
View File
@@ -98,9 +98,11 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
deploy
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
deploy
.history
# Cloned third-party repos (local only)
@@ -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);
}
}
@@ -996,6 +996,8 @@ The embedded detail proxy hides WeKnora's outer `.main > .aside_box` application
**Embedded native knowledge-base switching:** The original WeKnora breadcrumb picker remains available inside the detail iframe. A signed 15-minute embed cookie carries only the current DeerFlow user's visible mappings (their own plus published bases); the proxied `/api/v1/knowledge-bases` response is filtered to that allowlist. Choosing another entry performs a full iframe navigation, rechecks the DeerFlow mapping and read/write permission, then reissues the cookie for the selected base. Never expose the upstream service-admin list directly or permit an arbitrary remote knowledge-base ID.
The independent enterprise-research workbench (`/api/enterprise-research/tasks`) is deliberately separate from `/api/deep-research`: it stores an owner-scoped task plus a bounded, deduplicated evidence snapshot in `enterprise_research_tasks`, then calls WeKnora through the existing DeerFlow LLMWiki mapping and visibility checks. Never expose remote WeKnora IDs or credentials to its browser API, and do not make this workbench mutate a knowledge base or an existing Deep Research session/report. After collection, its plan endpoints can save an editable Markdown outline and an explicitly selected source subset; validate every selected chunk ID against the saved snapshot. The AI-plan variant may call a configured DeerFlow model but must not retrieve new evidence. `enterprise_research_report_jobs` freezes that plan/source subset plus action/model/style/length and before-image before calling the shared `DeerFlowCompletionBackend`, so the writer cannot run a fresh uncontrolled retrieval or accidentally use later task edits. Actions are `initial`, `rewrite`, and report-only `followup`; only successful initial/rewrite jobs may replace the task report. Its SSE route sends the persisted draft first and then only live `report_delta`/`reasoning_delta` events; reasoning must never be appended to report Markdown. Active rows carry a nullable owner/task dedupe key with a database unique constraint; terminal transitions clear it so history remains unlimited while simultaneous workers cannot create two active jobs for one task. Startup recovery must requeue the exact previously-read running snapshot and then use an atomic queued→running claim. Cancellation, completion, and failure are conditional state transitions so cross-process races cannot turn cancelled work into a committed report. Restore only accepts a completed rewrite before-image, rejects active jobs, and writes a new reversible history row before changing the task. The persistence contract is `deerflow.persistence.enterprise_research`; large evidence/report payloads use `PortableLongText` because MySQL `TEXT` cannot safely hold them. Regression coverage: `tests/test_enterprise_research_tasks.py`.
### Embedded Client (`packages/harness/deerflow/client.py`)
`DeerFlowClient` provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes.
@@ -81,6 +81,7 @@ Per-thread isolated execution with virtual path translation:
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
- **Enterprise Research workbench**: `/api/enterprise-research/tasks` is an independent, user-scoped evidence workbench; it never enters or changes `/api/deep-research`. DeerFlow persists each task's topic, generated queries, selected DeerFlow knowledge-base mapping IDs, collection state, and a bounded/deduplicated WeKnora evidence snapshot. Collection reuses the LLMWiki BFF's mapping-based authorization, so browsers never send a remote WeKnora ID or credential and can retrieve only sources in their own or published knowledge bases. Once collection finishes, the task can persist an editable Markdown research plan and a validated subset of its snapshot sources; the optional AI-plan endpoint improves that outline with a selected DeerFlow model without retrieving new material. Persistent `report-jobs` support initial writing, full-report rewrite, and report-only follow-up. Each job freezes its plan, source subset, model/style/length controls and before-image, streams Markdown/reasoning through DeerFlow's standard model adapter, persists the latest draft for reconnect, and commits only a successful non-follow-up report back to the owning task. Startup recovery uses compare-and-set requeue/claim, while cancellation and completion are conditional transitions so another process cannot overwrite a cancelled job. Markdown/Word delivery, clickable evidence citations, before/after comparison and reversible rewrite versions are served from the same task history without modifying an existing Deep Research report.
- **Embedded WeKnora detail chrome**: the proxy hides WeKnora's application sidebar (`.main > .aside_box`) and expands the detail outlet to the full iframe width, while preserving the Wiki page's own index/navigation panel.
- **Embedded WeKnora knowledge-base switching**: the original breadcrumb picker works inside the iframe, but lists only the signed-in user's own and published knowledge bases. Every switch is reauthorized through DeerFlow and receives a fresh short-lived iframe session, so it never exposes the upstream service-admin knowledge-base list.
- **Prefixed WeKnora deployment**: the frontend may be published under a separate prefix such as `/magentweb`, while embedded WeKnora detail traffic is served by the Gateway under `/deerflow`. The Gateway rewrites HTML root assets, WeKnora API URLs, redirects, and Vite dynamic dependency maps so page assets stay under `/deerflow`; the prefixed proxy routes must be registered before the unprefixed fallback. The signed embed cookie covers `/deerflow`, and auth/CSRF recognition includes all supported static directories.
@@ -153,6 +154,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
| `GET/POST/PUT/DELETE /api/enterprise-research/tasks*` | Independent enterprise-research evidence tasks. The authenticated DeerFlow account owns the task and all saved evidence snapshots; collection calls the configured WeKnora service only after resolving selected mapping IDs through normal visibility and permission checks. `POST /{id}/plan/draft` creates an editable deterministic plan, `POST /{id}/plan/ai` optionally improves it with the configured model, and `PUT /{id}/plan` persists its Markdown plus a source subset revalidated against that task's snapshot. `POST /{id}/report-jobs` freezes those inputs and accepts `initial` / `rewrite` / `followup` plus model, style and length controls; the job list is durable history, its SSE route replays the latest persisted draft before live Markdown/reasoning deltas, `/cancel` safely stops an active job, and `/restore` creates another reversible version while restoring a completed rewrite's before-image. It deliberately cannot change existing Deep Research reports or knowledge bases. |
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
@@ -41,6 +41,7 @@ from app.gateway.routers import (
feedback,
dashboard_sessions,
deep_research,
enterprise_research,
html_page_favorites,
intent,
knowledge,
@@ -1560,6 +1561,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Deep Research (深度研究) API at /api/deep-research
app.include_router(deep_research.router)
# Independent enterprise-research evidence workbench at /api/enterprise-research.
app.include_router(enterprise_research.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
@@ -305,6 +305,31 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.report_structure_store = make_report_structure_store(sf)
from deerflow.persistence.enterprise_research import (
make_enterprise_research_report_job_store,
make_enterprise_research_task_store,
)
app.state.enterprise_research_task_store = make_enterprise_research_task_store(sf)
app.state.enterprise_research_report_job_store = make_enterprise_research_report_job_store(sf)
# 企业研究报告写作是独立的、冻结计划/证据快照的后台任务;它不复用或改变
# 已有 deep-research 的 Job / dispatcher。
from app.gateway.enterprise_research_report_executor import EnterpriseResearchReportExecutor
app.state.enterprise_research_report_executor = EnterpriseResearchReportExecutor(
app.state.enterprise_research_task_store,
app.state.enterprise_research_report_job_store,
)
async def _recover_enterprise_research_reports() -> None:
try:
await app.state.enterprise_research_report_executor.recover_pending()
except Exception: # noqa: BLE001
logger.warning("[enterprise-research] startup report recovery failed", exc_info=True)
asyncio.create_task(_recover_enterprise_research_reports())
from deerflow.integrations.weknora.runtime import register_llmwiki_store
from deerflow.persistence.llmwiki import make_llmwiki_store
@@ -0,0 +1,347 @@
"""Independent, snapshot-based report writer for the enterprise workbench.
This deliberately does *not* use the existing deep-research job executor. It
only consumes a task's persisted plan and selected WeKnora evidence snapshot,
then streams Markdown through the shared DeerFlow model adapter.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections import defaultdict
from collections.abc import AsyncIterator, Awaitable, Callable
from difflib import SequenceMatcher
from typing import Any
from deerflow.agents.deep_research.adapters.llm import DeerFlowCompletionBackend
from deerflow.agents.deep_research.config import DeepResearchConfig
from deerflow.persistence.enterprise_research import EnterpriseResearchReportJobStore, EnterpriseResearchTaskStore
logger = logging.getLogger(__name__)
_TERMINAL = {"completed", "failed", "cancelled"}
class EnterpriseResearchLiveHub:
"""Small in-process fan-out for live deltas; completed output stays in SQL."""
def __init__(self) -> None:
self._queues: dict[str, set[asyncio.Queue[dict[str, Any]]]] = defaultdict(set)
self._lock = asyncio.Lock()
async def subscribe(
self, job_id: str
) -> tuple[asyncio.Queue[dict[str, Any]], Callable[[], Awaitable[None]]]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
async with self._lock:
self._queues[job_id].add(queue)
async def unsubscribe() -> None:
async with self._lock:
queues = self._queues.get(job_id)
if queues is not None:
queues.discard(queue)
if not queues:
self._queues.pop(job_id, None)
return queue, unsubscribe
async def publish(self, job_id: str, event: dict[str, Any]) -> None:
async with self._lock:
queues = list(self._queues.get(job_id, ()))
for queue in queues:
try:
queue.put_nowait(event)
except asyncio.QueueFull:
# A slow browser can reconnect and receive the persisted draft;
# never let it stall model streaming for every other client.
try:
queue.get_nowait()
queue.put_nowait(event)
except (asyncio.QueueEmpty, asyncio.QueueFull):
pass
class EnterpriseResearchReportExecutor:
def __init__(self, task_store: EnterpriseResearchTaskStore, job_store: EnterpriseResearchReportJobStore) -> None:
self._tasks = task_store
self._jobs = job_store
self._hub = EnterpriseResearchLiveHub()
self._running: dict[str, asyncio.Task[None]] = {}
self._guard = asyncio.Lock()
async def ensure_started(self, job_id: str, user_id: str) -> None:
"""Start exactly one in-process runner for an owner-scoped persisted job."""
async with self._guard:
existing = self._running.get(job_id)
if existing is not None and not existing.done():
return
task = asyncio.create_task(self._run(job_id, user_id), name=f"enterprise-research-report:{job_id}")
self._running[job_id] = task
task.add_done_callback(lambda _: self._running.pop(job_id, None))
async def recover_pending(self) -> None:
"""Requeue process-orphaned jobs and let the normal atomic claim decide the runner."""
for job in await self._jobs.list_recoverable(limit=100):
if job["status"] == "running":
requeued = await self._jobs.requeue_job(
job["id"], job["user_id"], expected_updated_at=job.get("updated_at")
)
if not requeued:
# Another process already recovered or resumed this exact
# snapshot; its normal claim now owns the execution.
continue
await self.ensure_started(job["id"], job["user_id"])
async def cancel(self, job_id: str, user_id: str) -> dict[str, Any] | None:
job = await self._jobs.get_job(job_id, user_id)
if job is None or job["status"] in _TERMINAL:
return job
running = self._running.get(job_id)
if running is not None and not running.done():
running.cancel()
try:
await running
except asyncio.CancelledError:
pass
return await self._jobs.get_job(job_id, user_id)
cancelled = await self._jobs.transition_job(
job_id, user_id, expected_statuses={"queued", "running"},
data={"status": "cancelled", "phase": "cancelled"},
)
if cancelled is not None:
await self._tasks.update_task(job["task_id"], user_id, {"active_report_job_id": None})
await self._hub.publish(job_id, {"type": "cancelled", "job": cancelled})
return cancelled
return await self._jobs.get_job(job_id, user_id)
async def stream_events(self, job_id: str, user_id: str) -> AsyncIterator[dict[str, Any]]:
"""Replay the latest durable draft, then attach to live token events."""
queue, unsubscribe = await self._hub.subscribe(job_id)
try:
current = await self._jobs.get_job(job_id, user_id)
if current is None:
return
yield {"type": "snapshot", "job": current}
if current["status"] in _TERMINAL:
return
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=15)
except TimeoutError:
current = await self._jobs.get_job(job_id, user_id)
if current is None:
return
yield {"type": "heartbeat", "status": current["status"], "phase": current["phase"]}
if current["status"] in _TERMINAL:
return
continue
yield event
if event.get("type") in {"completed", "failed", "cancelled"}:
return
finally:
await unsubscribe()
async def _set_phase(self, job_id: str, user_id: str, phase: str, **fields: Any) -> dict[str, Any] | None:
row = await self._jobs.transition_job(
job_id, user_id, expected_statuses={"running"}, data={"phase": phase, **fields}
)
if row is not None:
await self._hub.publish(job_id, {"type": "phase", "phase": phase, "status": row["status"]})
return row
@staticmethod
def _messages(job: dict[str, Any]) -> list[dict[str, str]]:
sources = job.get("sources") or []
evidence: list[str] = []
for index, source in enumerate(sources[:24], start=1):
if not isinstance(source, dict):
continue
chunk_id = str(source.get("chunk_id") or f"source-{index}")
title = str(source.get("title") or source.get("filename") or "未命名资料")
content = str(source.get("content") or "").strip()[:2_500]
if content:
evidence.append(f"[资料 {index} | id={chunk_id} | {title}]\n{content}")
action = str(job.get("action") or "initial")
style = str(job.get("style") or "formal")
instruction = str(job.get("instruction") or "").strip()
target_length = int(job.get("target_length") or 0)
source_context = f"# 已确认资料(共 {len(evidence)} 条)\n" + "\n\n".join(evidence)
common_system = (
"你是一名严谨的企业研究报告撰写人。只可使用用户确认的研究计划、现有报告和资料快照;"
"不可联网、不可补充未在资料中出现的具体事实或数据。不要输出 <think> 标签。每个事实性判断"
"尽量以 [资料:id] 标识其对应资料;证据不足时明确写为待验证判断。"
)
if action == "followup":
return [
{"role": "system", "content": common_system + "请直接回答报告追问,不要重写整篇报告。"},
{
"role": "user",
"content": (
f"# 当前报告\n{job.get('original_report') or ''}\n\n{source_context}\n\n"
f"# 用户问题\n{instruction}"
),
},
]
if action == "rewrite":
length_hint = f"目标篇幅约 {target_length} 字。" if target_length else "篇幅与原文大体相当。"
return [
{
"role": "system",
"content": common_system + "输出仅为改写后的完整 Markdown 报告,不要解释改写过程。",
},
{
"role": "user",
"content": (
f"请全文改写下列企业研究报告。写作风格:{style};{length_hint}\n"
f"改写要求:{instruction or '优化结构、逻辑与表达,并保留事实和引用。'}\n\n"
f"# 研究计划\n{job['plan_snapshot']}\n\n"
f"# 原报告\n{job.get('original_report') or ''}\n\n{source_context}"
),
},
]
length_hint = f"报告目标篇幅约 {target_length} 字。" if target_length else ""
return [
{
"role": "system",
"content": common_system + "输出仅为完整 Markdown 报告,不要解释写作过程。",
},
{
"role": "user",
"content": (
"请按下列研究计划撰写企业研究报告。保留计划中有意义的章节层级,"
f"内容应连贯、可核验,结论与建议要说明证据边界。写作风格:{style}。{length_hint}\n\n"
f"# 研究计划\n{job['plan_snapshot']}\n\n"
+ source_context
),
},
]
@staticmethod
def _summary(
report: str,
source_count: int,
action: str,
*,
original_report: str = "",
instruction: str = "",
) -> str:
headings = sum(1 for line in report.splitlines() if line.lstrip().startswith("#"))
if action == "followup":
return f"已基于当前报告和 {source_count} 条已确认资料完成追问回答,共 {len(report)} 字。"
if action == "rewrite":
before_blocks = [block.strip() for block in original_report.split("\n\n") if block.strip()]
after_blocks = [block.strip() for block in report.split("\n\n") if block.strip()]
matcher = SequenceMatcher(a=before_blocks, b=after_blocks, autojunk=False)
changed_blocks = sum(
max(before_end - before_start, after_end - after_start)
for tag, before_start, before_end, after_start, after_end in matcher.get_opcodes()
if tag != "equal"
)
before_headings = sum(1 for line in original_report.splitlines() if line.lstrip().startswith("#"))
request = instruction.strip()[:120] or "优化结构、逻辑与表达,并保留事实和引用"
return (
f"已依据 {source_count} 条已确认资料完成全文改写:原文 {len(original_report)} 字,"
f"改写后 {len(report)} 字,约调整 {changed_blocks} 个内容块,标题节点 "
f"{before_headings}→{headings}。改写要求:{request}。"
)
return f"已依据 {source_count} 条已确认资料完成报告,共 {len(report)} 字,包含 {headings} 个标题层级节点。"
async def _run(self, job_id: str, user_id: str) -> None:
job = await self._jobs.get_job(job_id, user_id)
if job is None or job["status"] in _TERMINAL:
return
if not await self._jobs.claim_job(job_id, user_id):
return
if await self._jobs.transition_job(
job_id, user_id, expected_statuses={"running"},
data={"phase": "understanding", "error_message": None, "report_markdown": ""},
) is None:
return
await self._hub.publish(job_id, {"type": "phase", "phase": "understanding", "status": "running"})
report_parts: list[str] = []
last_persist = 0.0
try:
await asyncio.sleep(0) # give the client an observable understanding phase
if await self._set_phase(job_id, user_id, "writing") is None:
return
config = DeepResearchConfig(
mode="basic", language="zh-CN", smart_model=job.get("model_name") or None,
custom_outline=str(job["plan_snapshot"])[:4_000], max_context_words=24_000,
).clamp()
backend = DeerFlowCompletionBackend(config)
async def on_delta(delta: str) -> None:
nonlocal last_persist
report_parts.append(delta)
await self._hub.publish(job_id, {"type": "report_delta", "delta": delta})
now = time.monotonic()
if now - last_persist >= 1.25:
last_persist = now
persisted = await self._jobs.transition_job(
job_id, user_id, expected_statuses={"running"},
data={"report_markdown": "".join(report_parts)},
)
if persisted is None:
raise asyncio.CancelledError
async def on_reasoning(delta: str) -> None:
# Thought traces are deliberately separate from Markdown. The
# client may choose to show them in a collapsed activity item.
await self._hub.publish(job_id, {"type": "reasoning_delta", "delta": delta})
result = await backend.stream_complete(
model_role="smart", messages=self._messages(job), operation="enterprise_research_write_report",
on_delta=on_delta, on_reasoning=on_reasoning,
)
report = result.text.strip()
if not report:
raise RuntimeError("模型没有返回可写入的报告内容")
if await self._set_phase(job_id, user_id, "reviewing") is None:
return
action = str(job.get("action") or "initial")
summary = self._summary(
report,
len(job.get("sources") or []),
action,
original_report=str(job.get("original_report") or ""),
instruction=str(job.get("instruction") or ""),
)
completed = await self._jobs.transition_job(
job_id, user_id, expected_statuses={"running"},
data={"status": "completed", "phase": "completed", "report_markdown": report, "report_summary": summary, "model_name": result.model},
)
if completed is None:
# A different process accepted cancellation while this worker
# was between its last token and commit. Never overwrite it.
return
task_update: dict[str, Any] = {"active_report_job_id": None}
if action != "followup":
task_update.update(
{"report_markdown": report, "report_summary": summary, "report_model_name": result.model}
)
await self._tasks.update_task(job["task_id"], user_id, task_update)
await self._hub.publish(job_id, {"type": "completed", "job": completed})
except asyncio.CancelledError:
cancelled = await self._jobs.transition_job(
job_id, user_id, expected_statuses={"queued", "running"},
data={"status": "cancelled", "phase": "cancelled"},
)
if cancelled is not None:
await self._tasks.update_task(job["task_id"], user_id, {"active_report_job_id": None})
await self._hub.publish(job_id, {"type": "cancelled", "job": cancelled})
raise
except Exception as exc: # noqa: BLE001 - turn model/provider failures into a visible job state
logger.exception("enterprise research report job %s failed", job_id)
message = str(exc)[:2_000] or "报告生成失败,请稍后重试。"
failed = await self._jobs.transition_job(
job_id, user_id, expected_statuses={"running"},
data={"status": "failed", "phase": "failed", "report_markdown": "".join(report_parts), "error_message": message},
)
if failed is not None:
await self._tasks.update_task(job["task_id"], user_id, {"active_report_job_id": None})
await self._hub.publish(job_id, {"type": "failed", "job": failed})
@@ -0,0 +1,710 @@
"""Independent enterprise-research workbench API.
This router intentionally does not alter the established ``/api/deep-research``
workflow. It stores a small owner-scoped research task and calls WeKnora through
the same DeerFlow-owned authorization mapping used by the knowledge-base page.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Literal
from urllib.parse import urlencode
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from starlette.responses import StreamingResponse
from app.gateway.deps import get_optional_user_from_request
from deerflow.agents.deep_research.adapters.llm import DeerFlowCompletionBackend
from deerflow.agents.deep_research.config import DeepResearchConfig
from deerflow.integrations.weknora.client import WeKnoraError
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
from deerflow.persistence.enterprise_research import EnterpriseResearchReportJobStore, EnterpriseResearchTaskStore
from deerflow.persistence.llmwiki import LlmWikiStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/enterprise-research", tags=["enterprise-research"])
TaskStatus = Literal["draft", "collecting", "ready", "partial", "failed"]
class EnterpriseResearchEvidence(BaseModel):
chunk_id: str = Field(default="", max_length=256)
content: str = Field(default="", max_length=4_000)
knowledge_id: str = Field(default="", max_length=256)
knowledge_base_id: str = Field(default="", max_length=256)
knowledge_base_name: str = Field(default="", max_length=512)
title: str = Field(default="", max_length=1_024)
filename: str = Field(default="", max_length=1_024)
score: float = 0.0
chunk_index: int = 0
url: str = Field(default="", max_length=4_096)
class EnterpriseResearchTaskResponse(BaseModel):
id: str
subject: str
focus: str
template_id: str
knowledge_base_ids: list[str]
queries: list[str]
selected_source_ids: list[str] = Field(default_factory=list)
sources: list[EnterpriseResearchEvidence]
plan_markdown: str | None = None
report_markdown: str | None = None
report_summary: str | None = None
report_model_name: str | None = None
active_report_job_id: str | None = None
status: TaskStatus
warning: str | None = None
created_at: str | None = None
updated_at: str | None = None
class EnterpriseResearchTaskListResponse(BaseModel):
tasks: list[EnterpriseResearchTaskResponse]
class EnterpriseResearchTaskCreate(BaseModel):
subject: str = Field(..., min_length=1, max_length=180)
focus: str = Field(default="", max_length=800)
template_id: str = Field(default="topic", min_length=1, max_length=64)
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
queries: list[str] = Field(..., min_length=1, max_length=8)
class EnterpriseResearchPlanUpdate(BaseModel):
plan_markdown: str = Field(..., min_length=1, max_length=16_000)
selected_source_ids: list[str] = Field(default_factory=list, max_length=48)
class EnterpriseResearchAiPlanCreate(BaseModel):
model_name: str | None = Field(default=None, max_length=256)
instruction: str = Field(default="", max_length=2_000)
class EnterpriseResearchReportJobCreate(BaseModel):
model_name: str | None = Field(default=None, max_length=256)
action: Literal["initial", "rewrite", "followup"] = "initial"
instruction: str = Field(default="", max_length=4_000)
style: Literal["formal", "analytical", "concise"] = "formal"
target_length: int | None = Field(default=None, ge=800, le=30_000)
class EnterpriseResearchReportJobResponse(BaseModel):
id: str
task_id: str
status: Literal["queued", "running", "completed", "failed", "cancelled"]
phase: str
model_name: str | None = None
action: Literal["initial", "rewrite", "followup"] = "initial"
instruction: str | None = None
style: str | None = None
target_length: int | None = None
original_report: str | None = None
report_markdown: str = ""
report_summary: str | None = None
error_message: str | None = None
created_at: str | None = None
updated_at: str | None = None
class EnterpriseResearchReportJobListResponse(BaseModel):
jobs: list[EnterpriseResearchReportJobResponse]
def _task_store(request: Request) -> EnterpriseResearchTaskStore:
store = getattr(request.app.state, "enterprise_research_task_store", None)
if store is None:
raise HTTPException(status_code=503, detail="企业深度研究任务存储暂不可用")
return store
def _report_job_store(request: Request) -> EnterpriseResearchReportJobStore:
store = getattr(request.app.state, "enterprise_research_report_job_store", None)
if store is None:
raise HTTPException(status_code=503, detail="企业研究报告任务存储暂不可用")
return store
def _report_executor(request: Request):
executor = getattr(request.app.state, "enterprise_research_report_executor", None)
if executor is None:
raise HTTPException(status_code=503, detail="企业研究报告执行器暂不可用")
return executor
def _report_start_lock(request: Request) -> asyncio.Lock:
lock = getattr(request.app.state, "enterprise_research_report_start_lock", None)
if lock is None:
lock = asyncio.Lock()
request.app.state.enterprise_research_report_start_lock = lock
return lock
def _llmwiki_store(request: Request) -> LlmWikiStore:
store = getattr(request.app.state, "llmwiki_store", None)
if store is None:
raise HTTPException(status_code=503, detail="知识库权限存储暂不可用")
return store
async def _actor(request: Request) -> tuple[str, bool]:
user = await get_optional_user_from_request(request)
if user is None:
return "default", True
return str(user.id), getattr(user, "system_role", None) == "admin"
def _response(row: dict[str, Any]) -> EnterpriseResearchTaskResponse:
return EnterpriseResearchTaskResponse(
id=str(row["id"]),
subject=str(row.get("subject") or ""),
focus=str(row.get("focus") or ""),
template_id=str(row.get("template_id") or "topic"),
knowledge_base_ids=[str(item) for item in row.get("knowledge_base_ids") or []],
queries=[str(item) for item in row.get("queries") or []],
selected_source_ids=[str(item) for item in row.get("selected_source_ids") or []],
sources=[EnterpriseResearchEvidence.model_validate(item) for item in row.get("sources") or [] if isinstance(item, dict)],
plan_markdown=str(row["plan_markdown"]) if row.get("plan_markdown") else None,
report_markdown=str(row["report_markdown"]) if row.get("report_markdown") else None,
report_summary=str(row["report_summary"]) if row.get("report_summary") else None,
report_model_name=str(row["report_model_name"]) if row.get("report_model_name") else None,
active_report_job_id=str(row["active_report_job_id"]) if row.get("active_report_job_id") else None,
status=row.get("status") if row.get("status") in {"draft", "collecting", "ready", "partial", "failed"} else "failed",
warning=str(row["warning"]) if row.get("warning") else None,
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
)
def _report_job_response(row: dict[str, Any]) -> EnterpriseResearchReportJobResponse:
job_status = str(row.get("status") or "failed")
if job_status not in {"queued", "running", "completed", "failed", "cancelled"}:
job_status = "failed"
action = str(row.get("action") or "initial")
if action not in {"initial", "rewrite", "followup"}:
action = "initial"
return EnterpriseResearchReportJobResponse(
id=str(row["id"]), task_id=str(row["task_id"]), status=job_status, phase=str(row.get("phase") or job_status),
model_name=str(row["model_name"]) if row.get("model_name") else None,
action=action,
instruction=str(row["instruction"]) if row.get("instruction") else None,
style=str(row["style"]) if row.get("style") else None,
target_length=int(row["target_length"]) if row.get("target_length") else None,
original_report=str(row["original_report"]) if row.get("original_report") else None,
report_markdown=str(row.get("report_markdown") or ""),
report_summary=str(row["report_summary"]) if row.get("report_summary") else None,
error_message=str(row["error_message"]) if row.get("error_message") else None,
created_at=row.get("created_at"), updated_at=row.get("updated_at"),
)
def _normalise_ids(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
item = str(value).strip()
if item and item not in seen:
seen.add(item)
result.append(item)
return result[:100]
def _normalise_queries(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
item = str(value).strip()[:512]
if item and item not in seen:
seen.add(item)
result.append(item)
return result[:8]
def _default_plan(task: dict[str, Any], selected_source_ids: list[str]) -> str:
"""Build a transparent editable plan; report prose is a later, separate job."""
subject = str(task.get("subject") or "研究主题")
focus = str(task.get("focus") or "")
template_id = str(task.get("template_id") or "topic")
if template_id == "event":
chapters = [
"一、事件边界与时间线",
"二、相关主体与立场",
"三、影响传导与风险研判",
"四、后续观察与应对建议",
]
elif template_id == "policy":
chapters = [
"一、政策与行业背景",
"二、现状、数据与变化趋势",
"三、传导机制与关键影响",
"四、风险、机会与行动建议",
]
else:
chapters = [
"一、研究对象与事实背景",
"二、关键问题、观点与分歧",
"三、影响、风险与机会",
"四、结论、待验证问题与建议",
]
sources_by_id = {str(item.get("chunk_id") or ""): item for item in task.get("sources") or [] if isinstance(item, dict)}
selected_titles = [
str(sources_by_id[item].get("title") or sources_by_id[item].get("filename") or "未命名资料")
for item in selected_source_ids
if item in sources_by_id
]
source_lines = "\n".join(f"- {title}" for title in selected_titles[:12]) or "- 暂无已选资料"
scope = focus or "围绕主题的事实、影响与可验证判断"
return (
f"# {subject}研究计划\n\n"
"## 研究目标\n"
f"围绕“{subject}”形成可追溯、可核验的研究结论;重点范围:{scope}。\n\n"
"## 核心问题\n"
"1. 当前已知事实、背景和时间范围是什么?\n"
f"2. 与“{subject}”相关的主要主体、观点或变化是什么?\n"
"3. 证据能够支持哪些影响、风险、机会或待验证判断?\n\n"
"## 报告结构\n"
+ "\n".join(f"{chapter}\n- 说明本章需回答的核心问题,并在正文中标记资料依据。" for chapter in chapters)
+ "\n\n## 已选证据\n"
+ source_lines
+ "\n\n## 写作约束\n"
"- 仅使用已选资料作为事实依据;不补充未经验证的外部事实。\n"
"- 对资料不足或观点相互矛盾之处明确标记,避免将推测写成结论。\n"
"- 生成报告前可继续编辑本计划与已选证据。\n"
)
def _validate_selected_source_ids(task: dict[str, Any], values: list[str]) -> list[str]:
available_ids = {str(item.get("chunk_id") or "") for item in task.get("sources") or [] if isinstance(item, dict)}
selected_ids = _normalise_ids(values)[:48]
if any(item not in available_ids for item in selected_ids):
raise HTTPException(status_code=422, detail="选中的资料已变化,请刷新后重新选择")
return selected_ids
async def _visible_knowledge_bases(request: Request, task: dict[str, Any], user_id: str, is_admin: bool) -> list[dict[str, Any]]:
store = _llmwiki_store(request)
selected_ids = _normalise_ids(task.get("knowledge_base_ids") or [])
if not selected_ids:
return await store.list_visible(user_id, scope="all", is_admin=False)
rows: list[dict[str, Any]] = []
for mapping_id in selected_ids:
row = await store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
if row is None:
# Never reveal whether this belongs to a different account.
raise HTTPException(status_code=404, detail="知识库不存在或无访问权限")
rows.append(row)
return rows
def _client(request: Request):
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None))
if not runtime.weknora_enabled:
raise HTTPException(status_code=409, detail="WeKnora 知识服务尚未启用")
try:
return build_weknora_client(runtime)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from None
def _evidence_view(result: dict[str, Any], mappings: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
remote_base_id = str(result.get("knowledge_base_id") or "")
mapping = mappings.get(remote_base_id)
if mapping is None:
return None
params = urlencode({"knowledge_base_id": mapping["id"], "chunk_id": result.get("chunk_id") or ""})
return {
"chunk_id": str(result.get("chunk_id") or ""),
"content": str(result.get("content") or "")[:4_000],
"knowledge_id": str(result.get("knowledge_id") or ""),
"knowledge_base_id": str(mapping["id"]),
"knowledge_base_name": str(mapping.get("name") or ""),
"title": str(result.get("title") or ""),
"filename": str(result.get("filename") or ""),
"score": float(result.get("score") or 0),
"chunk_index": int(result.get("chunk_index") or 0),
"url": f"/weknora-source-preview?{params}",
}
def _dedupe_evidence(results: list[dict[str, Any]], mappings: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[str] = set()
evidence: list[dict[str, Any]] = []
for result in results:
item = _evidence_view(result, mappings)
if item is None:
continue
key = item["chunk_id"] or f"{item['knowledge_id']}:{item['chunk_index']}:{item['title']}"
if key in seen:
continue
seen.add(key)
evidence.append(item)
if len(evidence) >= 48:
break
return evidence
@router.get("/tasks", response_model=EnterpriseResearchTaskListResponse)
async def list_tasks(request: Request, limit: int = Query(default=20, ge=1, le=50)) -> EnterpriseResearchTaskListResponse:
user_id, _ = await _actor(request)
rows = await _task_store(request).list_tasks(user_id, limit=limit)
return EnterpriseResearchTaskListResponse(tasks=[_response(row) for row in rows])
@router.post("/tasks", response_model=EnterpriseResearchTaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(request: Request, body: EnterpriseResearchTaskCreate) -> EnterpriseResearchTaskResponse:
user_id, _ = await _actor(request)
queries = _normalise_queries(body.queries)
if not queries:
raise HTTPException(status_code=422, detail="至少需要一个有效检索词")
row = await _task_store(request).create_task(
{
"id": f"ert_{uuid4().hex}",
"user_id": user_id,
"subject": body.subject.strip(),
"focus": body.focus.strip(),
"template_id": body.template_id.strip(),
"knowledge_base_ids": _normalise_ids(body.knowledge_base_ids),
"queries": queries,
"status": "draft",
}
)
return _response(row)
@router.post("/tasks/{task_id}/collect", response_model=EnterpriseResearchTaskResponse)
async def collect_task(request: Request, task_id: str) -> EnterpriseResearchTaskResponse:
"""Collect a bounded, user-authorized WeKnora evidence snapshot for a task."""
user_id, is_admin = await _actor(request)
task_store = _task_store(request)
task = await task_store.get_task(task_id, user_id)
if task is None:
raise HTTPException(status_code=404, detail="研究任务不存在")
task = await task_store.update_task(task_id, user_id, {"status": "collecting", "warning": None})
assert task is not None
try:
bases = await _visible_knowledge_bases(request, task, user_id, is_admin)
if not bases:
row = await task_store.update_task(task_id, user_id, {"status": "failed", "warning": "当前账号没有可用知识库。"})
return _response(row or task)
client = _client(request)
remote_ids = [str(row["weknora_id"]) for row in bases]
outcomes = await asyncio.gather(
*(client.search(query, remote_ids) for query in task["queries"]),
return_exceptions=True,
)
raw_results: list[dict[str, Any]] = []
failed_queries = 0
for outcome in outcomes:
if isinstance(outcome, Exception):
failed_queries += 1
logger.warning("Enterprise research query failed: %s", outcome)
else:
raw_results.extend(outcome)
sources = _dedupe_evidence(raw_results, {str(row["weknora_id"]): row for row in bases})
if not sources:
task_status: TaskStatus = "failed"
warning = "未检索到可用证据,请调整研究主题或检查知识库解析状态。"
elif failed_queries:
task_status = "partial"
warning = f"{failed_queries} 个检索方向暂时不可用,已保留其余结果。"
else:
task_status = "ready"
warning = None
row = await task_store.update_task(task_id, user_id, {"status": task_status, "sources": sources, "warning": warning})
return _response(row or task)
except HTTPException:
raise
except WeKnoraError as exc:
await task_store.update_task(task_id, user_id, {"status": "failed", "warning": str(exc)})
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from None
except Exception:
logger.exception("Enterprise research collection failed for %s", task_id)
await task_store.update_task(task_id, user_id, {"status": "failed", "warning": "证据收集出现异常,请稍后重试。"})
raise HTTPException(status_code=502, detail="证据收集失败,请稍后重试") from None
@router.post("/tasks/{task_id}/plan/draft", response_model=EnterpriseResearchTaskResponse)
async def create_plan_draft(request: Request, task_id: str) -> EnterpriseResearchTaskResponse:
"""Create a deterministic, editable research-plan draft from persisted evidence."""
user_id, _ = await _actor(request)
task_store = _task_store(request)
task = await task_store.get_task(task_id, user_id)
if task is None:
raise HTTPException(status_code=404, detail="研究任务不存在")
if not task.get("sources"):
raise HTTPException(status_code=409, detail="请先完成证据收集,再生成研究计划")
selected_source_ids = _validate_selected_source_ids(task, [
str(item.get("chunk_id") or "") for item in task["sources"][:24] if isinstance(item, dict)
])
row = await task_store.update_task(
task_id,
user_id,
{
"selected_source_ids": selected_source_ids,
"plan_markdown": _default_plan(task, selected_source_ids),
},
)
return _response(row or task)
@router.put("/tasks/{task_id}/plan", response_model=EnterpriseResearchTaskResponse)
async def update_plan(request: Request, task_id: str, body: EnterpriseResearchPlanUpdate) -> EnterpriseResearchTaskResponse:
"""Persist the user's selected evidence and editable plan before report generation."""
user_id, _ = await _actor(request)
task_store = _task_store(request)
task = await task_store.get_task(task_id, user_id)
if task is None:
raise HTTPException(status_code=404, detail="研究任务不存在")
if not task.get("sources"):
raise HTTPException(status_code=409, detail="当前任务没有可用于编排的证据")
selected_source_ids = _validate_selected_source_ids(task, body.selected_source_ids)
plan_markdown = body.plan_markdown.strip()
if not plan_markdown:
raise HTTPException(status_code=422, detail="研究计划不能为空")
row = await task_store.update_task(
task_id,
user_id,
{"selected_source_ids": selected_source_ids, "plan_markdown": plan_markdown},
)
return _response(row or task)
@router.post("/tasks/{task_id}/plan/ai", response_model=EnterpriseResearchTaskResponse)
async def create_ai_plan(
request: Request, task_id: str, body: EnterpriseResearchAiPlanCreate
) -> EnterpriseResearchTaskResponse:
"""Use the configured model to improve a plan, without collecting new evidence."""
user_id, _ = await _actor(request)
task_store = _task_store(request)
task = await task_store.get_task(task_id, user_id)
if task is None:
raise HTTPException(status_code=404, detail="研究任务不存在")
if not task.get("sources"):
raise HTTPException(status_code=409, detail="请先完成证据收集")
selected_ids = list(task.get("selected_source_ids") or [])
if not selected_ids:
selected_ids = [
str(item.get("chunk_id") or "")
for item in task["sources"][:24]
if isinstance(item, dict) and item.get("chunk_id")
]
selected_ids = _validate_selected_source_ids(task, selected_ids)
selected = {
str(item.get("chunk_id") or ""): item
for item in task.get("sources") or []
if isinstance(item, dict)
}
evidence_titles = "\n".join(
f"- {selected[item].get('title') or selected[item].get('filename') or item}"
for item in selected_ids
if item in selected
)
current = str(task.get("plan_markdown") or _default_plan(task, selected_ids))
backend = DeerFlowCompletionBackend(
DeepResearchConfig(mode="basic", language="zh-CN", smart_model=(body.model_name or "").strip() or None).clamp()
)
try:
result = await backend.stream_complete(
model_role="smart",
operation="enterprise_research_plan",
messages=[
{
"role": "system",
"content": (
"你是企业研究规划师。仅优化研究目标、核心问题、章节结构和证据使用计划;"
"不要撰写报告正文,不要联网,不要虚构资料。仅输出 Markdown 研究计划。"
),
},
{
"role": "user",
"content": (
f"研究主题:{task['subject']}\n重点范围:{task.get('focus') or '未限定'}\n"
f"用户补充要求:{body.instruction or '提高结构完整性和章节关联性'}\n\n"
f"当前计划:\n{current}\n\n已选资料标题:\n{evidence_titles}"
),
},
],
)
except Exception as exc:
logger.exception("enterprise research AI plan generation failed task=%s", task_id)
raise HTTPException(status_code=502, detail="研究计划生成失败,请检查模型配置后重试") from exc
plan = result.text.strip()
if plan.startswith("```"):
lines = plan.splitlines()[1:]
if lines and lines[-1].strip().startswith("```"):
lines.pop()
plan = "\n".join(lines).strip()
if not plan:
raise HTTPException(status_code=502, detail="模型没有返回可用研究计划")
row = await task_store.update_task(
task_id, user_id, {"plan_markdown": plan[:16_000], "selected_source_ids": selected_ids}
)
return _response(row or task)
@router.get("/tasks/{task_id}/report-jobs", response_model=EnterpriseResearchReportJobListResponse)
async def list_report_jobs(
request: Request, task_id: str, limit: int = Query(default=10, ge=1, le=20)
) -> EnterpriseResearchReportJobListResponse:
user_id, _ = await _actor(request)
if await _task_store(request).get_task(task_id, user_id) is None:
raise HTTPException(status_code=404, detail="研究任务不存在")
rows = await _report_job_store(request).list_jobs(task_id, user_id, limit=limit)
return EnterpriseResearchReportJobListResponse(jobs=[_report_job_response(row) for row in rows])
@router.post("/tasks/{task_id}/report-jobs", response_model=EnterpriseResearchReportJobResponse, status_code=status.HTTP_201_CREATED)
async def create_report_job(
request: Request, task_id: str, body: EnterpriseResearchReportJobCreate
) -> EnterpriseResearchReportJobResponse:
"""Freeze the approved plan/evidence, then launch an independent report job."""
user_id, _ = await _actor(request)
task_store = _task_store(request)
job_store = _report_job_store(request)
executor = _report_executor(request)
async with _report_start_lock(request):
task = await task_store.get_task(task_id, user_id)
if task is None:
raise HTTPException(status_code=404, detail="研究任务不存在")
active = await job_store.get_active_for_task(task_id, user_id)
if active is not None:
await executor.ensure_started(active["id"], user_id)
return _report_job_response(active)
plan = str(task.get("plan_markdown") or "").strip()
selected_ids = _validate_selected_source_ids(task, list(task.get("selected_source_ids") or []))
if not plan:
raise HTTPException(status_code=409, detail="请先生成并保存研究计划")
if not selected_ids:
raise HTTPException(status_code=409, detail="请至少选择一条资料后再撰写报告")
sources_by_id = {
str(item.get("chunk_id") or ""): item
for item in task.get("sources") or []
if isinstance(item, dict)
}
sources = [sources_by_id[source_id] for source_id in selected_ids if source_id in sources_by_id]
if not sources:
raise HTTPException(status_code=409, detail="已选资料不可用,请重新保存研究计划")
if body.action in {"rewrite", "followup"} and not task.get("report_markdown"):
raise HTTPException(status_code=409, detail="当前任务还没有可供改写或追问的报告")
if body.action == "followup" and not body.instruction.strip():
raise HTTPException(status_code=422, detail="请填写报告追问")
row = await job_store.create_job(
{
"id": f"erj_{uuid4().hex}", "task_id": task_id, "user_id": user_id,
"model_name": (body.model_name or "").strip() or None,
"action": body.action, "instruction": body.instruction.strip(), "style": body.style,
"target_length": body.target_length, "plan_snapshot": plan, "sources": sources,
"original_report": task.get("report_markdown") if body.action in {"rewrite", "followup"} else None,
}
)
await task_store.update_task(task_id, user_id, {"active_report_job_id": row["id"]})
await executor.ensure_started(row["id"], user_id)
return _report_job_response(row)
@router.get("/tasks/{task_id}/report-jobs/{job_id}/stream")
async def stream_report_job(request: Request, task_id: str, job_id: str) -> StreamingResponse:
"""SSE stream: durable draft snapshot first, then live Markdown/reasoning deltas."""
user_id, _ = await _actor(request)
job = await _report_job_store(request).get_job(job_id, user_id)
if job is None or job.get("task_id") != task_id:
raise HTTPException(status_code=404, detail="报告任务不存在")
if job["status"] == "queued":
await _report_executor(request).ensure_started(job_id, user_id)
async def body():
async for event in _report_executor(request).stream_events(job_id, user_id):
yield "event: enterprise_research\n"
yield "data: " + json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n\n"
return StreamingResponse(
body(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post(
"/tasks/{task_id}/report-jobs/{job_id}/cancel",
response_model=EnterpriseResearchReportJobResponse,
)
async def cancel_report_job(request: Request, task_id: str, job_id: str) -> EnterpriseResearchReportJobResponse:
user_id, _ = await _actor(request)
job = await _report_job_store(request).get_job(job_id, user_id)
if job is None or job.get("task_id") != task_id:
raise HTTPException(status_code=404, detail="报告任务不存在")
cancelled = await _report_executor(request).cancel(job_id, user_id)
return _report_job_response(cancelled or job)
@router.post(
"/tasks/{task_id}/report-jobs/{job_id}/restore",
response_model=EnterpriseResearchTaskResponse,
)
async def restore_report_version(request: Request, task_id: str, job_id: str) -> EnterpriseResearchTaskResponse:
"""Restore the before-image frozen by a completed full-report rewrite."""
user_id, _ = await _actor(request)
job = await _report_job_store(request).get_job(job_id, user_id)
if job is None or job.get("task_id") != task_id:
raise HTTPException(status_code=404, detail="报告任务不存在")
original = str(job.get("original_report") or "")
if job.get("status") != "completed" or job.get("action") != "rewrite" or not original.strip():
raise HTTPException(status_code=409, detail="该任务没有可恢复的改写前版本")
if await _report_job_store(request).get_active_for_task(task_id, user_id) is not None:
raise HTTPException(status_code=409, detail="报告任务正在执行,请完成或停止后再恢复版本")
task_store = _task_store(request)
current = await task_store.get_task(task_id, user_id)
if current is None:
raise HTTPException(status_code=404, detail="研究任务不存在")
current_report = str(current.get("report_markdown") or "")
if not current_report.strip():
raise HTTPException(status_code=409, detail="当前没有可替换的报告")
# The restore itself is a versioned operation: its before-image allows the
# user to reverse a mistaken restore through the same version UI.
await _report_job_store(request).create_job(
{
"id": f"erj_{uuid4().hex}", "task_id": task_id, "user_id": user_id,
"status": "completed", "phase": "completed", "action": "rewrite",
"instruction": f"恢复报告任务 {job_id} 的改写前版本", "style": "formal",
"model_name": current.get("report_model_name"),
"plan_snapshot": str(current.get("plan_markdown") or ""),
"sources": [
item for item in current.get("sources") or []
if isinstance(item, dict) and item.get("chunk_id") in set(current.get("selected_source_ids") or [])
],
"original_report": current_report, "report_markdown": original,
"report_summary": f"已恢复到报告任务 {job_id} 的改写前版本。",
}
)
task = await task_store.update_task(
task_id,
user_id,
{
"report_markdown": original,
"report_summary": f"已恢复到报告任务 {job_id} 的改写前版本。",
"active_report_job_id": None,
},
)
assert task is not None
return _response(task)
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(request: Request, task_id: str) -> None:
user_id, _ = await _actor(request)
if not await _task_store(request).delete_task(task_id, user_id):
raise HTTPException(status_code=404, detail="研究任务不存在")
@@ -0,0 +1,31 @@
"""Enterprise-research task persistence."""
from deerflow.persistence.enterprise_research.base import EnterpriseResearchReportJobStore, EnterpriseResearchTaskStore
from deerflow.persistence.enterprise_research.memory import MemoryEnterpriseResearchReportJobStore, MemoryEnterpriseResearchTaskStore
from deerflow.persistence.enterprise_research.model import EnterpriseResearchReportJobRow, EnterpriseResearchTaskRow
from deerflow.persistence.enterprise_research.sql import EnterpriseResearchReportJobRepository, EnterpriseResearchTaskRepository
__all__ = [
"EnterpriseResearchTaskRow",
"EnterpriseResearchReportJobRow",
"EnterpriseResearchTaskRepository",
"EnterpriseResearchReportJobRepository",
"EnterpriseResearchTaskStore",
"EnterpriseResearchReportJobStore",
"MemoryEnterpriseResearchTaskStore",
"MemoryEnterpriseResearchReportJobStore",
"make_enterprise_research_task_store",
"make_enterprise_research_report_job_store",
]
def make_enterprise_research_task_store(session_factory):
if session_factory is None:
return MemoryEnterpriseResearchTaskStore()
return EnterpriseResearchTaskRepository(session_factory)
def make_enterprise_research_report_job_store(session_factory):
if session_factory is None:
return MemoryEnterpriseResearchReportJobStore()
return EnterpriseResearchReportJobRepository(session_factory)
@@ -0,0 +1,75 @@
"""Persistence contract for the independent enterprise-research workbench."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class EnterpriseResearchTaskStore(ABC):
"""Owner-scoped task storage; evidence snapshots are DeerFlow-owned."""
@abstractmethod
async def list_tasks(self, user_id: str, *, limit: int = 20) -> list[dict[str, Any]]:
raise NotImplementedError
@abstractmethod
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def delete_task(self, task_id: str, user_id: str) -> bool:
raise NotImplementedError
class EnterpriseResearchReportJobStore(ABC):
"""Owner-scoped, frozen-input jobs for enterprise report writing."""
@abstractmethod
async def create_job(self, data: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def get_job(self, job_id: str, user_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def get_active_for_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def claim_job(self, job_id: str, user_id: str) -> bool:
"""Atomically move one queued job to running."""
raise NotImplementedError
@abstractmethod
async def requeue_job(self, job_id: str, user_id: str, *, expected_updated_at: str | None) -> bool:
"""Atomically requeue the exact running snapshot seen during recovery."""
raise NotImplementedError
@abstractmethod
async def transition_job(
self, job_id: str, user_id: str, *, expected_statuses: set[str], data: dict[str, Any]
) -> dict[str, Any] | None:
"""Apply a terminal/state transition only from one of the expected states."""
raise NotImplementedError
@abstractmethod
async def update_job(self, job_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def list_jobs(self, task_id: str, user_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
raise NotImplementedError
@abstractmethod
async def list_recoverable(self, *, limit: int = 100) -> list[dict[str, Any]]:
raise NotImplementedError
@@ -0,0 +1,172 @@
"""In-memory fallback for enterprise-research tasks."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.enterprise_research.base import EnterpriseResearchReportJobStore, EnterpriseResearchTaskStore
def _now() -> str:
return datetime.now(UTC).isoformat()
class MemoryEnterpriseResearchTaskStore(EnterpriseResearchTaskStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_tasks(self, user_id: str, *, limit: int = 20) -> list[dict[str, Any]]:
rows = [dict(row) for row in self._rows.values() if row["user_id"] == user_id]
return sorted(rows, key=lambda row: row["updated_at"], reverse=True)[:limit]
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
row = self._rows.get(task_id)
return dict(row) if row is not None and row["user_id"] == user_id else None
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
now = _now()
row = {
"id": str(data["id"]),
"user_id": str(data["user_id"]),
"subject": str(data["subject"]),
"focus": str(data.get("focus") or ""),
"template_id": str(data["template_id"]),
"knowledge_base_ids": list(data.get("knowledge_base_ids") or []),
"queries": list(data.get("queries") or []),
"selected_source_ids": list(data.get("selected_source_ids") or []),
"sources": list(data.get("sources") or []),
"plan_markdown": data.get("plan_markdown"),
"report_markdown": data.get("report_markdown"),
"report_summary": data.get("report_summary"),
"report_model_name": data.get("report_model_name"),
"active_report_job_id": data.get("active_report_job_id"),
"status": str(data.get("status") or "draft"),
"warning": data.get("warning"),
"created_at": now,
"updated_at": now,
}
self._rows[row["id"]] = row
return dict(row)
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
row = self._rows.get(task_id)
if row is None or row["user_id"] != user_id:
return None
for key in (
"status", "warning", "sources", "selected_source_ids", "plan_markdown",
"report_markdown", "report_summary", "report_model_name", "active_report_job_id",
):
if key in data:
row[key] = data[key]
row["updated_at"] = _now()
return dict(row)
async def delete_task(self, task_id: str, user_id: str) -> bool:
row = self._rows.get(task_id)
if row is None or row["user_id"] != user_id:
return False
del self._rows[task_id]
return True
class MemoryEnterpriseResearchReportJobStore(EnterpriseResearchReportJobStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def create_job(self, data: dict[str, Any]) -> dict[str, Any]:
now = _now()
status = str(data.get("status") or "queued")
active_dedupe_key = (
f"{data['user_id']}:{data['task_id']}" if status in {"queued", "running"} else None
)
if active_dedupe_key:
existing = next(
(row for row in self._rows.values() if row.get("active_dedupe_key") == active_dedupe_key),
None,
)
if existing is not None:
return dict(existing)
row = {
"id": str(data["id"]), "task_id": str(data["task_id"]), "user_id": str(data["user_id"]),
"status": status, "phase": str(data.get("phase") or "queued"),
"model_name": data.get("model_name"), "action": str(data.get("action") or "initial"),
"active_dedupe_key": active_dedupe_key,
"instruction": data.get("instruction"), "style": data.get("style"),
"target_length": data.get("target_length"), "plan_snapshot": str(data["plan_snapshot"]),
"sources": list(data.get("sources") or []), "report_markdown": str(data.get("report_markdown") or ""),
"original_report": data.get("original_report"),
"report_summary": data.get("report_summary"), "error_message": data.get("error_message"),
"created_at": now, "updated_at": now,
}
self._rows[row["id"]] = row
return dict(row)
async def get_job(self, job_id: str, user_id: str) -> dict[str, Any] | None:
row = self._rows.get(job_id)
return dict(row) if row is not None and row["user_id"] == user_id else None
async def get_active_for_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
rows = [row for row in self._rows.values() if row["task_id"] == task_id and row["user_id"] == user_id and row["status"] in {"queued", "running"}]
return dict(max(rows, key=lambda row: row["updated_at"])) if rows else None
async def claim_job(self, job_id: str, user_id: str) -> bool:
row = self._rows.get(job_id)
if row is None or row["user_id"] != user_id or row["status"] != "queued":
return False
row["status"] = "running"
row["updated_at"] = _now()
return True
async def requeue_job(self, job_id: str, user_id: str, *, expected_updated_at: str | None) -> bool:
row = self._rows.get(job_id)
if (
row is None
or row["user_id"] != user_id
or row["status"] != "running"
or row.get("updated_at") != expected_updated_at
):
return False
row.update({"status": "queued", "phase": "queued", "report_markdown": "", "error_message": None})
row["updated_at"] = _now()
return True
async def transition_job(
self, job_id: str, user_id: str, *, expected_statuses: set[str], data: dict[str, Any]
) -> dict[str, Any] | None:
row = self._rows.get(job_id)
if row is None or row["user_id"] != user_id or row["status"] not in expected_statuses:
return None
for key in (
"status", "phase", "report_markdown", "report_summary", "error_message", "model_name",
"action", "instruction", "style", "target_length", "original_report",
):
if key in data:
row[key] = data[key]
if data.get("status") in {"completed", "failed", "cancelled"}:
row["active_dedupe_key"] = None
row["updated_at"] = _now()
return dict(row)
async def update_job(self, job_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
row = self._rows.get(job_id)
if row is None or row["user_id"] != user_id:
return None
for key in (
"status", "phase", "report_markdown", "report_summary", "error_message", "model_name",
"action", "instruction", "style", "target_length", "original_report",
):
if key in data:
row[key] = data[key]
if data.get("status") in {"completed", "failed", "cancelled"}:
row["active_dedupe_key"] = None
row["updated_at"] = _now()
return dict(row)
async def list_jobs(self, task_id: str, user_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
rows = [dict(row) for row in self._rows.values() if row["task_id"] == task_id and row["user_id"] == user_id]
return sorted(rows, key=lambda row: row["updated_at"], reverse=True)[:limit]
async def list_recoverable(self, *, limit: int = 100) -> list[dict[str, Any]]:
rows = [dict(row) for row in self._rows.values() if row["status"] in {"queued", "running"}]
return sorted(rows, key=lambda row: row["updated_at"])[:limit]
@@ -0,0 +1,77 @@
"""ORM rows for the independent enterprise-research workbench."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON, PortableLongText
class EnterpriseResearchTaskRow(Base):
"""A user-owned research task plus its bounded WeKnora evidence snapshot."""
__tablename__ = "enterprise_research_tasks"
__table_args__ = (Index("ix_enterprise_research_tasks_owner_updated", "user_id", "updated_at"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
subject: Mapped[str] = mapped_column(String(256), nullable=False)
focus: Mapped[str] = mapped_column(Text, nullable=False, default="", server_default="")
template_id: Mapped[str] = mapped_column(String(64), nullable=False)
knowledge_base_ids: Mapped[list[str]] = mapped_column(PortableJSON(), nullable=False, default=list)
queries: Mapped[list[str]] = mapped_column(PortableJSON(), nullable=False, default=list)
# Nullable keeps upgrades safe: the generic schema reconciler can add this
# field to a phase-one table without an unsafe MySQL TEXT default.
selected_source_ids: Mapped[list[str] | None] = mapped_column(PortableJSON(), nullable=True, default=list)
# A WeKnora result can contain a long document chunk. MySQL TEXT is only
# 64 KiB, so evidence uses LONGTEXT through PortableLongText.
sources_json: Mapped[str] = mapped_column(PortableLongText(), nullable=False, default="[]", server_default="[]")
plan_markdown: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
report_markdown: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
report_summary: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
report_model_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
active_report_job_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft", server_default="draft")
warning: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)
class EnterpriseResearchReportJobRow(Base):
"""A report job whose plan/evidence are frozen when the user starts it."""
__tablename__ = "enterprise_research_report_jobs"
__table_args__ = (
Index("ix_enterprise_research_report_jobs_task_owner_updated", "task_id", "user_id", "updated_at"),
UniqueConstraint("active_dedupe_key", name="uq_enterprise_research_report_jobs_active"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
task_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", server_default="queued")
phase: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", server_default="queued")
model_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
# Present only for queued/running rows. NULL terminal rows keep unlimited
# history while this key rejects cross-process duplicate starts.
active_dedupe_key: Mapped[str | None] = mapped_column(String(256), nullable=True)
action: Mapped[str | None] = mapped_column(String(32), nullable=True, default="initial")
instruction: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
style: Mapped[str | None] = mapped_column(String(32), nullable=True)
target_length: Mapped[int | None] = mapped_column(nullable=True)
plan_snapshot: Mapped[str] = mapped_column(PortableLongText(), nullable=False)
sources_json: Mapped[str] = mapped_column(PortableLongText(), nullable=False)
original_report: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
report_markdown: Mapped[str] = mapped_column(PortableLongText(), nullable=False, default="", server_default="")
report_summary: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
error_message: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)
@@ -0,0 +1,320 @@
"""SQLAlchemy store for enterprise-research tasks."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.enterprise_research.base import EnterpriseResearchReportJobStore, EnterpriseResearchTaskStore
from deerflow.persistence.enterprise_research.model import EnterpriseResearchReportJobRow, EnterpriseResearchTaskRow
def _to_dict(row: EnterpriseResearchTaskRow) -> dict[str, Any]:
try:
sources = json.loads(row.sources_json or "[]")
except (TypeError, ValueError):
sources = []
data: dict[str, Any] = {
"id": row.id,
"user_id": row.user_id,
"subject": row.subject,
"focus": row.focus,
"template_id": row.template_id,
"knowledge_base_ids": list(row.knowledge_base_ids or []),
"queries": list(row.queries or []),
"selected_source_ids": list(row.selected_source_ids or []),
"sources": sources if isinstance(sources, list) else [],
"plan_markdown": row.plan_markdown,
"report_markdown": row.report_markdown,
"report_summary": row.report_summary,
"report_model_name": row.report_model_name,
"active_report_job_id": row.active_report_job_id,
"status": row.status,
"warning": row.warning,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
return data
class EnterpriseResearchTaskRepository(EnterpriseResearchTaskStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_tasks(self, user_id: str, *, limit: int = 20) -> list[dict[str, Any]]:
stmt = (
select(EnterpriseResearchTaskRow)
.where(EnterpriseResearchTaskRow.user_id == user_id)
.order_by(EnterpriseResearchTaskRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
stmt = select(EnterpriseResearchTaskRow).where(
EnterpriseResearchTaskRow.id == task_id,
EnterpriseResearchTaskRow.user_id == user_id,
)
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
return _to_dict(row) if row is not None else None
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = EnterpriseResearchTaskRow(
id=str(data["id"]),
user_id=str(data["user_id"]),
subject=str(data["subject"]),
focus=str(data.get("focus") or ""),
template_id=str(data["template_id"]),
knowledge_base_ids=list(data.get("knowledge_base_ids") or []),
queries=list(data.get("queries") or []),
selected_source_ids=list(data.get("selected_source_ids") or []),
sources_json=json.dumps(list(data.get("sources") or []), ensure_ascii=False),
plan_markdown=data.get("plan_markdown"),
report_markdown=data.get("report_markdown"),
report_summary=data.get("report_summary"),
report_model_name=data.get("report_model_name"),
active_report_job_id=data.get("active_report_job_id"),
status=str(data.get("status") or "draft"),
warning=data.get("warning"),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
# Do not refresh after commit: a MySQL read/write split can route it
# to a lagging replica. The flushed in-memory row is authoritative.
await session.flush()
payload = _to_dict(row)
await session.commit()
return payload
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
stmt = select(EnterpriseResearchTaskRow).where(
EnterpriseResearchTaskRow.id == task_id,
EnterpriseResearchTaskRow.user_id == user_id,
)
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None:
return None
if "status" in data:
row.status = str(data["status"])
if "warning" in data:
row.warning = data["warning"]
if "sources" in data:
row.sources_json = json.dumps(list(data["sources"] or []), ensure_ascii=False)
if "selected_source_ids" in data:
row.selected_source_ids = list(data["selected_source_ids"] or [])
if "plan_markdown" in data:
row.plan_markdown = data["plan_markdown"]
for key in ("report_markdown", "report_summary", "report_model_name", "active_report_job_id"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
await session.flush()
payload = _to_dict(row)
await session.commit()
return payload
async def delete_task(self, task_id: str, user_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(
delete(EnterpriseResearchTaskRow).where(
EnterpriseResearchTaskRow.id == task_id,
EnterpriseResearchTaskRow.user_id == user_id,
)
)
await session.commit()
return bool(result.rowcount)
def _job_to_dict(row: EnterpriseResearchReportJobRow) -> dict[str, Any]:
try:
sources = json.loads(row.sources_json or "[]")
except (TypeError, ValueError):
sources = []
return {
"id": row.id, "task_id": row.task_id, "user_id": row.user_id, "status": row.status,
"phase": row.phase, "model_name": row.model_name, "action": row.action or "initial",
"instruction": row.instruction, "style": row.style, "target_length": row.target_length,
"plan_snapshot": row.plan_snapshot,
"sources": sources if isinstance(sources, list) else [], "report_markdown": row.report_markdown,
"original_report": row.original_report,
"report_summary": row.report_summary, "error_message": row.error_message,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
class EnterpriseResearchReportJobRepository(EnterpriseResearchReportJobStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def create_job(self, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
status = str(data.get("status") or "queued")
active_dedupe_key = (
f"{data['user_id']}:{data['task_id']}" if status in {"queued", "running"} else None
)
row = EnterpriseResearchReportJobRow(
id=str(data["id"]), task_id=str(data["task_id"]), user_id=str(data["user_id"]),
status=status, phase=str(data.get("phase") or "queued"),
model_name=data.get("model_name"), action=str(data.get("action") or "initial"),
active_dedupe_key=active_dedupe_key,
instruction=data.get("instruction"), style=data.get("style"), target_length=data.get("target_length"),
plan_snapshot=str(data["plan_snapshot"]),
sources_json=json.dumps(list(data.get("sources") or []), ensure_ascii=False),
original_report=data.get("original_report"),
report_markdown=str(data.get("report_markdown") or ""), report_summary=data.get("report_summary"),
error_message=data.get("error_message"), created_at=now, updated_at=now,
)
async with self._sf() as session:
session.add(row)
try:
await session.flush()
payload = _job_to_dict(row)
await session.commit()
return payload
except IntegrityError:
await session.rollback()
if not active_dedupe_key:
raise
existing = (await session.execute(
select(EnterpriseResearchReportJobRow)
.where(EnterpriseResearchReportJobRow.active_dedupe_key == active_dedupe_key)
.limit(1)
)).scalar_one_or_none()
if existing is None:
raise
return _job_to_dict(existing)
async def get_job(self, job_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = (await session.execute(select(EnterpriseResearchReportJobRow).where(
EnterpriseResearchReportJobRow.id == job_id, EnterpriseResearchReportJobRow.user_id == user_id,
))).scalar_one_or_none()
return _job_to_dict(row) if row is not None else None
async def get_active_for_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = (await session.execute(select(EnterpriseResearchReportJobRow).where(
EnterpriseResearchReportJobRow.task_id == task_id,
EnterpriseResearchReportJobRow.user_id == user_id,
EnterpriseResearchReportJobRow.status.in_(["queued", "running"]),
).order_by(EnterpriseResearchReportJobRow.updated_at.desc()).limit(1))).scalar_one_or_none()
return _job_to_dict(row) if row is not None else None
async def claim_job(self, job_id: str, user_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(
update(EnterpriseResearchReportJobRow)
.where(
EnterpriseResearchReportJobRow.id == job_id,
EnterpriseResearchReportJobRow.user_id == user_id,
EnterpriseResearchReportJobRow.status == "queued",
)
.values(status="running", updated_at=datetime.now(UTC))
)
await session.commit()
return bool(result.rowcount)
async def requeue_job(self, job_id: str, user_id: str, *, expected_updated_at: str | None) -> bool:
if not expected_updated_at:
return False
try:
expected = datetime.fromisoformat(expected_updated_at)
except ValueError:
return False
async with self._sf() as session:
result = await session.execute(
update(EnterpriseResearchReportJobRow)
.where(
EnterpriseResearchReportJobRow.id == job_id,
EnterpriseResearchReportJobRow.user_id == user_id,
EnterpriseResearchReportJobRow.status == "running",
EnterpriseResearchReportJobRow.updated_at == expected,
)
.values(
status="queued",
phase="queued",
report_markdown="",
error_message=None,
updated_at=datetime.now(UTC),
)
)
await session.commit()
return bool(result.rowcount)
async def transition_job(
self, job_id: str, user_id: str, *, expected_statuses: set[str], data: dict[str, Any]
) -> dict[str, Any] | None:
allowed = {
"status", "phase", "report_markdown", "report_summary", "error_message", "model_name",
"action", "instruction", "style", "target_length", "original_report",
}
values = {key: value for key, value in data.items() if key in allowed}
if data.get("status") in {"completed", "failed", "cancelled"}:
values["active_dedupe_key"] = None
values["updated_at"] = datetime.now(UTC)
async with self._sf() as session:
result = await session.execute(
update(EnterpriseResearchReportJobRow)
.where(
EnterpriseResearchReportJobRow.id == job_id,
EnterpriseResearchReportJobRow.user_id == user_id,
EnterpriseResearchReportJobRow.status.in_(expected_statuses),
)
.values(**values)
)
if not result.rowcount:
await session.rollback()
return None
await session.commit()
return await self.get_job(job_id, user_id)
async def update_job(self, job_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = (await session.execute(select(EnterpriseResearchReportJobRow).where(
EnterpriseResearchReportJobRow.id == job_id, EnterpriseResearchReportJobRow.user_id == user_id,
))).scalar_one_or_none()
if row is None:
return None
for key in (
"status", "phase", "report_markdown", "report_summary", "error_message", "model_name",
"action", "instruction", "style", "target_length", "original_report",
):
if key in data:
setattr(row, key, data[key])
if data.get("status") in {"completed", "failed", "cancelled"}:
row.active_dedupe_key = None
row.updated_at = datetime.now(UTC)
await session.flush()
payload = _job_to_dict(row)
await session.commit()
return payload
async def list_jobs(self, task_id: str, user_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
async with self._sf() as session:
rows = (await session.execute(select(EnterpriseResearchReportJobRow).where(
EnterpriseResearchReportJobRow.task_id == task_id, EnterpriseResearchReportJobRow.user_id == user_id,
).order_by(EnterpriseResearchReportJobRow.updated_at.desc()).limit(limit))).scalars()
return [_job_to_dict(row) for row in rows]
async def list_recoverable(self, *, limit: int = 100) -> list[dict[str, Any]]:
async with self._sf() as session:
rows = (await session.execute(
select(EnterpriseResearchReportJobRow)
.where(EnterpriseResearchReportJobRow.status.in_(["queued", "running"]))
.order_by(EnterpriseResearchReportJobRow.updated_at.asc())
.limit(limit)
)).scalars()
return [_job_to_dict(row) for row in rows]
@@ -46,6 +46,7 @@ from deerflow.persistence.deep_research_sessions.model import DeepResearchSessio
from deerflow.persistence.deep_research_sources.model import DeepResearchSourceRow
from deerflow.persistence.document_rewrite_versions.model import DocumentRewriteVersionRow
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.enterprise_research.model import EnterpriseResearchReportJobRow, EnterpriseResearchTaskRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.intent_turns.model import IntentTurnRow
from deerflow.persistence.light_apps.model import LightAppRow
@@ -162,6 +163,8 @@ __all__ = [
"TaskCopTaskRow",
"TaskReportRow",
"EmbedSessionRow",
"EnterpriseResearchTaskRow",
"EnterpriseResearchReportJobRow",
"IntentTurnRow",
"ThreadMetaRow",
"ThreadShareRow",
@@ -0,0 +1,396 @@
"""Regression tests for owner-scoped enterprise-research task persistence."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.base import Base
from deerflow.persistence.enterprise_research import (
EnterpriseResearchReportJobRepository,
EnterpriseResearchTaskRepository,
MemoryEnterpriseResearchReportJobStore,
MemoryEnterpriseResearchTaskStore,
make_enterprise_research_task_store,
)
@pytest_asyncio.fixture
async def store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'enterprise-research.db'}")
async with engine.begin() as conn:
import deerflow.persistence.enterprise_research.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
return EnterpriseResearchTaskRepository(async_sessionmaker(engine, expire_on_commit=False))
async def _create(store, *, task_id: str, user_id: str = "u1"):
return await store.create_task(
{
"id": task_id,
"user_id": user_id,
"subject": "新能源汽车供应链风险",
"focus": "关键原材料",
"template_id": "topic",
"knowledge_base_ids": ["kb-1"],
"queries": ["新能源汽车供应链风险", "关键原材料"],
"status": "draft",
}
)
@pytest.mark.asyncio
async def test_task_evidence_roundtrip_and_owner_isolation(store: EnterpriseResearchTaskRepository):
created = await _create(store, task_id="ert-1")
assert created["sources"] == []
assert created["status"] == "draft"
updated = await store.update_task(
"ert-1",
"u1",
{
"status": "ready",
"selected_source_ids": ["chunk-1"],
"plan_markdown": "# 供应链风险研究计划",
"sources": [
{
"chunk_id": "chunk-1",
"title": "供应链报告",
"content": "证据内容",
"knowledge_base_id": "kb-1",
}
],
},
)
assert updated is not None
assert updated["status"] == "ready"
assert updated["sources"][0]["chunk_id"] == "chunk-1"
assert updated["selected_source_ids"] == ["chunk-1"]
assert updated["plan_markdown"] == "# 供应链风险研究计划"
assert await store.get_task("ert-1", "u2") is None
assert await store.update_task("ert-1", "u2", {"status": "failed"}) is None
@pytest.mark.asyncio
async def test_list_is_owner_scoped_and_delete_requires_owner(store: EnterpriseResearchTaskRepository):
await _create(store, task_id="ert-u1", user_id="u1")
await _create(store, task_id="ert-u2", user_id="u2")
assert [task["id"] for task in await store.list_tasks("u1")] == ["ert-u1"]
assert await store.delete_task("ert-u1", "u2") is False
assert await store.delete_task("ert-u1", "u1") is True
assert await store.get_task("ert-u1", "u1") is None
@pytest.mark.asyncio
async def test_memory_store_matches_sql_contract():
store = make_enterprise_research_task_store(None)
assert isinstance(store, MemoryEnterpriseResearchTaskStore)
await _create(store, task_id="memory-1")
assert (await store.get_task("memory-1", "u1"))["subject"] == "新能源汽车供应链风险"
assert await store.delete_task("memory-1", "u1") is True
@pytest.mark.asyncio
async def test_report_job_freezes_sources_and_is_owner_scoped(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'enterprise-report-job.db'}")
async with engine.begin() as conn:
import deerflow.persistence.enterprise_research.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
jobs = EnterpriseResearchReportJobRepository(async_sessionmaker(engine, expire_on_commit=False))
created = await jobs.create_job(
{
"id": "erj-1", "task_id": "ert-1", "user_id": "u1", "plan_snapshot": "# 计划",
"sources": [{"chunk_id": "source-1", "content": "冻结证据"}],
"action": "rewrite", "instruction": "优化结构", "original_report": "# 原报告",
}
)
assert created["status"] == "queued"
assert created["sources"][0]["content"] == "冻结证据"
assert created["action"] == "rewrite"
assert created["original_report"] == "# 原报告"
deduplicated = await jobs.create_job(
{
"id": "erj-duplicate", "task_id": "ert-1", "user_id": "u1", "plan_snapshot": "# 其他计划",
"sources": [], "action": "initial",
}
)
assert deduplicated["id"] == "erj-1"
assert await jobs.get_job("erj-1", "u2") is None
assert (await jobs.get_active_for_task("ert-1", "u1"))["id"] == "erj-1"
assert await jobs.claim_job("erj-1", "u1") is True
assert await jobs.claim_job("erj-1", "u1") is False
running = (await jobs.list_recoverable())[0]
assert running["status"] == "running"
assert await jobs.requeue_job("erj-1", "u1", expected_updated_at="stale-snapshot") is False
assert await jobs.requeue_job("erj-1", "u1", expected_updated_at=running["updated_at"]) is True
assert await jobs.requeue_job("erj-1", "u1", expected_updated_at=running["updated_at"]) is False
assert await jobs.claim_job("erj-1", "u1") is True
completed = await jobs.transition_job(
"erj-1", "u1", expected_statuses={"running"},
data={"status": "completed", "phase": "completed", "report_markdown": "# 报告"},
)
assert completed is not None and completed["report_markdown"] == "# 报告"
assert await jobs.transition_job(
"erj-1", "u1", expected_statuses={"running"}, data={"status": "cancelled"}
) is None
assert await jobs.get_active_for_task("ert-1", "u1") is None
next_job = await jobs.create_job(
{"id": "erj-2", "task_id": "ert-1", "user_id": "u1", "plan_snapshot": "# 新计划", "sources": []}
)
assert next_job["id"] == "erj-2"
@pytest.mark.asyncio
async def test_memory_report_job_store_has_matching_owner_contract():
jobs = MemoryEnterpriseResearchReportJobStore()
await jobs.create_job({"id": "memory-job", "task_id": "task", "user_id": "u1", "plan_snapshot": "x"})
assert await jobs.get_job("memory-job", "u2") is None
assert (await jobs.list_jobs("task", "u1"))[0]["id"] == "memory-job"
assert await jobs.claim_job("memory-job", "u1") is True
assert await jobs.claim_job("memory-job", "u1") is False
running = await jobs.get_job("memory-job", "u1")
assert running is not None
assert await jobs.requeue_job("memory-job", "u1", expected_updated_at=running["updated_at"]) is True
assert await jobs.claim_job("memory-job", "u1") is True
completed = await jobs.transition_job(
"memory-job", "u1", expected_statuses={"running"}, data={"status": "completed", "phase": "completed"}
)
assert completed is not None and completed["status"] == "completed"
def test_evidence_snapshot_deduplicates_and_never_exposes_unmapped_remote_bases():
from app.gateway.routers.enterprise_research import _dedupe_evidence
mappings = {"remote-kb-1": {"id": "local-kb-1", "name": "用户可见知识库", "weknora_id": "remote-kb-1"}}
evidence = _dedupe_evidence(
[
{"chunk_id": "chunk-1", "content": "第一段", "knowledge_base_id": "remote-kb-1", "title": "文档 A"},
{"chunk_id": "chunk-1", "content": "重复段", "knowledge_base_id": "remote-kb-1", "title": "文档 A"},
{"chunk_id": "private-chunk", "content": "不应显示", "knowledge_base_id": "other-user-kb", "title": "私有文档"},
],
mappings,
)
assert len(evidence) == 1
assert evidence[0]["knowledge_base_id"] == "local-kb-1"
assert "other-user-kb" not in str(evidence)
def test_report_executor_uses_distinct_initial_rewrite_and_followup_prompts():
from app.gateway.enterprise_research_report_executor import EnterpriseResearchReportExecutor
base = {
"plan_snapshot": "# 计划",
"sources": [{"chunk_id": "source-1", "title": "内部资料", "content": "证据正文"}],
"original_report": "# 原报告\n原始正文",
"instruction": "压缩重复段落",
"target_length": 3_000,
"style": "analytical",
}
initial = EnterpriseResearchReportExecutor._messages({**base, "action": "initial"})
rewrite = EnterpriseResearchReportExecutor._messages({**base, "action": "rewrite"})
followup = EnterpriseResearchReportExecutor._messages({**base, "action": "followup"})
assert "完整 Markdown 报告" in initial[0]["content"]
assert "原报告" not in initial[1]["content"]
assert "全文改写" in rewrite[1]["content"] and "# 原报告" in rewrite[1]["content"]
assert "不要重写整篇报告" in followup[0]["content"]
assert "压缩重复段落" in followup[1]["content"]
summary = EnterpriseResearchReportExecutor._summary(
"# 新报告\n\n新正文", 2, "rewrite", original_report="# 旧报告\n\n旧正文", instruction="调整结构"
)
assert "原文" in summary and "标题节点 1→1" in summary and "调整结构" in summary
@pytest.mark.asyncio
async def test_executor_recovery_requeues_exact_running_snapshot(monkeypatch):
from app.gateway.enterprise_research_report_executor import EnterpriseResearchReportExecutor
tasks = MemoryEnterpriseResearchTaskStore()
jobs = MemoryEnterpriseResearchReportJobStore()
await jobs.create_job({"id": "recover-job", "task_id": "task", "user_id": "u1", "plan_snapshot": "# 计划"})
assert await jobs.claim_job("recover-job", "u1") is True
executor = EnterpriseResearchReportExecutor(tasks, jobs)
started: list[tuple[str, str]] = []
async def remember(job_id: str, user_id: str) -> None:
started.append((job_id, user_id))
monkeypatch.setattr(executor, "ensure_started", remember)
await executor.recover_pending()
recovered = await jobs.get_job("recover-job", "u1")
assert recovered is not None and recovered["status"] == "queued"
assert started == [("recover-job", "u1")]
@pytest.mark.asyncio
async def test_enterprise_research_http_plan_job_dedupe_and_cancel(monkeypatch):
import app.gateway.routers.enterprise_research as enterprise_router
tasks = MemoryEnterpriseResearchTaskStore()
jobs = MemoryEnterpriseResearchReportJobStore()
class NoopExecutor:
def __init__(self) -> None:
self.started: list[tuple[str, str]] = []
async def ensure_started(self, job_id: str, user_id: str) -> None:
self.started.append((job_id, user_id))
async def cancel(self, job_id: str, user_id: str):
job = await jobs.get_job(job_id, user_id)
if job is None:
return None
cancelled = await jobs.transition_job(
job_id, user_id, expected_statuses={"queued", "running"},
data={"status": "cancelled", "phase": "cancelled"},
)
await tasks.update_task(job["task_id"], user_id, {"active_report_job_id": None})
return cancelled
async def anonymous_actor(_request):
return None
monkeypatch.setattr(enterprise_router, "get_optional_user_from_request", anonymous_actor)
app = FastAPI()
app.include_router(enterprise_router.router)
app.state.enterprise_research_task_store = tasks
app.state.enterprise_research_report_job_store = jobs
app.state.enterprise_research_report_executor = NoopExecutor()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
created = await client.post(
"/api/enterprise-research/tasks",
json={"subject": "企业风险", "queries": ["企业风险"], "knowledge_base_ids": []},
)
assert created.status_code == 201
task_id = created.json()["id"]
await tasks.update_task(
task_id, "default",
{
"status": "ready", "selected_source_ids": ["source-1"],
"sources": [{"chunk_id": "source-1", "title": "内部材料", "content": "已核验事实"}],
},
)
plan = await client.post(f"/api/enterprise-research/tasks/{task_id}/plan/draft")
assert plan.status_code == 200 and plan.json()["plan_markdown"]
saved = await client.put(
f"/api/enterprise-research/tasks/{task_id}/plan",
json={"plan_markdown": "# 企业风险报告", "selected_source_ids": ["source-1"]},
)
assert saved.status_code == 200
first = await client.post(
f"/api/enterprise-research/tasks/{task_id}/report-jobs",
json={"action": "initial", "style": "formal", "target_length": 2_000},
)
assert first.status_code == 201
duplicate = await client.post(
f"/api/enterprise-research/tasks/{task_id}/report-jobs",
json={"action": "initial"},
)
assert duplicate.status_code == 201 and duplicate.json()["id"] == first.json()["id"]
cancelled = await client.post(
f"/api/enterprise-research/tasks/{task_id}/report-jobs/{first.json()['id']}/cancel"
)
assert cancelled.status_code == 200 and cancelled.json()["status"] == "cancelled"
@pytest.mark.asyncio
async def test_executor_stream_commit_separates_reasoning_and_followup(monkeypatch):
import app.gateway.enterprise_research_report_executor as executor_module
tasks = MemoryEnterpriseResearchTaskStore()
jobs = MemoryEnterpriseResearchReportJobStore()
await _create(tasks, task_id="stream-task")
await tasks.update_task(
"stream-task", "u1",
{"plan_markdown": "# 研究计划", "report_markdown": "# 旧报告", "report_summary": "旧摘要"},
)
class FakeBackend:
def __init__(self, _config) -> None:
pass
async def stream_complete(self, *, messages, on_delta, on_reasoning, **_kwargs):
is_followup = "不要重写整篇报告" in messages[0]["content"]
text = "追问回答" if is_followup else "# 新报告\n正文 [资料:source-1]"
await on_reasoning("模型内部思考")
for delta in (text[:4], text[4:]):
await on_delta(delta)
return SimpleNamespace(text=text, model="fake-model")
monkeypatch.setattr(executor_module, "DeerFlowCompletionBackend", FakeBackend)
executor = executor_module.EnterpriseResearchReportExecutor(tasks, jobs)
await jobs.create_job(
{
"id": "initial-job", "task_id": "stream-task", "user_id": "u1", "plan_snapshot": "# 研究计划",
"sources": [{"chunk_id": "source-1", "content": "证据"}], "action": "initial",
}
)
await executor._run("initial-job", "u1")
completed = await jobs.get_job("initial-job", "u1")
task = await tasks.get_task("stream-task", "u1")
assert completed is not None and completed["status"] == "completed"
assert completed["report_markdown"] == "# 新报告\n正文 [资料:source-1]"
assert "模型内部思考" not in completed["report_markdown"]
assert task is not None and task["report_markdown"] == completed["report_markdown"]
await jobs.create_job(
{
"id": "followup-job", "task_id": "stream-task", "user_id": "u1", "plan_snapshot": "# 研究计划",
"sources": [{"chunk_id": "source-1", "content": "证据"}], "action": "followup",
"instruction": "结论是什么", "original_report": completed["report_markdown"],
}
)
await executor._run("followup-job", "u1")
followup = await jobs.get_job("followup-job", "u1")
unchanged = await tasks.get_task("stream-task", "u1")
assert followup is not None and followup["report_markdown"] == "追问回答"
assert unchanged is not None and unchanged["report_markdown"] == completed["report_markdown"]
@pytest.mark.asyncio
async def test_cross_process_cancel_cannot_be_overwritten_by_late_model_result(monkeypatch):
import app.gateway.enterprise_research_report_executor as executor_module
tasks = MemoryEnterpriseResearchTaskStore()
jobs = MemoryEnterpriseResearchReportJobStore()
await _create(tasks, task_id="cancel-task")
await tasks.update_task("cancel-task", "u1", {"report_markdown": "# 已保存报告"})
await jobs.create_job(
{"id": "cancel-job", "task_id": "cancel-task", "user_id": "u1", "plan_snapshot": "# 计划"}
)
started = asyncio.Event()
release = asyncio.Event()
class SlowBackend:
def __init__(self, _config) -> None:
pass
async def stream_complete(self, *, on_delta, **_kwargs):
started.set()
await release.wait()
await on_delta("# 不应提交的迟到结果")
return SimpleNamespace(text="# 不应提交的迟到结果", model="fake-model")
monkeypatch.setattr(executor_module, "DeerFlowCompletionBackend", SlowBackend)
writer = executor_module.EnterpriseResearchReportExecutor(tasks, jobs)
remote_gateway = executor_module.EnterpriseResearchReportExecutor(tasks, jobs)
running = asyncio.create_task(writer._run("cancel-job", "u1"))
await started.wait()
cancelled = await remote_gateway.cancel("cancel-job", "u1")
assert cancelled is not None and cancelled["status"] == "cancelled"
release.set()
with pytest.raises(asyncio.CancelledError):
await running
final_job = await jobs.get_job("cancel-job", "u1")
final_task = await tasks.get_task("cancel-task", "u1")
assert final_job is not None and final_job["status"] == "cancelled"
assert final_task is not None and final_task["report_markdown"] == "# 已保存报告"