From 2402a84c71f5646920883ceb0ea1d3899d2868c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 19 May 2026 15:14:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A3=80=E7=B4=A2=E9=80=BB=E8=BE=91=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/docs/素材专家检索.md | 30 +++ .../artifact/DraftOutlineSidebar.tsx | 192 ++++++++++++++++++ .../artifact/TextArtifactRenderer.tsx | 28 ++- .../open-canvas/hooks/useDocumentOutline.ts | 72 +++++++ .../agents/ai_writing/nodes/researcher.py | 39 +++- 5 files changed, 346 insertions(+), 15 deletions(-) create mode 100644 frontend-web/src/open-canvas/components/artifact/DraftOutlineSidebar.tsx create mode 100644 frontend-web/src/open-canvas/hooks/useDocumentOutline.ts diff --git a/frontend-web/docs/素材专家检索.md b/frontend-web/docs/素材专家检索.md index 2d66b605..ae1a3396 100644 --- a/frontend-web/docs/素材专家检索.md +++ b/frontend-web/docs/素材专家检索.md @@ -46,6 +46,36 @@ ai_writing: --- +## 1.6 素材总量上限 + +素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。 + +配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段: + +```yaml +ai_writing: + # 素材总量上限,范围 1~100,非法值回退 20 + max_materials: 20 +``` + +均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整): + +| `max_materials` | 检索词数 | 每词检索条数 | +|---|---|---| +| 20 | 1 | 20 | +| 20 | 2 | 10 | +| 20 | 3 | 7(共 21,合并后截断到 20) | +| 20 | 5 | 4 | + +- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。 +- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。 +- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。 +- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。 + +> `config.yaml` 支持热加载,改完一般无需重启后端。 + +--- + ## 2. 内网接口规格 - **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es` diff --git a/frontend-web/src/open-canvas/components/artifact/DraftOutlineSidebar.tsx b/frontend-web/src/open-canvas/components/artifact/DraftOutlineSidebar.tsx new file mode 100644 index 00000000..98cf7a85 --- /dev/null +++ b/frontend-web/src/open-canvas/components/artifact/DraftOutlineSidebar.tsx @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { Editor } from "@tiptap/react"; +import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useDocumentOutline } from "../../hooks/useDocumentOutline"; +import type { OutlineHeading } from "../../hooks/useDocumentOutline"; + +interface DraftOutlineSidebarProps { + /** Tiptap 编辑器实例,用于提取标题并解析标题 DOM */ + editor: Editor | null; + /** 文档滚动容器,点击目录项 / scroll-spy 时在其内部滚动与定位 */ + scrollEl: HTMLElement | null; +} + +/** 点击跳转时标题距容器顶部预留的间距(px) */ +const SCROLL_TOP_OFFSET = 16; + +/** scroll-spy:标题顶部进入此阈值内即视为「当前章节」(px) */ +const ACTIVE_THRESHOLD = 80; + +/** + * 目录项的展示层级: + * - 文档 H1 = 文章标题,单独作顶部标题块(不进列表) + * - 文档 H2 = 章节名称 → 列表一级项 + * - 文档 H3 = 要点 → 列表二级项 + */ +function displayLevel(h: OutlineHeading): 1 | 2 { + return h.level >= 3 ? 2 : 1; +} + +/** + * 草稿文档左侧目录栏(仿豆包 / Word 目录窗格): + * 顶部独立标题块 + 章节/要点两级列表,支持点击平滑跳转、滚动高亮、折叠。 + */ +export function DraftOutlineSidebar({ editor, scrollEl }: DraftOutlineSidebarProps) { + const headings = useDocumentOutline(editor); + const [activeId, setActiveId] = useState(null); + const [collapsed, setCollapsed] = useState(false); + + // 第一个 H1 作为文章标题,其余标题进目录列表 + const { titleHeading, items } = useMemo(() => { + const titleIdx = headings.findIndex((h) => h.level === 1); + return { + titleHeading: titleIdx >= 0 ? headings[titleIdx] : null, + items: headings.filter((_, i) => i !== titleIdx), + }; + }, [headings]); + + const scrollToHeading = useCallback( + (pos: number, id: string) => { + if (!editor || !scrollEl) return; + const dom = editor.view.nodeDOM(pos) as HTMLElement | null; + if (!dom) return; + const elTop = dom.getBoundingClientRect().top; + const containerTop = scrollEl.getBoundingClientRect().top; + const target = scrollEl.scrollTop + (elTop - containerTop) - SCROLL_TOP_OFFSET; + scrollEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" }); + setActiveId(id); + }, + [editor, scrollEl], + ); + + // scroll-spy:随文档滚动高亮当前章节(rAF 节流) + useEffect(() => { + if (!editor || !scrollEl || headings.length === 0) return; + + let raf = 0; + const recompute = () => { + raf = 0; + const containerTop = scrollEl.getBoundingClientRect().top; + let current = headings[0].id; + for (const h of headings) { + const dom = editor.view.nodeDOM(h.pos) as HTMLElement | null; + if (!dom) continue; + const relTop = dom.getBoundingClientRect().top - containerTop; + // 标题在阈值内或之上 → 当前章节;首个落在阈值下方时其余皆下方,停止 + if (relTop <= ACTIVE_THRESHOLD) { + current = h.id; + } else { + break; + } + } + setActiveId(current); + }; + + const onScroll = () => { + if (raf) return; + raf = requestAnimationFrame(recompute); + }; + + recompute(); + scrollEl.addEventListener("scroll", onScroll, { passive: true }); + return () => { + if (raf) cancelAnimationFrame(raf); + scrollEl.removeEventListener("scroll", onScroll); + }; + }, [editor, scrollEl, headings]); + + // 没有可导航的章节时不渲染目录栏 + if (items.length === 0) return null; + + // 折叠态:仅留一条窄轨 + 展开按钮 + if (collapsed) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 头部:目录标签 + 折叠按钮 */} +
+ 目录 + +
+ + {/* 文章标题块 */} + {titleHeading && ( + + )} + + {/* 章节 / 要点列表 */} + +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx index 23bca574..a11a70dc 100644 --- a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx +++ b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx @@ -13,6 +13,7 @@ import { WritingBubbleMenu } from "./WritingBubbleMenu"; import { WritingParagraphMenu } from "./WritingParagraphMenu"; import { WritingAiMenu } from "./WritingAiMenu"; import { WritingRewritePreview } from "./WritingRewritePreview"; +import { DraftOutlineSidebar } from "./DraftOutlineSidebar"; import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; import { useCanvasUIContext } from "../../contexts/CanvasUIContext"; import { applyReplace, applyInsert } from "../../utils/apply-rewrite"; @@ -45,6 +46,9 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende const [showScrollBtn, setShowScrollBtn] = useState(false); const autoScrollRef = useRef(true); + // 编辑模式文档滚动容器:用 state 而非 ref,使目录栏在容器挂载后能拿到它 + const [docScrollEl, setDocScrollEl] = useState(null); + // ── AI rewrite flow state ────────────────────────────────────────────── const [phase, setPhase] = useState("idle"); const [panelPos, setPanelPos] = useState({ x: 0, y: 0 }); @@ -271,15 +275,21 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
-
-
- {editor && ( - <> - - - - )} - +
+ {/* 左侧目录栏(仿豆包):文档标题层级导航 */} + + + {/* 文档滚动区 */} +
+
+ {editor && ( + <> + + + + )} + +
diff --git a/frontend-web/src/open-canvas/hooks/useDocumentOutline.ts b/frontend-web/src/open-canvas/hooks/useDocumentOutline.ts new file mode 100644 index 00000000..e61ed390 --- /dev/null +++ b/frontend-web/src/open-canvas/hooks/useDocumentOutline.ts @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import type { Editor } from "@tiptap/react"; + +/** 文档目录中的一个标题项 */ +export interface OutlineHeading { + /** React key + scroll-spy 标识,按 ProseMirror 位置生成 */ + id: string; + /** 标题层级,仅保留 1~3 */ + level: 1 | 2 | 3; + /** 标题纯文本 */ + text: string; + /** 标题节点在 ProseMirror 文档中的位置,用于解析 DOM 元素 */ + pos: number; +} + +/** 仅纳入目录的最大标题层级(H1~H3) */ +const MAX_LEVEL = 3; +/** 内容变化后重建目录的防抖间隔(ms) */ +const DEBOUNCE_MS = 200; + +/** + * 从 Tiptap 编辑器实时提取 H1~H3 标题,构成文档目录。 + * + * 首次挂载立即构建一次;之后监听 `editor.on('update')`,按 DEBOUNCE_MS 防抖重建, + * 避免逐字输入时频繁全量遍历。 + */ +export function useDocumentOutline(editor: Editor | null): OutlineHeading[] { + const [headings, setHeadings] = useState([]); + + useEffect(() => { + if (!editor) { + setHeadings([]); + return; + } + + const compute = () => { + const out: OutlineHeading[] = []; + editor.state.doc.descendants((node, pos) => { + if (node.type.name !== "heading") return true; + const level = node.attrs.level as number; + if (level >= 1 && level <= MAX_LEVEL) { + out.push({ + id: `heading-${pos}`, + level: level as 1 | 2 | 3, + text: node.textContent.trim() || "(无标题)", + pos, + }); + } + return true; + }); + setHeadings(out); + }; + + // 首次立即构建 + compute(); + + // 内容变化时防抖重建 + let timer: ReturnType | undefined; + const onUpdate = () => { + clearTimeout(timer); + timer = setTimeout(compute, DEBOUNCE_MS); + }; + editor.on("update", onUpdate); + + return () => { + clearTimeout(timer); + editor.off("update", onUpdate); + }; + }, [editor]); + + return headings; +} diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py index bc34cec2..da9223d7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py @@ -18,7 +18,10 @@ from ._utils import llm_json logger = logging.getLogger(__name__) -_MAX_MATERIALS = 12 +# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 20 +_DEFAULT_MAX_MATERIALS = 20 +_MIN_MAX_MATERIALS = 1 +_MAX_MAX_MATERIALS = 100 # 素材检索关键词数量边界:用户可指定 1~10 个,非法值回退默认 6 _DEFAULT_KEYWORD_COUNT = 6 @@ -34,6 +37,26 @@ def _resolve_keyword_count(raw: object) -> int: return max(1, min(_MAX_KEYWORD_COUNT, n)) +def _resolve_max_materials(raw: object) -> int: + """把 config.yaml 配置的素材总量上限规整到 [1, 100],非法值回退默认 20。""" + try: + n = int(raw) # type: ignore[arg-type] + except (TypeError, ValueError): + return _DEFAULT_MAX_MATERIALS + return max(_MIN_MAX_MATERIALS, min(_MAX_MAX_MATERIALS, n)) + + +def _per_keyword_limit(max_materials: int, total_keywords: int) -> int: + """把素材总量上限均分到每个检索词上的检索条数(向上取整)。 + + 向上取整保证合并去重后素材数仍能接近总量上限;最终会再统一截断到上限。 + total_keywords <= 0 时回退为总量上限本身。 + """ + if total_keywords <= 0: + return max_materials + return (max_materials + total_keywords - 1) // total_keywords + + def _notice(title: str, detail: str, raw: str = "") -> dict: """构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。 @@ -67,6 +90,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict ai_writing_config = cfg.get("ai_writing_config", {}) or {} keyword_model: str | None = ai_writing_config.get("keyword_model") or None rank_model: str | None = ai_writing_config.get("rank_model") or None + # 素材总量上限(config.yaml: ai_writing.max_materials),按检索词数均分到每个词 + max_materials = _resolve_max_materials(ai_writing_config.get("max_materials")) async def _emit(message: str) -> None: if sse_queue is None: @@ -103,6 +128,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict search_limit = max(search_limit, len(keywords)) search_keywords = keywords[:search_limit] total = len(search_keywords) + # 每个检索词分到的检索条数 = 总量上限 / 检索词数(向上取整) + per_keyword = _per_keyword_limit(max_materials, total) await _emit(f"检索词已生成,开始检索 {total} 个方向…") # 并发搜索,但每完成一个就推送进度(计数在单线程 asyncio 下无需加锁) @@ -111,7 +138,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict async def _search_one(kw: str) -> list[SearchResult]: nonlocal completed try: - return await provider.search(kw, max_results=3) + return await provider.search(kw, max_results=per_keyword) finally: completed += 1 if completed < total: @@ -159,7 +186,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # Step 3: 相关度排序(如素材较多,让 LLM 排序) await _emit("检索完成,正在筛选排序素材…") rank_error = "" - if len(raw_materials) > _MAX_MATERIALS: + if len(raw_materials) > max_materials: materials_list = "\n".join( f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}" for m in raw_materials @@ -167,7 +194,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict rank_result = await llm_json( RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list), - fallback={"ranked_ids": [m["id"] for m in raw_materials[:_MAX_MATERIALS]], "summary": f"共搜索到{len(raw_materials)}条素材"}, + fallback={"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"}, config=config, model_name=rank_model, ) @@ -180,10 +207,10 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict for m in raw_materials: if m["id"] not in sorted_ids_set: sorted_materials.append(m) - materials = sorted_materials[:_MAX_MATERIALS] + materials = sorted_materials[:max_materials] summary = rank_result.get("summary", f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条") else: - materials = sorted(raw_materials, key=lambda m: m.get("relevance_score", 0), reverse=True)[:_MAX_MATERIALS] + materials = sorted(raw_materials, key=lambda m: m.get("relevance_score", 0), reverse=True)[:max_materials] summary = f"共搜索到{len(raw_materials)}条素材,精选{len(materials)}条" # 空结果兜底:未检索到任何素材时给出明确文案,前端据此弹出「无数据继续」选项