diff --git a/.gitignore b/.gitignore index 34a6d1fd..6924a39a 100644 --- a/.gitignore +++ b/.gitignore @@ -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) diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 7499cc54..586be7ea 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -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: "多智能体规划", diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 8f87e2be..fa4cdc8a 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -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", + }, ], }, { diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index 582b0a39..6922a0a0 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -248,8 +248,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa ); return ( - + + /> + ); } diff --git a/frontend-web/src/pages/EnterpriseResearchPage.tsx b/frontend-web/src/pages/EnterpriseResearchPage.tsx new file mode 100644 index 00000000..9c2118bb --- /dev/null +++ b/frontend-web/src/pages/EnterpriseResearchPage.tsx @@ -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 = { + 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 {meta.label}; +} + +export default function EnterpriseResearchPage() { + const runtime = useLlmWikiRuntime(); + const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora"); + const [tasks, setTasks] = useState([]); + const [activeTaskId, setActiveTaskId] = useState(null); + const [subject, setSubject] = useState(""); + const [focus, setFocus] = useState(""); + const [templateId, setTemplateId] = useState("topic"); + const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]); + const [selectedSourceId, setSelectedSourceId] = useState(null); + const [reportJob, setReportJob] = useState(null); + const [reportJobs, setReportJobs] = useState([]); + const [reportDraft, setReportDraft] = useState(""); + const [jobDraft, setJobDraft] = useState(""); + const [reasoning, setReasoning] = useState(""); + const [isStartingReport, setIsStartingReport] = useState(false); + const reportActionRef = useRef("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) => { + 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[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 ( +
+
+
+
+ +
+
+
+

企业深度研究

+ 独立工作台 +
+

面向内部知识库的研究任务、证据编排与可追溯交付

+
+
+ +
+ +
+ + +
+ {!activeTaskId ? ( +
+
+ 阶段一:证据编排 +

建立一个可追溯的研究任务

+

页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。

+
+ + + + 研究对象与范围 + 用一句话明确需要研究的对象;补充范围可帮助生成更聚焦的检索方向。 + + + +