This commit is contained in:
xuhd committed 2026-06-10 11:27:48 +08:00
1 parent e54f42c0eb
commit b8d3a549df
3 files changed
+74 -6

No files matched your search

@@ -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',
@@ -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}
/>
</div>
)}
@@ -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)