feat: 笔记本 Studio 模块 — RAG 聊天、引用弹窗、侧边栏工作室

- 新增 Studio 笔记本列表页与详情页(RAG 流式聊天、历史线程切换)
- 修复 streamSubgraphs: true 使 AI 流式消息正常展示
- 引用圆圈 [[cite:N]] 渲染 + 点击弹窗显示文档 chunk 内容
- 侧边栏「工作室」区域支持折叠/展开,笔记本过滤 is_owner=true
- 后端 lead_agent 支持 notebook_source_ids / notebook_token 参数
- 线程 metadata 改为 thread_type=notebook,侧边栏过滤同步

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Sonnet 4.6 committed 2026-05-26 23:55:08 +08:00
1 parent d738c13e9e
commit ff8eb930f7
17 files changed
+1325 -12

No files matched your search

@@ -466,16 +466,24 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
)
# Default lead agent (unchanged behavior)
is_notebook_mode = bool(cfg.get("notebook_search_space_id"))
notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or []
default_tools = get_available_tools(
model_name=model_name,
groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
)
if is_notebook_mode:
from deerflow.tools.builtins.notebook_search_tool import notebook_search_tool
default_tools = [notebook_search_tool] + default_tools
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
tools=get_available_tools(
model_name=model_name,
groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
app_config=resolved_app_config,
),
tools=default_tools,
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
system_prompt=apply_prompt_template(
subagent_enabled=subagent_enabled,
@@ -487,6 +495,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
available_skills=(set(agent_config.skills or []) if agent_config else None),
app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled,
is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids,
),
state_schema=ThreadState,
)
@@ -868,6 +868,8 @@ def apply_prompt_template(
is_bootstrap: bool = False,
app_config: AppConfig | None = None,
memory_injection_enabled: bool = True,
is_notebook_mode: bool = False,
notebook_source_ids: list[int] | None = None,
) -> str:
storage_agent_id = agent_id if agent_id is not None else agent_name
@@ -955,4 +957,46 @@ Writing mode is enabled for this run.
acp_section=acp_and_mounts_section,
)
return prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
prompt = prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
if is_notebook_mode:
source_ids = notebook_source_ids or []
if source_ids:
source_line = f"当前检索范围:{len(source_ids)} 个来源(IDs: {', '.join(str(i) for i in source_ids)})"
else:
source_line = "当前检索范围:全部来源"
prompt += f"""
<notebook_mode>
## 角色
你是一个笔记本 AI 助手,**只能**基于笔记本文档来源回答用户问题,禁止使用任何其他知识。
## 可用知识来源
{source_line}
## 强制工作流程(每次用户提问都必须严格执行,无例外)
1. **第一步(必须)**:立即调用 `search_notebook_sources` 工具,`query` 填写用户问题或精炼的检索关键词。
- 无论问题多简单,都必须先检索,不得跳过。
- 若检索结果不足,可再次调用不同 query 补充检索。
2. **第二步**:仅基于检索到的内容组织回答,**不得使用训练知识或推断**。
3. **第三步**:若检索结果中确实无相关内容,明确告知用户"暂未在来源中找到相关信息"。
## 引用格式(强制)
- 每个事实/句子后立即插入 `[[cite:<chunk_id>]]` 标记,chunk_id 来自检索结果中每个文本块的 id 字段。
- 多个来源支持同一句话时连续插入:`[[cite:101]][[cite:102]]`
- 引用标记紧跟被引用内容末尾,不加空格,不单独成行。
## 示例
用户:合同中违约金是多少?
(调用 search_notebook_sources query="违约金",返回 chunk_id=101 的结果)
回答:根据合同第五条,违约金为合同总额的 0.5%[[cite:101]],乙方拒绝收货视为违约[[cite:101]]。
## 绝对禁止
- 未调用 `search_notebook_sources` 就直接回答
- 引用不在检索结果中的 chunk_id
- 省略引用标记
- 使用笔记本来源之外的知识
</notebook_mode>"""
return prompt
@@ -0,0 +1,71 @@
"""笔记本 RAG 检索工具。
从 LangGraph configurable 中读取 notebook_search_space_id / notebook_source_ids /
notebook_token,调用外部检索接口返回相关文本块,供 LLM 在笔记本模式下引用回答。
API base URL 通过环境变量 DEER_FLOW_NOTEBOOK_API_BASE 配置。
"""
from __future__ import annotations
import logging
import os
import httpx
from langchain.tools import tool
from langgraph.config import get_config
logger = logging.getLogger(__name__)
_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
def _notebook_api_base() -> str:
return (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
@tool("search_notebook_sources", parse_docstring=True)
def notebook_search_tool(query: str, top_k: int = 10) -> str:
"""Search the notebook's knowledge sources to find relevant content.
Call this tool for EVERY user question. After retrieving results, cite
sources inline using [[cite:<document_id>]] markers immediately after
each claim, e.g. "违约金为0.5%[[cite:101]]".
Args:
query: The user's question or a refined search query.
top_k: Number of top chunks to retrieve (default 10).
"""
base = _notebook_api_base()
if not base:
return "Notebook API base URL not configured (DEER_FLOW_NOTEBOOK_API_BASE)."
cfg = get_config().get("configurable", {})
search_space_id: int | None = cfg.get("notebook_search_space_id")
source_ids: list[int] = cfg.get("notebook_source_ids") or []
token: str = cfg.get("notebook_token") or ""
if not search_space_id:
return "No notebook search space configured."
logger.info("notebook_search_tool query=%r source_ids=%s search_space_id=%s", query, source_ids, search_space_id)
try:
resp = httpx.post(
f"{base}{_SEARCH_PATH}",
headers={"Authorization": f"Bearer {token}"},
json={
"query": query,
"source_ids": [682],
"search_space_id": search_space_id,
"top_k": top_k,
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
content = data.get("content") if isinstance(data, dict) else None
return content if content else "No relevant content found."
except Exception as e:
logger.warning("notebook_search_tool failed: %s", e)
return f"Search failed: {e}"