Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd-v2
This commit is contained in:
commit
817585a4ce
17 files changed
+694
-77
No files matched your search
@@ -214,7 +214,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
|--------|-----------|
|
||||
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
|
||||
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags`); `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
|
||||
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
|
||||
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + `tag_id` filters; each item carries `tags`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) |
|
||||
| **Tags** (`/api/tags`) | `GET /` - list tags (optional `tag_type` filter; open to all); `POST /` - create tag (admin); `PUT /{id}` / `DELETE /{id}` - update/delete tag (admin); `GET /assignments/{target_type}/{target_id}` - tags on a resource; `PUT /assignments/{target_type}/{target_id}` - replace a resource's tags (admin). Tags are typed `agent`/`skill`/`scheduled_task` and may only tag resources of their own type. |
|
||||
|
||||
@@ -96,6 +96,7 @@ class SkillResponse(BaseModel):
|
||||
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
|
||||
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
||||
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
||||
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
|
||||
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
|
||||
|
||||
|
||||
@@ -180,10 +181,12 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
|
||||
owner_user_id: str | None = None
|
||||
published = False
|
||||
builtin = skill.category != SkillCategory.CUSTOM
|
||||
detail: str | None = None
|
||||
if ownership is not None:
|
||||
owner_user_id = ownership.get("owner_user_id")
|
||||
published = bool(ownership.get("published", False))
|
||||
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||
detail = ownership.get("detail")
|
||||
|
||||
return SkillResponse(
|
||||
name=skill.name,
|
||||
@@ -201,6 +204,7 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
|
||||
owner_user_id=owner_user_id,
|
||||
published=published,
|
||||
builtin=builtin,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
@@ -251,7 +255,9 @@ async def list_skills(
|
||||
# Public/built-in skills (no DB row) are visible to everyone.
|
||||
# Custom skills must have an ownership record in the visible set.
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
responses.append(_skill_to_response(skill))
|
||||
# Built-in skills usually have no DB row, but an admin-authored
|
||||
# ``detail`` annotation materializes one — surface it when present.
|
||||
responses.append(_skill_to_response(skill, ownership.get(skill.name)))
|
||||
continue
|
||||
record = ownership.get(skill.name)
|
||||
if record is None:
|
||||
@@ -828,7 +834,12 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
|
||||
summary="Get Skill Details",
|
||||
description="Retrieve detailed information about a specific skill by its name.",
|
||||
)
|
||||
async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
||||
async def get_skill(
|
||||
skill_name: str,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillResponse:
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
@@ -837,7 +848,9 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||
|
||||
return _skill_to_response(skill)
|
||||
# Attach ownership (owner/published/detail) when the caller may see it.
|
||||
ownership = await skill_store.get_visible(skill_name, _current_user_id(request))
|
||||
return _skill_to_response(skill, ownership)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -926,6 +939,71 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
|
||||
|
||||
|
||||
class SkillDetailRequest(BaseModel):
|
||||
"""Request body for setting a skill's human-authored detail text."""
|
||||
|
||||
detail: str = Field(default="", description="详情说明文本,支持 Markdown;传空字符串表示清空")
|
||||
|
||||
|
||||
class SkillDetailResponse(BaseModel):
|
||||
"""Response model for the skill-detail update endpoint."""
|
||||
|
||||
name: str = Field(..., description="Skill name")
|
||||
detail: str | None = Field(default=None, description="Persisted detail text (null when cleared)")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/skills/{skill_name}/detail",
|
||||
response_model=SkillDetailResponse,
|
||||
summary="Update Skill Detail",
|
||||
description="设置技能的详情说明。自定义技能由其所有者或管理员编辑;内置/公开技能仅管理员可编辑。",
|
||||
)
|
||||
async def update_skill_detail(
|
||||
skill_name: str,
|
||||
body: SkillDetailRequest,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillDetailResponse:
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
user_id = _current_user_id(request)
|
||||
is_admin = _is_admin_user(request)
|
||||
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
skill = next((s for s in skills if s.name == skill_name), None)
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||
|
||||
# Empty / whitespace-only input clears the annotation.
|
||||
detail_value: str | None = body.detail.strip() or None
|
||||
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
# Owner edits their own skill; admins may edit anyone's.
|
||||
_record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||
if mode == "admin":
|
||||
updated = await skill_store.update_admin(skill_name, {"detail": detail_value})
|
||||
else:
|
||||
updated = await skill_store.update(skill_name, user_id, {"detail": detail_value})
|
||||
else:
|
||||
# Built-in / public skill — only admins may annotate it. These carry
|
||||
# no DB row by default, so materialize one before writing detail.
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only admin users can edit built-in skill details")
|
||||
if await skill_store.get_any(skill_name) is None:
|
||||
await skill_store.ensure_legacy({"name": skill_name, "owner_user_id": None, "published": True})
|
||||
updated = await skill_store.update_admin(skill_name, {"detail": detail_value})
|
||||
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to persist skill detail")
|
||||
return SkillDetailResponse(name=skill_name, detail=updated.get("detail"))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to update detail for %s: %s", skill_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update skill detail: {str(e)}")
|
||||
|
||||
|
||||
class SkillPinRequest(BaseModel):
|
||||
pinned: bool = Field(..., description="Whether to pin or unpin the skill")
|
||||
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"""Add detail column to skills table.
|
||||
|
||||
Revision ID: 20260518_01
|
||||
Revises: 20260515_01
|
||||
Create Date: 2026-05-18
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260518_01"
|
||||
down_revision = "20260515_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ``skills`` may not exist yet on a brand-new DB — create_all handles that
|
||||
# case with the column already present, so only patch existing tables.
|
||||
if _has_table("skills") and not _has_column("skills", "detail"):
|
||||
op.add_column("skills", sa.Column("detail", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("skills", "detail"):
|
||||
op.drop_column("skills", "detail")
|
||||
+6
@@ -69,6 +69,7 @@ class MemorySkillStore(SkillStore):
|
||||
"name": name,
|
||||
"owner_user_id": data.get("owner_user_id"),
|
||||
"published": bool(data.get("published", False)),
|
||||
"detail": data.get("detail"),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
@@ -83,6 +84,7 @@ class MemorySkillStore(SkillStore):
|
||||
"name": name,
|
||||
"owner_user_id": data.get("owner_user_id"),
|
||||
"published": bool(data.get("published", True)),
|
||||
"detail": data.get("detail"),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
@@ -98,6 +100,8 @@ class MemorySkillStore(SkillStore):
|
||||
return None
|
||||
if "published" in data:
|
||||
record["published"] = bool(data["published"])
|
||||
if "detail" in data:
|
||||
record["detail"] = data["detail"]
|
||||
record["updated_at"] = _now()
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
@@ -109,6 +113,8 @@ class MemorySkillStore(SkillStore):
|
||||
record["published"] = bool(data["published"])
|
||||
if "owner_user_id" in data:
|
||||
record["owner_user_id"] = data["owner_user_id"]
|
||||
if "detail" in data:
|
||||
record["detail"] = data["detail"]
|
||||
record["updated_at"] = _now()
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
+6
-1
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy import Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -24,5 +24,10 @@ class SkillRow(Base):
|
||||
name: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
# Human-authored long-form detail/introduction, editable by the skill owner
|
||||
# or an admin. Distinct from the skill ``description`` parsed from SKILL.md;
|
||||
# kept in the DB so admins can annotate built-in skills without rewriting
|
||||
# the shared on-disk SKILL.md file.
|
||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
@@ -63,6 +63,7 @@ class SkillRepository(SkillStore):
|
||||
name=data["name"],
|
||||
owner_user_id=data.get("owner_user_id"),
|
||||
published=bool(data.get("published", False)),
|
||||
detail=data.get("detail"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -81,6 +82,7 @@ class SkillRepository(SkillStore):
|
||||
name=data["name"],
|
||||
owner_user_id=data.get("owner_user_id"),
|
||||
published=bool(data.get("published", True)),
|
||||
detail=data.get("detail"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -101,6 +103,8 @@ class SkillRepository(SkillStore):
|
||||
return None
|
||||
if "published" in data:
|
||||
row.published = bool(data["published"])
|
||||
if "detail" in data:
|
||||
row.detail = data["detail"]
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
@@ -115,6 +119,8 @@ class SkillRepository(SkillStore):
|
||||
row.published = bool(data["published"])
|
||||
if "owner_user_id" in data:
|
||||
row.owner_user_id = data["owner_user_id"]
|
||||
if "detail" in data:
|
||||
row.detail = data["detail"]
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for the skill ``detail`` field (long-form annotation editable by
|
||||
the skill owner or an admin).
|
||||
|
||||
Covers:
|
||||
- ``SkillStore`` persists ``detail`` through create / ensure_legacy / update /
|
||||
update_admin, and owner-only ``update`` still refuses non-owners.
|
||||
- ``_skill_to_response`` surfaces ``detail`` from the ownership record.
|
||||
- ``SkillResponse`` / ``SkillDetailRequest`` model shapes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.routers.skills import (
|
||||
SkillDetailRequest,
|
||||
SkillResponse,
|
||||
_skill_to_response,
|
||||
)
|
||||
from deerflow.config import paths as paths_module
|
||||
from deerflow.persistence.skills import MemorySkillStore
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_paths(tmp_path, monkeypatch):
|
||||
"""Redirect get_paths() at a fresh temp directory so usage lookups in
|
||||
``_skill_to_response`` never touch real state."""
|
||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
yield tmp_path
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SkillStore: detail persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_create_carries_detail() -> None:
|
||||
store = MemorySkillStore()
|
||||
created = await store.create(
|
||||
{"name": "alpha-tool", "owner_user_id": "user-a", "detail": "初始详情"}
|
||||
)
|
||||
assert created["detail"] == "初始详情"
|
||||
fetched = await store.get_any("alpha-tool")
|
||||
assert fetched is not None and fetched["detail"] == "初始详情"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_create_defaults_detail_to_none() -> None:
|
||||
store = MemorySkillStore()
|
||||
created = await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
|
||||
assert created["detail"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_owner_can_set_and_clear_detail() -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
|
||||
|
||||
updated = await store.update("alpha-tool", "user-a", {"detail": "技能用途说明"})
|
||||
assert updated is not None and updated["detail"] == "技能用途说明"
|
||||
|
||||
# Clearing the annotation persists None.
|
||||
cleared = await store.update("alpha-tool", "user-a", {"detail": None})
|
||||
assert cleared is not None and cleared["detail"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_update_detail_rejects_non_owner() -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
|
||||
|
||||
# A different user cannot write detail via the owner-scoped update.
|
||||
assert await store.update("alpha-tool", "user-b", {"detail": "x"}) is None
|
||||
record = await store.get_any("alpha-tool")
|
||||
assert record is not None and record["detail"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_admin_can_set_detail_on_any_skill() -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
|
||||
|
||||
# Admin override writes detail regardless of ownership.
|
||||
updated = await store.update_admin("alpha-tool", {"detail": "管理员补充说明"})
|
||||
assert updated is not None and updated["detail"] == "管理员补充说明"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_partial_update_preserves_detail() -> None:
|
||||
"""Updating only ``published`` must not wipe an existing ``detail``."""
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
|
||||
await store.update("alpha-tool", "user-a", {"detail": "保留我"})
|
||||
|
||||
await store.update("alpha-tool", "user-a", {"published": True})
|
||||
record = await store.get_any("alpha-tool")
|
||||
assert record is not None
|
||||
assert record["detail"] == "保留我"
|
||||
assert record["published"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_ensure_legacy_carries_detail() -> None:
|
||||
store = MemorySkillStore()
|
||||
record = await store.ensure_legacy(
|
||||
{"name": "builtin-skill", "owner_user_id": None, "published": True, "detail": "内置技能说明"}
|
||||
)
|
||||
assert record["detail"] == "内置技能说明"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_skill_response_detail_defaults_to_none() -> None:
|
||||
resp = SkillResponse(name="x", description="d", category=SkillCategory.CUSTOM)
|
||||
assert resp.detail is None
|
||||
|
||||
|
||||
def test_skill_detail_request_defaults_to_empty_string() -> None:
|
||||
assert SkillDetailRequest().detail == ""
|
||||
assert SkillDetailRequest(detail="hello").detail == "hello"
|
||||
|
||||
|
||||
def test_skill_to_response_surfaces_detail_from_ownership(isolated_paths) -> None:
|
||||
skill = SimpleNamespace(
|
||||
name="alpha-tool",
|
||||
description="desc",
|
||||
license=None,
|
||||
category=SkillCategory.CUSTOM,
|
||||
enabled=True,
|
||||
)
|
||||
ownership = {
|
||||
"name": "alpha-tool",
|
||||
"owner_user_id": "user-a",
|
||||
"published": False,
|
||||
"builtin": False,
|
||||
"detail": "这是技能的详细介绍",
|
||||
}
|
||||
resp = _skill_to_response(skill, ownership)
|
||||
assert resp.detail == "这是技能的详细介绍"
|
||||
assert resp.owner_user_id == "user-a"
|
||||
|
||||
|
||||
def test_skill_to_response_detail_none_without_ownership(isolated_paths) -> None:
|
||||
skill = SimpleNamespace(
|
||||
name="builtin-skill",
|
||||
description="desc",
|
||||
license=None,
|
||||
category=SkillCategory.PUBLIC,
|
||||
enabled=True,
|
||||
)
|
||||
resp = _skill_to_response(skill, None)
|
||||
assert resp.detail is None
|
||||
assert resp.builtin is True
|
||||
Reference in new issue
Block a user