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:
1 parent
11bc442f10
commit
28098b3155
22 files changed
+500
-126
No files matched your search
@@ -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,
|
||||
|
||||
+36
-12
@@ -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 白名单 —— 改为只暴露"未归档"且"内置或属于
|
||||
|
||||
+6
-4
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user