diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx index 15666c54..f7bb2e9d 100644 --- a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx +++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx @@ -256,6 +256,10 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { const stream = streamRef.current; if (!stream) return; try { + // 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。 + // 三个合法值:knowledge_base / skill / notebook,后端 state.py 接受任意 str。 + // 与 material_source 独立的 notebook_search_space_id / notebook_source_ids + // 控制是否真的去笔记后端拉素材(仅当用户选了"我的笔记"模式时由表单填充)。 await stream.submit( { user_intent: request.userIntent, @@ -266,7 +270,9 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { word_count_target: request.wordCountTarget, keyword_count: Math.max(1, Math.min(10, request.keywordCount)), writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose', - material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base', + material_source: request.materialSource, + notebook_search_space_id: request.noteSearchSpaceId ?? null, + notebook_source_ids: request.noteSourceIds ?? null, revision_count: 0, max_revisions: 3, status: 'researching', diff --git a/frontend-web/src/pages/StudioNotebookDetailPage.tsx b/frontend-web/src/pages/StudioNotebookDetailPage.tsx index 488a8ce8..a304ab2d 100644 --- a/frontend-web/src/pages/StudioNotebookDetailPage.tsx +++ b/frontend-web/src/pages/StudioNotebookDetailPage.tsx @@ -291,6 +291,17 @@ export default function StudioNotebookDetailPage() { await send(text, selectedSourceIds); }; + // Clarification 卡片的「发送」按钮回调。MessageList 内部把用户选中的选项 + + // 自由文本合并成一段 answer 传过来,这里走和普通发送同一条 send() 链路, + // 把当前选中的来源也一并带上,保持上下文一致。 + const handleClarificationSubmit = useCallback( + async (answer: string) => { + if (isStreaming) return; + await send(answer, selectedSourceIds); + }, + [send, selectedSourceIds, isStreaming], + ); + const handleBackToList = () => { setActiveThreadId(null); setThreadId(undefined); @@ -655,6 +666,7 @@ export default function StudioNotebookDetailPage() { thread={thread} paddingBottom={120} extraRehypePlugins={citationRehypePlugins} + onClarificationSubmit={handleClarificationSubmit} /> )} 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 88386ff1..429d199c 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 @@ -109,20 +109,42 @@ async def _search_notebook( """从笔记 API 按关键词检索内容,返回 source_type='notebook' 的 Material 列表。""" import httpx # 懒导入:仅在笔记模式下才需要 + # 入参全 dump(token 只打长度,不打明文,防止泄漏到日志) + logger.info( + "[notebook-search] called keywords=%s search_space_id=%s source_ids=%s token_len=%d", + keywords, + search_space_id, + source_ids, + len(token) if token else 0, + ) + base = (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/") if not base: - logger.warning("DEER_FLOW_NOTEBOOK_API_BASE not set, skipping notebook search") + # 改成 error 级别,warning 在很多部署里默认会被过滤掉看不到 + logger.error( + "[notebook-search] DEER_FLOW_NOTEBOOK_API_BASE not set, skipping notebook search. " + "请在后端启动环境里设这个环境变量(指向笔记后端根地址,例如 http://localhost:8003)。" + ) return [] + full_url = f"{base}{_NOTEBOOK_SEARCH_PATH}" + logger.info("[notebook-search] base=%s full_url=%s", base, full_url) + results: list[Material] = [] mat_idx = 0 try: async with httpx.AsyncClient(timeout=20) as client: for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]: + logger.info("[notebook-search] POST kw=%r body=%s", kw, { + "query": kw, + "source_ids": source_ids, + "search_space_id": search_space_id, + "top_k": _NOTEBOOK_TOP_K, + }) try: + # /mentioned-documents/search 接口无需鉴权,不传 Authorization header resp = await client.post( - f"{base}{_NOTEBOOK_SEARCH_PATH}", - headers={"Authorization": f"Bearer {token}"}, + full_url, json={ "query": kw, "source_ids": source_ids, @@ -130,10 +152,29 @@ async def _search_notebook( "top_k": _NOTEBOOK_TOP_K, }, ) + logger.info( + "[notebook-search] kw=%r http_status=%d body_len=%d", + kw, + resp.status_code, + len(resp.content or b""), + ) + if resp.status_code >= 400: + # 把错误响应体打出来,方便定位 401 / 422 等 + logger.error( + "[notebook-search] kw=%r HTTP %d response: %s", + kw, + resp.status_code, + (resp.text or "")[:1000], + ) resp.raise_for_status() data = resp.json() # 解析 documents 列表,每个 chunk 作为独立素材 documents: list[dict] = data.get("documents") if isinstance(data, dict) else [] + logger.info( + "[notebook-search] kw=%r parsed documents=%d", + kw, + len(documents or []), + ) for doc in documents or []: doc_title: str = doc.get("title") or "笔记文档" doc_url: str = doc.get("url") or "" @@ -154,10 +195,11 @@ async def _search_notebook( url=doc_url, )) except Exception as e: - logger.warning("notebook search kw=%r failed: %s", kw, e) + logger.warning("[notebook-search] kw=%r failed: %s", kw, e, exc_info=True) except Exception as e: - logger.warning("notebook search client error: %s", e) + logger.warning("[notebook-search] client error: %s", e, exc_info=True) + logger.info("[notebook-search] done — returning %d materials", len(results)) return results @@ -250,6 +292,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # Step 1.5: 笔记检索(如果传入了 notebook_search_space_id) notebook_search_space_id: int | None = state.get("notebook_search_space_id") notebook_materials: list[Material] = [] + if notebook_search_space_id: notebook_source_ids: list[int] = state.get("notebook_source_ids") or [] notebook_token: str = cfg.get("notebook_token") or "" @@ -369,6 +412,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict # 技能模式不再走下面的并发/合并逻辑,all_results_nested 留空、跳过 web 通用搜索 all_results_nested: list = [] # 把已收集到的素材挂在 raw_materials_pre,下方合并阶段直接挪过去 + elif material_source == "notebook" and notebook_search_space_id: + # ── 纯笔记模式 ── + # 用户在表单选了「我的笔记」作为素材来源 → 跳过 web/intranet 检索, + # 仅使用 Step 1.5 已拉取的 notebook_materials。 + # 与 UI 文案"从笔记本中检索素材"对齐。 + _emit(f"仅使用笔记素材,跳过 web/intranet 检索({len(notebook_materials)} 条)") + all_results_nested = [] else: # ── 常规 web/intranet 模式 ── provider = get_search_provider(ai_writing_config)