feat: 知识库实体筛选、技能白名单收紧、嵌入主题与提问组件优化

- 知识库笔记列表支持按实体精确筛选(路由/服务/仓储层透传,关系图索引查询)
- 自定义智能体技能白名单在 skill_view 与 skill_manage 的 list/view 别名上同样生效;
  config 未声明 skills 时视为无可见技能
- 嵌入圆桌主题解析默认深蓝,强制主题期间不再写 localStorage 覆盖主站选择
- 智能体提问建议拆出 agent-question-chips 组件,编辑器与详情面板联动优化
- 新增 qa-client 问答测试脚本

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Fable 5 committed 2026-06-12 11:09:32 +08:00
1 parent 11bc442f10
commit 28098b3155
22 files changed
+500 -126

No files matched your search

+2 -1
View File
@@ -251,7 +251,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/entity/source_type/status + pagination; `entity` is an exact entity-name filter resolved via the note→entity relation graph, combinable with `keyword` — powers the related-notes dialog's server-side search + paging); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
@@ -406,6 +406,7 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
- **Injection**: Enabled skills listed in agent system prompt with container paths
- **Per-agent whitelist (custom agents)**: a custom agent's `config.yaml` `skills:` field is an explicit allowlist — `None` (key omitted) and `[]` both mean **no skills**, never "all skills". Only the default lead agent (no `agent_id`) and the bootstrap flow see every enabled skill. Enforced consistently in three places: the system prompt (`get_skills_prompt_section`, empty whitelist → no `<skill_system>` block at all), the `skill_list` tool, and the `skill_view` tool (out-of-whitelist names answer "not found" so existence isn't leaked) — including the `skill_manage` `list`/`view` aliases, which delegate to the shared `skill_list_impl`/`skill_view_impl` with the active `agent_id`. Tests: `tests/test_skill_tools_whitelist.py`
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) — never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill → owner or admin; built-in/public → admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (一键补充: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` — an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) — distinct from the model returning an unusable name (→ `None`). The single endpoint maps that to `502 大模型报错,无法补全技能中文名:…`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N 个因大模型报错失败". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI 生成" plus an **admin-only** header "一键补充中文名" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
@@ -424,6 +424,7 @@ async def list_notes(
request: Request,
keyword: str | None = None,
tag: str | None = None,
entity: str | None = None,
source_type: str | None = None,
status: str | None = None,
limit: int = 20,
@@ -432,7 +433,7 @@ async def list_notes(
service = get_knowledge_service(request)
limit = max(1, min(limit, 100))
offset = max(0, offset)
return await service.list_notes(keyword=keyword, tag=tag, source_type=source_type, status=status, limit=limit, offset=offset)
return await service.list_notes(keyword=keyword, tag=tag, entity=entity, source_type=source_type, status=status, limit=limit, offset=offset)
@router.get("/notes/{note_id}")
@@ -276,6 +276,7 @@ class KnowledgeRepository:
*,
keyword: str | None = None,
tag: str | None = None,
entity: str | None = None,
source_type: str | None = None,
status: str | None = None,
include_archived: bool = False,
@@ -303,6 +304,16 @@ class KnowledgeRepository:
.where(func.lower(KnowledgeTagRow.name) == tag.lower())
)
conditions.append(KnowledgeNoteRow.id.in_(tag_subq))
if entity:
# Exact entity match via the note→entity relation graph — precise
# and index-backed, unlike a content LIKE scan on large vaults.
entity_subq = (
select(KnowledgeRelationRow.from_note_id)
.join(KnowledgeEntityRow, KnowledgeRelationRow.to_entity_id == KnowledgeEntityRow.id)
.where(func.lower(KnowledgeEntityRow.name) == entity.lower())
.where(KnowledgeRelationRow.from_note_id.is_not(None))
)
conditions.append(KnowledgeNoteRow.id.in_(entity_subq))
for cond in conditions:
stmt = stmt.where(cond)
@@ -523,6 +523,7 @@ class KnowledgeService:
*,
keyword: str | None = None,
tag: str | None = None,
entity: str | None = None,
source_type: str | None = None,
status: str | None = None,
limit: int = 20,
@@ -531,6 +532,7 @@ class KnowledgeService:
items, total = await self.repo.list_notes(
keyword=keyword,
tag=tag,
entity=entity,
source_type=normalize_source_type(source_type) if source_type else None,
status=status,
limit=limit,
@@ -39,10 +39,15 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
"""Return the explicit skill name set declared in an agent's config.yaml.
Semantics:
- ``None`` returned → no agent_id, or config missing — caller should
fall back to "all skills visible".
- ``set()`` (empty) → agent has ``skills: []`` declared — visibility
is intentionally empty.
- ``None`` returned → no agent_id, or the agent's on-disk config is
missing entirely — caller should fall back to "all skills visible"
(default lead agent behavior).
- ``set()`` (empty) → custom agent with ``skills: []`` declared **or**
with no ``skills`` key at all — visibility is intentionally empty.
This mirrors the prompt layer (``agents_config.AgentConfig.skills``:
``None``/``[]`` both mean "no skills" for custom agents), so an agent
whose config omits ``skills`` can no longer enumerate every skill
through this tool.
- Non-empty set → only these names are visible to this agent.
"""
if not agent_id:
@@ -51,9 +56,9 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None:
from deerflow.config.agents_config import load_agent_config
cfg = load_agent_config(agent_id)
if cfg is None or cfg.skills is None:
if cfg is None:
return None
return set(cfg.skills)
return set(cfg.skills or [])
except Exception:
logger.debug("Failed to load agent config for skill_list filter", exc_info=True)
return None
@@ -119,16 +124,21 @@ def _load_owned_skill_names(user_id: str | None) -> set[str] | None:
return None
@tool("skill_view")
def skill_view_tool(name: str) -> str:
"""Read the full SKILL.md content of a skill by name.
def skill_view_impl(name: str, agent_id: str | None) -> str:
"""Read a skill's SKILL.md, honoring the agent's config.yaml whitelist.
Args:
name: The skill name (hyphen-case, e.g. 'my-skill').
Custom agents may only read skills on their whitelist — same filter as
``skill_list``, so a restricted agent cannot bypass its allowlist by
guessing skill names. Replies "not found" rather than "forbidden" to
avoid leaking which skills exist.
"""
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.usage import bump_view
allowed = _load_agent_skill_whitelist(agent_id)
if allowed is not None and name not in allowed:
return f"Skill '{name}' not found."
storage = get_or_new_skill_storage()
try:
if storage.custom_skill_exists(name):
@@ -156,6 +166,16 @@ def skill_view_tool(name: str) -> str:
return content
@tool("skill_view")
def skill_view_tool(name: str, runtime: ToolRuntime) -> str:
"""Read the full SKILL.md content of a skill by name.
Args:
name: The skill name (hyphen-case, e.g. 'my-skill').
"""
return skill_view_impl(name, _resolve_active_agent_id(runtime))
@tool("skill_list")
def skill_list_tool(runtime: ToolRuntime) -> str:
"""List skills available to this agent, with metadata and usage stats.
@@ -175,11 +195,15 @@ def skill_list_tool(runtime: ToolRuntime) -> str:
state, use_count, view_count, patch_count, last_activity_at, pinned,
source.
"""
return skill_list_impl(_resolve_active_agent_id(runtime))
def skill_list_impl(agent_id: str | None) -> str:
"""List skills visible to ``agent_id`` (None = default lead agent)."""
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import SkillCategory
from deerflow.skills.usage import STATE_ARCHIVED, all_entries, latest_activity_at
agent_id = _resolve_active_agent_id(runtime)
allowed = _load_agent_skill_whitelist(agent_id)
# 默认 lead agent 没有 per-agent 白名单 —— 改为只暴露"未归档"且"内置或属于
@@ -218,14 +218,16 @@ async def _skill_manage_impl(
"""
# Read-only aliases — delegate to the dedicated tools so a model that
# over-uses skill_manage doesn't trip on Unsupported action errors.
# Pass the active agent_id through so a custom agent's skill whitelist
# is enforced on these aliases just like on skill_list/skill_view.
if action == "list":
from deerflow.tools.builtins.skill_tools import skill_list_tool
from deerflow.tools.builtins.skill_tools import _resolve_active_agent_id, skill_list_impl
return await _to_thread(skill_list_tool.invoke, {})
return await _to_thread(skill_list_impl, _resolve_active_agent_id(runtime))
if action == "view":
from deerflow.tools.builtins.skill_tools import skill_view_tool
from deerflow.tools.builtins.skill_tools import _resolve_active_agent_id, skill_view_impl
return await _to_thread(skill_view_tool.invoke, {"name": name})
return await _to_thread(skill_view_impl, name, _resolve_active_agent_id(runtime))
name = SkillStorage.validate_skill_name(name)
lock = _get_lock(name)