ReactNode }) {
- const key = useLocation().key;
- return
{render()};
-}
-
export default function WorkspaceRoutes({
pageSidebarCollapsed = false,
pageSidebarHidden = false,
@@ -135,7 +121,9 @@ export default function WorkspaceRoutes({
path="chats/:thread_id"
element={
- } />
+
+
+
}
/>
diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py
index 1a10cad2..9d8cbcb3 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/skills.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py
@@ -1,1965 +1,2155 @@
-import asyncio
-import json
-import logging
-import mimetypes
-import re
-import tempfile
-from datetime import datetime
-from pathlib import Path
-from typing import Any
-
-from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
-from fastapi.responses import FileResponse
-from pydantic import BaseModel, Field
-
-from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
-from app.gateway.path_utils import resolve_thread_virtual_path
-from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
-from deerflow.config.app_config import AppConfig
-from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
-from deerflow.persistence.skills import SkillStore
-from deerflow.persistence.tags import TagStore
-from deerflow.runtime.user_context import get_effective_user_id
-from deerflow.skills import Skill
-from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
-from deerflow.skills.installer import SkillAlreadyExistsError
-from deerflow.skills.security_scanner import scan_skill_content
-from deerflow.skills.storage import get_or_new_skill_storage
-from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(prefix="/api", tags=["skills"])
-
-
-# ---------------------------------------------------------------------------
-# Per-user authz helpers (mirrors routers/agents.py)
-# ---------------------------------------------------------------------------
-
-
-def _current_user_id(request: Request) -> str:
- user = getattr(request.state, "user", None)
- if user is not None:
- return str(user.id)
- return get_effective_user_id()
-
-
-def _is_admin_user(request: Request) -> bool:
- user = getattr(request.state, "user", None)
- return getattr(user, "system_role", None) == "admin"
-
-
-def _can_view_skill_source(
- category: SkillCategory,
- owner_user_id: str | None,
- user_id: str,
- *,
- is_admin: bool,
-) -> bool:
- """Whether the raw SKILL.md source is visible to a user.
-
- Admins may view any skill's source; otherwise only the publisher (the
- owner of a custom skill) qualifies. Built-in / public skills have no human
- owner, so for non-admins they are never source-visible.
- """
- if is_admin:
- return True
- return category == SkillCategory.CUSTOM and owner_user_id is not None and owner_user_id == user_id
-
-
-async def _get_owned_skill_or_raise(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
- owned = await store.get_owned(name, user_id)
- if owned is not None:
- return owned
- visible = await store.get_visible(name, user_id)
- if visible is None:
- raise HTTPException(status_code=404, detail=f"Skill '{name}' not found")
- raise HTTPException(status_code=403, detail="You can only modify skills that you own")
-
-
-async def _get_editable_skill_or_raise(
- store: SkillStore,
- name: str,
- user_id: str,
- *,
- is_admin: bool,
-) -> tuple[dict[str, Any], str]:
- """Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
-
- Admins can edit/delete any skill (including others' published ones, since
- the spec lets them force-takedown). Non-admins are limited to their own.
- """
- owned = await store.get_owned(name, user_id)
- if owned is not None:
- return owned, "owner"
- record = await store.get_any(name)
- if record is None:
- raise HTTPException(status_code=404, detail=f"Skill '{name}' not found")
- if is_admin:
- return record, "admin"
- raise HTTPException(status_code=403, detail="You can only modify skills that you own")
-
-
-class SkillResponse(BaseModel):
- """Response model for skill information."""
-
- name: str = Field(..., description="Name of the skill")
- description: str = Field(..., description="Description of what the skill does")
- license: str | None = Field(None, description="License information")
- category: SkillCategory = Field(..., description="Category of the skill (public or custom)")
- enabled: bool = Field(default=True, description="Whether this skill is enabled")
- use_count: int = Field(default=0, description="Number of times the skill has been used")
- view_count: int = Field(default=0, description="Number of times the skill has been viewed")
- patch_count: int = Field(default=0, description="Number of times the skill has been patched")
- last_activity_at: datetime | None = Field(default=None, description="Most recent activity timestamp")
- state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
- pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
- source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
- 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")
- display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
- tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
-
-
-class SkillsListResponse(BaseModel):
- """Response model for listing all skills."""
-
- skills: list[SkillResponse]
-
-
-class SkillUpdateRequest(BaseModel):
- """Request model for updating a skill."""
-
- enabled: bool = Field(..., description="Whether to enable or disable the skill")
-
-
-class SkillDisplayUpdateRequest(BaseModel):
- """Request model for updating a skill's frontend display adapter."""
-
- display: SkillDisplayConfig | None = Field(default=None, description="Display adapter configuration; null clears it")
-
-
-class SkillDisplaySampleResponse(BaseModel):
- """Sample structured output used to infer a display adapter."""
-
- skill_name: str
- sample: dict[str, Any]
-
-
-class SkillInstallRequest(BaseModel):
- """Request model for installing a skill from a .skill file."""
-
- thread_id: str = Field(..., description="The thread ID where the .skill file is located")
- path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
- target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
-
-
-class SkillInstallResponse(BaseModel):
- """Response model for skill installation."""
-
- success: bool = Field(..., description="Whether the installation was successful")
- skill_name: str = Field(..., description="Name of the installed skill")
- message: str = Field(..., description="Installation result message")
- overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
- category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'")
-
-
-class SkillUploadValidateResponse(BaseModel):
- """Response model for the pre-install validation endpoint."""
-
- ok: bool = Field(..., description="Whether the archive passed cheap validation")
- skill_name: str = Field(..., description="Skill name parsed from frontmatter")
- description: str = Field(default="", description="Skill description parsed from frontmatter")
- license: str | None = Field(default=None, description="Skill license, if declared")
- allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke")
- conflict: bool = Field(default=False, description="True if a skill with the same name already exists")
- conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true")
- warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user")
-
-
-class SkillReconcileResponse(BaseModel):
- """Response model for reconciling custom skills found on disk."""
-
- adopted: list[str] = Field(default_factory=list, description="Skills found on disk that had no DB row; now owned by the caller")
- already_registered: list[str] = Field(default_factory=list, description="Skills found on disk that already had a DB row (unchanged)")
-
-
-class CustomSkillContentResponse(SkillResponse):
- content: str = Field(..., description="Raw SKILL.md content")
-
-
-class CustomSkillUpdateRequest(BaseModel):
- content: str = Field(..., description="Replacement SKILL.md content")
-
-
-class CustomSkillHistoryResponse(BaseModel):
- history: list[dict]
-
-
-class SkillRollbackRequest(BaseModel):
- history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
-
-
-def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) -> SkillResponse:
- """Convert a Skill object to a SkillResponse.
-
- ``ownership`` is the skill's record from ``SkillStore`` (or None for public
- built-in skills which never have a DB row). When present, contributes the
- ``owner_user_id`` / ``published`` / ``builtin`` fields used for per-user
- visibility and the frontend's owner badges.
- """
- from deerflow.skills.usage import get_entry, latest_activity_at
-
- entry = get_entry(skill.name)
- last_at_str = latest_activity_at(skill.name)
- last_at: datetime | None = None
- if last_at_str:
- try:
- last_at = datetime.fromisoformat(last_at_str)
- except Exception:
- pass
-
- 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")
-
- display = None
- try:
- skill_config = get_extensions_config().skills.get(skill.name)
- if skill_config and skill_config.display:
- display = skill_config.display.model_dump(by_alias=True)
- except Exception:
- logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True)
-
- return SkillResponse(
- name=skill.name,
- description=skill.description,
- license=skill.license,
- category=skill.category,
- enabled=skill.enabled,
- use_count=entry.get("use_count", 0),
- view_count=entry.get("view_count", 0),
- patch_count=entry.get("patch_count", 0),
- last_activity_at=last_at,
- state=entry.get("state", "active"),
- pinned=entry.get("pinned", False),
- source=entry.get("source", "upload"),
- owner_user_id=owner_user_id,
- published=published,
- builtin=builtin,
- detail=detail,
- display=display,
- )
-
-
-def _dump_extensions_config(config_obj: ExtensionsConfig) -> dict[str, Any]:
- return {
- "mcpServers": {name: server.model_dump(by_alias=True) for name, server in config_obj.mcp_servers.items()},
- "skills": {name: skill_config.model_dump(by_alias=True, exclude_none=True) for name, skill_config in config_obj.skills.items()},
- }
-
-
-_SKILL_SAMPLE_SYSTEM_PROMPT = (
- "You infer realistic JSON return shapes for AI agent skills. "
- "Given a skill's name, description, and full SKILL.md, output a SINGLE JSON object that "
- "represents a plausible successful response from this skill's primary action. "
- "Rules:\n"
- "- Output JSON ONLY. No prose. No markdown fences. No comments.\n"
- "- The top level must be a JSON object (not an array).\n"
- "- Include a results/items/list/records array (pick the most natural key) with 2-3 example entries.\n"
- "- Use realistic, idiomatic field names (e.g. title, content, source, publish_time, score, url, id) that match the skill's domain.\n"
- "- Each entry should have enough fields to drive a citation/list/table UI: a title, a short snippet/content, a source, a time/date, and an id or url when relevant.\n"
- "- Numbers stay numbers, dates stay ISO strings, scores between 0 and 1 when applicable.\n"
- "- Do not invent fields that contradict the skill description."
-)
-
-
-def _extract_json_from_llm_output(raw: str) -> dict[str, Any] | None:
- """Best-effort extraction of a JSON object from an LLM response."""
- text = (raw or "").strip()
- if not text:
- return None
- fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL | re.IGNORECASE)
- if fence:
- text = fence.group(1).strip()
- try:
- parsed = json.loads(text)
- return parsed if isinstance(parsed, dict) else None
- except json.JSONDecodeError:
- pass
- match = re.search(r"\{.*\}", text, re.DOTALL)
- if not match:
- return None
- try:
- parsed = json.loads(match.group(0))
- return parsed if isinstance(parsed, dict) else None
- except json.JSONDecodeError:
- return None
-
-
-async def _generate_skill_display_sample(skill: Skill, app_config: AppConfig) -> dict[str, Any]:
- """Generate a realistic return-shape sample for ``skill`` via the LLM.
-
- Reads SKILL.md, asks the configured curator model (or main model) to infer
- a plausible JSON response. Falls back to :func:`_mock_skill_display_sample`
- when the model is unconfigured, the call times out, or the output cannot
- be parsed as a JSON object.
- """
- try:
- skill_content = skill.skill_file.read_text(encoding="utf-8")
- except OSError as exc:
- logger.debug("Cannot read SKILL.md for %s (%s); falling back to description only", skill.name, exc)
- skill_content = ""
- # Cap the SKILL.md body so very large workflow docs don't blow the context.
- max_chars = 6000
- if len(skill_content) > max_chars:
- skill_content = skill_content[:max_chars] + "\n…(truncated)"
-
- user_prompt = (
- f"Skill name: {skill.name}\n"
- f"Short description: {skill.description}\n\n"
- f"SKILL.md:\n-----\n{skill_content or '(SKILL.md unavailable; rely on the short description above.)'}\n-----\n\n"
- "Return one JSON object that matches what this skill would actually produce on success."
- )
-
- try:
- from deerflow.models import create_chat_model
-
- # Reuse the curator/security-scan model setting; falls back to the main model when unset.
- model_name = app_config.skill_evolution.curator.model_name if app_config.skill_evolution and app_config.skill_evolution.curator else None
- model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config)
- response = await asyncio.wait_for(
- model.ainvoke(
- [
- {"role": "system", "content": _SKILL_SAMPLE_SYSTEM_PROMPT},
- {"role": "user", "content": user_prompt},
- ],
- config={"run_name": "skill_display_sample"},
- ),
- timeout=45,
- )
- parsed = _extract_json_from_llm_output(str(getattr(response, "content", "") or ""))
- if parsed:
- return parsed
- logger.warning("Skill display sample model returned non-JSON for %s; falling back to static mock", skill.name)
- except asyncio.TimeoutError:
- logger.warning("Skill display sample LLM call timed out for %s; falling back to static mock", skill.name)
- except Exception:
- logger.warning("Skill display sample LLM call failed for %s; falling back to static mock", skill.name, exc_info=True)
- return _mock_skill_display_sample(skill.name)
-
-
-def _mock_skill_display_sample(skill_name: str) -> dict[str, Any]:
- if skill_name == "knowledge-search-v2":
- return {
- "success": True,
- "query": "latest news",
- "count": 3,
- "results": [
- {
- "index": 1,
- "recUuid": "rec-001",
- "relevance_score": 0.982,
- "title": "Knowledge result one",
- "source_category": "knowledge/news",
- "source": "internal news library",
- "publish_time": "2026-05-25",
- "content_preview": "Mock result used to verify citation numbers, source cards, and recUuid template links.",
- "content_length": 1200,
- },
- {
- "index": 2,
- "recUuid": "rec-002",
- "relevance_score": 0.951,
- "title": "Knowledge result two",
- "source_category": "knowledge/news",
- "source": "internal news library",
- "publish_time": "2026-05-24",
- "content_preview": "Mock result used to verify citation ordering and detail preview.",
- "content_length": 980,
- },
- {
- "index": 3,
- "recUuid": "rec-003",
- "relevance_score": 0.921,
- "title": "Knowledge result three",
- "source_category": "knowledge/commentary",
- "source": "internal knowledge library",
- "publish_time": "2026-05-23",
- "content_preview": "Mock result used to verify list mode and custom display instruction previews.",
- "content_length": 760,
- },
- ],
- }
- return {
- "success": True,
- "query": "sample query",
- "results": [
- {
- "id": "sample-001",
- "title": "Sample result",
- "summary": "Sample structured output used to infer field mapping.",
- "source": "sample",
- "updatedAt": "2026-05-25",
- }
- ],
- }
-
-
-async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[str, dict[str, Any]]:
- """Index visible-to-user skill records by name. Internal call cache for list endpoints."""
- records = await skill_store.list_visible(user_id)
- return {record["name"]: record for record in records}
-
-
-async def _attach_and_filter_tags(
- tag_store: TagStore,
- responses: list[SkillResponse],
- search: str | None,
- tag_ids: list[str] | None,
-) -> list[SkillResponse]:
- """Bulk-attach tags to skill responses, then apply name/tag filters."""
- assignments = await tag_store.list_assignments("skill", [r.name for r in responses])
- for resp in responses:
- resp.tags = assignments.get(resp.name, [])
- if search:
- needle = search.strip().lower()
- responses = [r for r in responses if needle in r.name.lower()]
- if tag_ids:
- wanted = set(tag_ids)
- responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
- return responses
-
-
-@router.get(
- "/skills",
- response_model=SkillsListResponse,
- summary="List All Skills",
- description="Retrieve a list of all available skills from both public and custom directories.",
-)
-async def list_skills(
- request: Request,
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
- tag_store: TagStore = Depends(get_tag_store),
- search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
- tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
-) -> SkillsListResponse:
- try:
- user_id = _current_user_id(request)
- all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
- ownership = await _build_ownership_index(skill_store, user_id)
- responses: list[SkillResponse] = []
- for skill in all_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:
- # 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:
- continue
- responses.append(_skill_to_response(skill, record))
- responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
- return SkillsListResponse(skills=responses)
- except Exception as e:
- logger.error(f"Failed to load skills: {e}", exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
-
-
-_MAX_SKILL_UPLOAD_BYTES = 512 * 1024 * 1024
-_ZIP_MAGIC = b"PK\x03\x04"
-
-
-async def _persist_uploaded_skill(file: UploadFile) -> Path:
- """Stream an uploaded skill archive to a temp file. Caller must unlink it.
-
- Validates the leading bytes look like a ZIP archive and enforces the same
- 512 MB cap that the extractor uses, so we reject obvious garbage early.
- """
- if file.filename is None:
- raise HTTPException(status_code=400, detail="No file provided")
- suffix = Path(file.filename).suffix.lower()
- if suffix not in {".zip", ".skill"}:
- raise HTTPException(status_code=400, detail="Skill upload must be a .zip or .skill file")
-
- fd, tmp_name = tempfile.mkstemp(suffix=".skill", prefix="skill-upload-")
- tmp_path = Path(tmp_name)
- total = 0
- head: bytes = b""
- try:
- with open(fd, "wb") as out:
- while True:
- chunk = await file.read(1024 * 1024)
- if not chunk:
- break
- if not head:
- head = chunk[: len(_ZIP_MAGIC)]
- total += len(chunk)
- if total > _MAX_SKILL_UPLOAD_BYTES:
- raise HTTPException(status_code=400, detail="Uploaded skill archive exceeds the 512 MB limit")
- out.write(chunk)
- except HTTPException:
- tmp_path.unlink(missing_ok=True)
- raise
- except Exception:
- tmp_path.unlink(missing_ok=True)
- raise
-
- if not head.startswith(_ZIP_MAGIC):
- tmp_path.unlink(missing_ok=True)
- raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
- return tmp_path
-
-
-@router.post(
- "/skills/validate",
- response_model=SkillUploadValidateResponse,
- summary="Validate Skill Archive (No Install)",
- description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.",
-)
-async def validate_skill_upload(
- file: UploadFile = File(..., description="The .zip or .skill archive to validate"),
- config: AppConfig = Depends(get_config),
-) -> SkillUploadValidateResponse:
- tmp_path = await _persist_uploaded_skill(file)
- try:
- result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path)
- return SkillUploadValidateResponse(**result)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except FileNotFoundError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to validate uploaded skill: %s", e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to validate skill: {e}")
- finally:
- tmp_path.unlink(missing_ok=True)
-
-
-@router.post(
- "/skills/install-upload",
- response_model=SkillInstallResponse,
- summary="Install Skill from Upload",
- description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
-)
-async def install_skill_upload(
- request: Request,
- file: UploadFile = File(..., description="The .zip or .skill archive to install"),
- overwrite: bool = False,
- target_category: SkillCategory = SkillCategory.CUSTOM,
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
-) -> SkillInstallResponse:
- user_id = _current_user_id(request)
- is_admin = _is_admin_user(request)
- # Only admins may install to the public (built-in) directory.
- if target_category == SkillCategory.PUBLIC and not is_admin:
- raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
- tmp_path = await _persist_uploaded_skill(file)
- try:
- storage = get_or_new_skill_storage(app_config=config)
- # Pre-parse the archive to learn the skill name without writing anything,
- # so we can reject the upload before touching the filesystem if another
- # user already owns this name. Admins bypass the ownership check since
- # they install to the shared public directory.
- try:
- validation = storage.validate_skill_archive(tmp_path)
- preview_name = validation.get("skill_name") if isinstance(validation, dict) else None
- except Exception:
- preview_name = None
- if preview_name and not is_admin:
- existing = await skill_store.get_any(preview_name)
- if existing and existing.get("owner_user_id") not in (None, user_id):
- raise HTTPException(
- status_code=409,
- detail=f"Skill '{preview_name}' already exists and is owned by another user",
- )
- result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category)
- try:
- from deerflow.skills.usage import mark_uploaded
- mark_uploaded(result["skill_name"])
- except Exception:
- pass
- # For public installs (admin), register as built-in with no owner.
- # For custom installs, register ownership under the uploading user.
- if target_category == SkillCategory.PUBLIC:
- await skill_store.ensure_legacy(
- {"name": result["skill_name"], "owner_user_id": None, "published": True}
- )
- else:
- # ``ensure_legacy`` is idempotent and only patches a NULL owner; it
- # never overwrites an existing owner, so the pre-parse check above
- # is what really guards cross-user steals.
- await skill_store.ensure_legacy(
- {"name": result["skill_name"], "owner_user_id": user_id, "published": False}
- )
- await refresh_skills_system_prompt_cache_async()
- return SkillInstallResponse(**result)
- except FileNotFoundError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except SkillAlreadyExistsError as e:
- raise HTTPException(status_code=409, detail=str(e))
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to install uploaded skill: %s", e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to install skill: {e}")
- finally:
- tmp_path.unlink(missing_ok=True)
-
-
-@router.post(
- "/skills/install",
- response_model=SkillInstallResponse,
- summary="Install Skill",
- description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
-)
-async def install_skill(
- body: SkillInstallRequest,
- request: Request,
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
-) -> SkillInstallResponse:
- try:
- user_id = _current_user_id(request)
- is_admin = _is_admin_user(request)
- target_category = body.target_category
- if target_category == SkillCategory.PUBLIC and not is_admin:
- raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
- skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path)
- result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
- try:
- from deerflow.skills.usage import mark_uploaded
- mark_uploaded(result["skill_name"])
- except Exception:
- pass
- if target_category == SkillCategory.PUBLIC:
- await skill_store.ensure_legacy(
- {"name": result["skill_name"], "owner_user_id": None, "published": True}
- )
- else:
- await skill_store.ensure_legacy(
- {"name": result["skill_name"], "owner_user_id": user_id, "published": False}
- )
- await refresh_skills_system_prompt_cache_async()
- return SkillInstallResponse(**result)
- except FileNotFoundError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except SkillAlreadyExistsError as e:
- raise HTTPException(status_code=409, detail=str(e))
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Failed to install skill: {e}", exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
-
-
-@router.post(
- "/skills/reconcile-custom",
- response_model=SkillReconcileResponse,
- summary="Adopt Orphan Custom Skills",
- description=(
- "Scan the on-disk ``skills/custom/`` directory and register any skill that has a "
- "valid SKILL.md but no DB ownership row. Adopted skills are recorded as owned by "
- "the calling user with ``published=false``. Skips skills that already have a row "
- "(regardless of owner). Useful after manually unzipping skills onto the filesystem."
- ),
-)
-async def reconcile_custom_skills(
- request: Request,
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
-) -> SkillReconcileResponse:
- try:
- user_id = _current_user_id(request)
- storage = get_or_new_skill_storage(app_config=config)
- custom_skills = [s for s in storage.load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
-
- adopted: list[str] = []
- already: list[str] = []
- for skill in custom_skills:
- existing = await skill_store.get_any(skill.name)
- if existing is not None:
- already.append(skill.name)
- continue
- await skill_store.ensure_legacy(
- {"name": skill.name, "owner_user_id": user_id, "published": False}
- )
- try:
- from deerflow.skills.usage import mark_uploaded
- mark_uploaded(skill.name)
- except Exception:
- pass
- adopted.append(skill.name)
-
- if adopted:
- await refresh_skills_system_prompt_cache_async()
- return SkillReconcileResponse(adopted=adopted, already_registered=already)
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to reconcile custom skills: %s", e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to reconcile custom skills: {e}")
-
-
-@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
-async def list_custom_skills(
- request: Request,
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
-) -> SkillsListResponse:
- try:
- user_id = _current_user_id(request)
- custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
- ownership = await _build_ownership_index(skill_store, user_id)
- responses = [
- _skill_to_response(skill, ownership[skill.name])
- for skill in custom
- if skill.name in ownership
- ]
- return SkillsListResponse(skills=responses)
- except Exception as e:
- logger.error("Failed to list custom skills: %s", e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}")
-
-
-@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
-async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
- try:
- skill_name = skill_name.replace("\r\n", "").replace("\n", "")
- 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 and s.category == SkillCategory.CUSTOM), None)
- if skill is None:
- raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
- return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=get_or_new_skill_storage(app_config=config).read_custom_skill(skill_name))
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to get custom skill %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to get custom skill: {str(e)}")
-
-
-@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
-async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
- try:
- skill_name = skill_name.replace("\r\n", "").replace("\n", "")
- storage = get_or_new_skill_storage(app_config=config)
- storage.ensure_custom_skill_is_editable(skill_name)
- storage.validate_skill_markdown_content(skill_name, request.content)
- scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
- if scan.decision == "block":
- raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
- prev_content = storage.read_custom_skill(skill_name)
- storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content)
- storage.append_history(
- skill_name,
- {
- "action": "human_edit",
- "author": "human",
- "thread_id": None,
- "file_path": SKILL_MD_FILE,
- "prev_content": prev_content,
- "new_content": request.content,
- "scanner": {"decision": scan.decision, "reason": scan.reason},
- },
- )
- try:
- from deerflow.skills.usage import bump_patch
- bump_patch(skill_name)
- except Exception:
- pass
- await refresh_skills_system_prompt_cache_async()
- return await get_custom_skill(skill_name, config)
- except HTTPException:
- raise
- except FileNotFoundError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- logger.error("Failed to update custom skill %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to update custom skill: {str(e)}")
-
-
-@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
-async def delete_custom_skill(
- skill_name: str,
- request: Request,
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
- agent_store=Depends(get_agent_store),
-) -> dict[str, bool]:
- try:
- skill_name = skill_name.replace("\r\n", "").replace("\n", "")
- user_id = _current_user_id(request)
- is_admin = _is_admin_user(request)
- record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
- operation = "admin_takedown" if mode == "admin" and record.get("owner_user_id") not in (None, user_id) else "delete"
-
- storage = get_or_new_skill_storage(app_config=config)
- storage.delete_custom_skill(
- skill_name,
- history_meta={
- "action": "human_delete",
- "author": "human",
- "thread_id": None,
- "file_path": SKILL_MD_FILE,
- "prev_content": None,
- "new_content": None,
- "scanner": {"decision": "allow", "reason": "Deletion requested."},
- },
- )
- try:
- from deerflow.skills.usage import forget
- forget(skill_name)
- except Exception:
- pass
-
- # Persist ownership record removal so the DB stays consistent.
- if mode == "admin":
- await skill_store.delete_admin(skill_name)
- else:
- await skill_store.delete(skill_name, user_id)
-
- try:
- await get_tag_store(request).unassign_all("skill", skill_name)
- except Exception: # noqa: BLE001 — tag cleanup must not block deletion
- pass
-
- # Strip references from every agent that pulled this skill in,
- # and notify their owners (no-op for self).
- await cascade_skill_unpublished_or_deleted(
- skill_name,
- actor_user_id=user_id,
- operation=operation,
- skill_owner_user_id=record.get("owner_user_id"),
- agent_store=agent_store,
- )
-
- await refresh_skills_system_prompt_cache_async()
- return {"success": True}
- except HTTPException:
- raise
- except FileNotFoundError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- logger.error("Failed to delete custom skill %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
-
-
-class SkillPublishRequest(BaseModel):
- """Request body for toggling a custom skill's published flag."""
-
- published: bool = Field(..., description="Whether to publish (True) or unpublish (False)")
-
-
-class SkillPublishResponse(BaseModel):
- name: str
- owner_user_id: str | None
- published: bool
-
-
-@router.put(
- "/skills/custom/{skill_name}/published",
- response_model=SkillPublishResponse,
- summary="Toggle Custom Skill Published State",
-)
-async def update_custom_skill_published(
- skill_name: str,
- body: SkillPublishRequest,
- request: Request,
- skill_store: SkillStore = Depends(get_skill_store),
- agent_store=Depends(get_agent_store),
-) -> SkillPublishResponse:
- try:
- skill_name = skill_name.replace("\r\n", "").replace("\n", "")
- user_id = _current_user_id(request)
- is_admin = _is_admin_user(request)
- record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
-
- was_published = bool(record.get("published", False))
- if mode == "admin":
- updated = await skill_store.update_admin(skill_name, {"published": body.published})
- else:
- updated = await skill_store.update(skill_name, user_id, {"published": body.published})
- if updated is None:
- raise HTTPException(status_code=500, detail="Failed to persist published state")
-
- # If we are *removing* visibility (was published, now not), strip from
- # other users' agents and notify them. admin takedown is signaled by
- # the actor not being the owner.
- if was_published and not body.published:
- skill_owner = record.get("owner_user_id")
- operation = "admin_takedown" if mode == "admin" and skill_owner not in (None, user_id) else "unpublish"
- await cascade_skill_unpublished_or_deleted(
- skill_name,
- actor_user_id=user_id,
- operation=operation,
- skill_owner_user_id=skill_owner,
- agent_store=agent_store,
- )
- await refresh_skills_system_prompt_cache_async()
-
- return SkillPublishResponse(
- name=updated["name"],
- owner_user_id=updated.get("owner_user_id"),
- published=bool(updated.get("published", False)),
- )
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to update published state for %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to update published state: {str(e)}")
-
-
-_ALLOWED_USER_STATES = {"active", "archived"}
-
-
-class SkillStateRequest(BaseModel):
- """Request body for switching a custom skill's lifecycle state."""
-
- state: str = Field(..., description="New state: 'active' (restore) or 'archived' (soft archive)")
-
-
-class SkillPinRequest(BaseModel):
- """Request body for pinning / unpinning a custom skill."""
-
- pinned: bool = Field(..., description="Whether to pin (true) so the curator never auto-archives this skill")
-
-
-def _reload_skill_response(skill_name: str, ownership: dict[str, Any], config: AppConfig) -> SkillResponse:
- """Reload a single skill from disk and return its API response form."""
- 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 on disk")
- return _skill_to_response(skill, ownership)
-
-
-@router.put(
- "/skills/custom/{skill_name}/state",
- response_model=SkillResponse,
- summary="Update Custom Skill Lifecycle State",
- description="Restore (active) or soft-archive a custom skill. Only the owner or admins may invoke.",
-)
-async def update_custom_skill_state(
- skill_name: str,
- body: SkillStateRequest,
- 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", "")
- if body.state not in _ALLOWED_USER_STATES:
- raise HTTPException(
- status_code=400,
- detail=f"state must be one of {sorted(_ALLOWED_USER_STATES)}",
- )
- user_id = _current_user_id(request)
- is_admin = _is_admin_user(request)
- record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
-
- from deerflow.skills.usage import set_state as _usage_set_state
-
- _usage_set_state(skill_name, body.state)
- await refresh_skills_system_prompt_cache_async()
- return _reload_skill_response(skill_name, record, config)
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to update state for %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to update state: {e}")
-
-
-@router.put(
- "/skills/custom/{skill_name}/pinned",
- response_model=SkillResponse,
- summary="Pin or Unpin a Custom Skill",
- description="Pin a skill so the curator never auto-archives it. Only the owner or admins may invoke.",
-)
-async def update_custom_skill_pinned(
- skill_name: str,
- body: SkillPinRequest,
- 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", "")
- user_id = _current_user_id(request)
- is_admin = _is_admin_user(request)
- record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
-
- from deerflow.skills.usage import set_pinned as _usage_set_pinned
-
- _usage_set_pinned(skill_name, body.pinned)
- return _reload_skill_response(skill_name, record, config)
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to update pinned for %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}")
-
-
-@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
-async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
- try:
- skill_name = skill_name.replace("\r\n", "").replace("\n", "")
- storage = get_or_new_skill_storage(app_config=config)
- if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
- raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
- return CustomSkillHistoryResponse(history=storage.read_history(skill_name))
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to read history for %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to read history: {str(e)}")
-
-
-@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
-async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
- try:
- storage = get_or_new_skill_storage(app_config=config)
- if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
- raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
- history = storage.read_history(skill_name)
- if not history:
- raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
- record = history[request.history_index]
- target_content = record.get("prev_content")
- if target_content is None:
- raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
- storage.validate_skill_markdown_content(skill_name, target_content)
- scan = await scan_skill_content(target_content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
- skill_file = storage.get_custom_skill_file(skill_name)
- current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None
- history_entry = {
- "action": "rollback",
- "author": "human",
- "thread_id": None,
- "file_path": SKILL_MD_FILE,
- "prev_content": current_content,
- "new_content": target_content,
- "rollback_from_ts": record.get("ts"),
- "scanner": {"decision": scan.decision, "reason": scan.reason},
- }
- if scan.decision == "block":
- storage.append_history(skill_name, history_entry)
- raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
- storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
- storage.append_history(skill_name, history_entry)
- await refresh_skills_system_prompt_cache_async()
- return await get_custom_skill(skill_name, config)
- except HTTPException:
- raise
- except IndexError:
- raise HTTPException(status_code=400, detail="history_index is out of range")
- except FileNotFoundError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except Exception as e:
- logger.error("Failed to roll back custom skill %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to roll back custom skill: {str(e)}")
-
-
-@router.get(
- "/skills/{skill_name}",
- response_model=SkillResponse,
- summary="Get Skill Details",
- description="Retrieve detailed information about a specific skill by its name.",
-)
-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)
- 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")
-
- # 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:
- logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
-
-
-class SkillContentResponse(BaseModel):
- """SKILL.md 原文内容,适用于公共和自定义技能。"""
-
- name: str
- category: SkillCategory
- content: str
-
-
-@router.get(
- "/skills/{skill_name}/content",
- response_model=SkillContentResponse,
- summary="Get Skill Markdown Content",
- description="Retrieve the raw SKILL.md content of a skill. Only an admin or the skill's publisher (owner of a custom skill) may read it; other users see only the description and detail.",
-)
-async def get_skill_content(
- skill_name: str,
- request: Request,
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
-) -> SkillContentResponse:
- try:
- skill_name = skill_name.replace("\r\n", "").replace("\n", "")
- 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")
- # The raw SKILL.md is implementation detail — only an admin or the
- # publisher (owner of a custom skill) may view it. Everyone else is
- # limited to the description + detail surfaced by the list/detail APIs.
- record = await skill_store.get_any(skill_name)
- owner_user_id = record.get("owner_user_id") if record else None
- if not _can_view_skill_source(skill.category, owner_user_id, _current_user_id(request), is_admin=_is_admin_user(request)):
- raise HTTPException(status_code=403, detail="Only the skill publisher or an admin can view the SKILL.md source")
- content = skill.skill_file.read_text(encoding="utf-8")
- return SkillContentResponse(name=skill.name, category=skill.category, content=content)
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to get skill content %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}")
-
-
-# ---------------------------------------------------------------------------
-# Skill files browser (tree + per-file read / write)
-# ---------------------------------------------------------------------------
-
-
-_SKILL_FILE_TEXT_EXTS = {
- ".md", ".markdown", ".txt", ".rst",
- ".py", ".pyi",
- ".json", ".jsonl",
- ".yaml", ".yml",
- ".toml", ".ini", ".cfg", ".env",
- ".sh", ".bash", ".zsh", ".ps1",
- ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
- ".html", ".htm", ".css", ".scss", ".sass", ".xml", ".svg",
- ".csv", ".tsv",
- ".sql",
-}
-_SKILL_FILE_TREE_SKIP_NAMES = {
- "__pycache__", "node_modules", ".git", ".DS_Store", ".idea", ".vscode",
-}
-_SKILL_FILE_MAX_NODES = 2000
-_SKILL_FILE_MAX_DEPTH = 8
-_SKILL_FILE_READ_BYTES = 2 * 1024 * 1024 # 2 MiB cap on JSON content payload
-_SKILL_FILE_WRITE_BYTES = 1 * 1024 * 1024 # 1 MiB cap on edits
-
-
-class SkillFileNode(BaseModel):
- """One entry in a skill's file tree.
-
- ``children`` is only set for directories. ``path`` is POSIX-style and is
- always relative to the skill's own directory (never absolute).
- """
-
- name: str = Field(..., description="Base name (no slashes)")
- type: str = Field(..., description="'file' or 'dir'")
- path: str = Field(..., description="Path relative to the skill directory, POSIX-separated")
- size: int | None = Field(default=None, description="File size in bytes (files only)")
- mtime: float | None = Field(default=None, description="Modification time as POSIX seconds (files only)")
- children: list["SkillFileNode"] | None = Field(default=None, description="Child entries (directories only)")
-
-
-SkillFileNode.model_rebuild()
-
-
-class SkillFilesResponse(BaseModel):
- """Tree of files under a skill directory."""
-
- name: str
- category: SkillCategory
- editable: bool = Field(..., description="True when the caller may PUT new content into any file under this tree")
- root: SkillFileNode
-
-
-class SkillFileContentResponse(BaseModel):
- """JSON payload for reading a single file inside a skill."""
-
- name: str
- category: SkillCategory
- path: str
- mime: str
- size: int
- mtime: float
- is_text: bool = Field(..., description="True when ``content`` carries the decoded text; otherwise download via the binary endpoint")
- editable: bool = Field(..., description="True when the caller may PUT new content for this file")
- content: str | None = Field(default=None, description="Decoded text content when ``is_text`` is True")
-
-
-class SkillFileSaveRequest(BaseModel):
- """Request body for writing a text file inside a skill."""
-
- path: str = Field(..., description="File path relative to the skill directory, POSIX-separated")
- content: str = Field(..., description="Replacement text content")
-
-
-class SkillFileSaveResponse(BaseModel):
- name: str
- path: str
- size: int
- mtime: float
-
-
-def _normalize_skill_name_param(skill_name: str) -> str:
- """Strip CR/LF the same way other skill routes do before lookup."""
- return skill_name.replace("\r\n", "").replace("\n", "")
-
-
-def _load_skill_or_404(skill_name: str, config: AppConfig) -> Skill:
- 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")
- return skill
-
-
-async def _ensure_files_view_allowed(
- skill: Skill,
- request: Request,
- skill_store: SkillStore,
-) -> dict[str, Any] | None:
- """Mirror _can_view_skill_source: admins or the publisher (custom owner)."""
- record = await skill_store.get_any(skill.name)
- owner_user_id = record.get("owner_user_id") if record else None
- user_id = _current_user_id(request)
- if not _can_view_skill_source(skill.category, owner_user_id, user_id, is_admin=_is_admin_user(request)):
- raise HTTPException(status_code=403, detail="Only the skill publisher or an admin can browse skill files")
- return record
-
-
-def _caller_can_edit_skill_files(
- skill: Skill,
- record: dict[str, Any] | None,
- request: Request,
-) -> bool:
- """Editing is limited to custom skills, and to either owner or admin."""
- if skill.category != SkillCategory.CUSTOM:
- return False
- if _is_admin_user(request):
- return True
- if record is None:
- return False
- return record.get("owner_user_id") == _current_user_id(request)
-
-
-def _resolve_skill_relative_path(skill: Skill, relative_path: str) -> Path:
- """Validate *relative_path* against the skill directory and return the absolute Path.
-
- Rejects empty, absolute, parent-traversing, or out-of-tree paths so a
- malicious caller can never escape the skill's own directory.
- """
- if not relative_path or relative_path.strip() == "":
- raise HTTPException(status_code=400, detail="path must not be empty")
- candidate = Path(relative_path)
- if candidate.is_absolute() or any(part in {"..", ""} for part in candidate.parts):
- raise HTTPException(status_code=400, detail="path must be a relative path without '..' segments")
- skill_dir = skill.skill_file.parent.resolve()
- target = (skill_dir / candidate).resolve()
- try:
- target.relative_to(skill_dir)
- except ValueError:
- raise HTTPException(status_code=400, detail="path must stay inside the skill directory")
- return target
-
-
-def _file_mime(path: Path) -> str:
- """Best-effort MIME guess; defaults to ``application/octet-stream``."""
- guessed, _ = mimetypes.guess_type(path.name)
- if guessed:
- return guessed
- if path.suffix.lower() in _SKILL_FILE_TEXT_EXTS:
- return "text/plain"
- return "application/octet-stream"
-
-
-def _is_text_file(path: Path) -> bool:
- """Treat known text extensions as text; everything else as binary."""
- return path.suffix.lower() in _SKILL_FILE_TEXT_EXTS
-
-
-def _build_skill_file_tree(skill_dir: Path) -> SkillFileNode:
- """Walk *skill_dir* and build a SkillFileNode tree, depth/count capped.
-
- Hidden files (``.foo``) and a small block-list of noise directories
- (``__pycache__`` etc.) are skipped so the UI does not drown in housekeeping
- artefacts.
- """
- counter = {"n": 0}
-
- def build(current_dir: Path, depth: int) -> SkillFileNode:
- rel = current_dir.relative_to(skill_dir).as_posix()
- node = SkillFileNode(
- name=current_dir.name if rel != "." else skill_dir.name,
- type="dir",
- path="" if rel == "." else rel,
- children=[],
- )
- if depth >= _SKILL_FILE_MAX_DEPTH:
- return node
- try:
- entries = sorted(current_dir.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
- except OSError:
- return node
- for entry in entries:
- if counter["n"] >= _SKILL_FILE_MAX_NODES:
- break
- if entry.name.startswith(".") or entry.name in _SKILL_FILE_TREE_SKIP_NAMES:
- continue
- counter["n"] += 1
- if entry.is_dir():
- node.children.append(build(entry, depth + 1))
- continue
- try:
- stat = entry.stat()
- size = stat.st_size
- mtime = stat.st_mtime
- except OSError:
- size = 0
- mtime = 0.0
- node.children.append(
- SkillFileNode(
- name=entry.name,
- type="file",
- path=entry.relative_to(skill_dir).as_posix(),
- size=size,
- mtime=mtime,
- )
- )
- return node
-
- return build(skill_dir, 0)
-
-
-@router.get(
- "/skills/{skill_name}/files",
- response_model=SkillFilesResponse,
- summary="List Skill Files (Tree)",
- description=(
- "Return the full file tree under a skill directory. Only an admin or the "
- "skill's publisher (owner of a custom skill) may browse files; other users "
- "get 403."
- ),
-)
-async def list_skill_files(
- skill_name: str,
- request: Request,
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
-) -> SkillFilesResponse:
- try:
- skill_name = _normalize_skill_name_param(skill_name)
- skill = _load_skill_or_404(skill_name, config)
- record = await _ensure_files_view_allowed(skill, request, skill_store)
- skill_dir = skill.skill_file.parent
- root = _build_skill_file_tree(skill_dir)
- return SkillFilesResponse(
- name=skill.name,
- category=skill.category,
- editable=_caller_can_edit_skill_files(skill, record, request),
- root=root,
- )
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to list skill files for %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to list skill files: {e}")
-
-
-@router.get(
- "/skills/{skill_name}/files/content",
- response_model=SkillFileContentResponse,
- summary="Read Skill File (Text)",
- description=(
- "Return JSON metadata for a single file under a skill. Text files include "
- "their decoded ``content``; binary files omit ``content`` and should be "
- "fetched from the ``files/download`` endpoint for inline display or "
- "download. Same view authorisation as the file tree endpoint."
- ),
-)
-async def get_skill_file_content(
- skill_name: str,
- request: Request,
- path: str = Query(..., description="File path relative to the skill directory"),
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
-) -> SkillFileContentResponse:
- try:
- skill_name = _normalize_skill_name_param(skill_name)
- skill = _load_skill_or_404(skill_name, config)
- record = await _ensure_files_view_allowed(skill, request, skill_store)
- target = _resolve_skill_relative_path(skill, path)
- if not target.exists() or not target.is_file():
- raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'")
- stat = target.stat()
- mime = _file_mime(target)
- is_text = _is_text_file(target)
- content: str | None = None
- if is_text:
- if stat.st_size > _SKILL_FILE_READ_BYTES:
- raise HTTPException(status_code=413, detail=f"File exceeds the {_SKILL_FILE_READ_BYTES // 1024} KiB preview limit")
- try:
- content = target.read_text(encoding="utf-8")
- except UnicodeDecodeError:
- # File has a text-y extension but is not UTF-8 — treat as binary.
- is_text = False
- return SkillFileContentResponse(
- name=skill.name,
- category=skill.category,
- path=target.relative_to(skill.skill_file.parent.resolve()).as_posix(),
- mime=mime,
- size=stat.st_size,
- mtime=stat.st_mtime,
- is_text=is_text,
- editable=is_text and _caller_can_edit_skill_files(skill, record, request),
- content=content,
- )
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to read skill file %s/%s: %s", skill_name, path, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to read skill file: {e}")
-
-
-@router.get(
- "/skills/{skill_name}/files/download",
- summary="Download Skill File (Raw Bytes)",
- description=(
- "Stream the raw bytes of a single file under a skill, suitable for "
- "embedding in an ``
![]()
`` tag or for direct download. Same view "
- "authorisation as the file tree endpoint."
- ),
-)
-async def download_skill_file(
- skill_name: str,
- request: Request,
- path: str = Query(..., description="File path relative to the skill directory"),
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
-):
- skill_name = _normalize_skill_name_param(skill_name)
- skill = _load_skill_or_404(skill_name, config)
- await _ensure_files_view_allowed(skill, request, skill_store)
- target = _resolve_skill_relative_path(skill, path)
- if not target.exists() or not target.is_file():
- raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'")
- return FileResponse(
- path=target,
- media_type=_file_mime(target),
- filename=target.name,
- )
-
-
-@router.put(
- "/skills/{skill_name}/files/content",
- response_model=SkillFileSaveResponse,
- summary="Write Skill File (Text)",
- description=(
- "Replace the contents of a text file under a custom skill. Only the "
- "skill's owner or an admin may write; built-in (public) skills are "
- "read-only. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
- "refresh the system-prompt cache."
- ),
-)
-async def save_skill_file(
- skill_name: str,
- body: SkillFileSaveRequest,
- request: Request,
- config: AppConfig = Depends(get_config),
- skill_store: SkillStore = Depends(get_skill_store),
-) -> SkillFileSaveResponse:
- try:
- skill_name = _normalize_skill_name_param(skill_name)
- skill = _load_skill_or_404(skill_name, config)
- if skill.category != SkillCategory.CUSTOM:
- raise HTTPException(status_code=403, detail="Built-in skills are read-only")
- record = await skill_store.get_any(skill.name)
- if not _caller_can_edit_skill_files(skill, record, request):
- raise HTTPException(status_code=403, detail="Only the skill owner or an admin can edit files in this skill")
-
- target = _resolve_skill_relative_path(skill, body.path)
- if not target.exists():
- raise HTTPException(status_code=404, detail=f"File '{body.path}' not found in skill '{skill_name}'")
- if target.is_dir():
- raise HTTPException(status_code=400, detail="path points to a directory")
- if not _is_text_file(target):
- raise HTTPException(status_code=400, detail=f"File type '{target.suffix or target.name}' is not editable")
-
- if len(body.content.encode("utf-8")) > _SKILL_FILE_WRITE_BYTES:
- raise HTTPException(status_code=413, detail=f"Content exceeds the {_SKILL_FILE_WRITE_BYTES // 1024} KiB edit limit")
-
- relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix()
- storage = get_or_new_skill_storage(app_config=config)
-
- is_skill_md = relative_posix == SKILL_MD_FILE
- prev_content: str | None = None
- if target.exists():
- try:
- prev_content = target.read_text(encoding="utf-8")
- except UnicodeDecodeError:
- prev_content = None
-
- if is_skill_md:
- # SKILL.md edits must keep their frontmatter valid (name must match).
- storage.validate_skill_markdown_content(skill.name, body.content)
-
- storage.write_custom_skill(skill.name, relative_posix, body.content)
-
- try:
- storage.append_history(
- skill.name,
- {
- "action": "human_edit_file",
- "author": "human",
- "thread_id": None,
- "file_path": relative_posix,
- "prev_content": prev_content,
- "new_content": body.content,
- },
- )
- except Exception as history_err: # noqa: BLE001 — history is best-effort
- logger.warning("Failed to append edit history for %s/%s: %s", skill.name, relative_posix, history_err)
-
- if is_skill_md:
- try:
- from deerflow.skills.usage import bump_patch
- bump_patch(skill.name)
- except Exception:
- pass
- await refresh_skills_system_prompt_cache_async()
-
- stat = target.stat()
- return SkillFileSaveResponse(
- name=skill.name,
- path=relative_posix,
- size=stat.st_size,
- mtime=stat.st_mtime,
- )
- except HTTPException:
- raise
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- except FileNotFoundError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except Exception as e:
- logger.error("Failed to save skill file %s/%s: %s", skill_name, body.path, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to save skill file: {e}")
-
-
-@router.put(
- "/skills/{skill_name}",
- response_model=SkillResponse,
- summary="Update Skill",
- description="Update a skill's enabled status by modifying the extensions_config.json file.",
-)
-async def update_skill(skill_name: str, request: SkillUpdateRequest, config: AppConfig = Depends(get_config)) -> 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)
- 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")
-
- config_path = ExtensionsConfig.resolve_config_path()
- if config_path is None:
- config_path = Path.cwd().parent / "extensions_config.json"
- logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
-
- extensions_config = get_extensions_config()
- current_skill_config = extensions_config.skills.get(skill_name)
- extensions_config.skills[skill_name] = SkillStateConfig(
- enabled=request.enabled,
- display=current_skill_config.display if current_skill_config else None,
- )
-
- config_data = _dump_extensions_config(extensions_config)
-
- with open(config_path, "w", encoding="utf-8") as f:
- json.dump(config_data, f, indent=2)
-
- logger.info(f"Skills configuration updated and saved to: {config_path}")
- reload_extensions_config()
- await refresh_skills_system_prompt_cache_async()
-
- skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
- updated_skill = next((s for s in skills if s.name == skill_name), None)
-
- if updated_skill is None:
- raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
-
- logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}")
- return _skill_to_response(updated_skill)
-
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
-
-
-@router.put(
- "/skills/{skill_name}/display",
- response_model=SkillResponse,
- summary="Update Skill Display Adapter",
- description="Update a skill's frontend display adapter in extensions_config.json.",
-)
-async def update_skill_display(
- skill_name: str,
- request: SkillDisplayUpdateRequest,
- config: AppConfig = Depends(get_config),
-) -> 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)
- 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")
-
- config_path = ExtensionsConfig.resolve_config_path()
- if config_path is None:
- config_path = Path.cwd().parent / "extensions_config.json"
- logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
-
- extensions_config = get_extensions_config()
- current_skill_config = extensions_config.skills.get(skill_name)
- extensions_config.skills[skill_name] = SkillStateConfig(
- enabled=current_skill_config.enabled if current_skill_config else skill.enabled,
- display=request.display,
- )
-
- with open(config_path, "w", encoding="utf-8") as f:
- json.dump(_dump_extensions_config(extensions_config), f, indent=2, ensure_ascii=False)
-
- reload_extensions_config()
- await refresh_skills_system_prompt_cache_async()
- skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
- updated_skill = next((s for s in skills if s.name == skill_name), None)
- if updated_skill is None:
- raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update")
- return _skill_to_response(updated_skill)
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to update display adapter for %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to update skill display adapter: {str(e)}")
-
-
-@router.post(
- "/skills/{skill_name}/display-sample",
- response_model=SkillDisplaySampleResponse,
- summary="Get Skill Display Sample",
- description="Return a structured sample response used by the frontend to infer display mapping.",
-)
-async def get_skill_display_sample(
- skill_name: str,
- config: AppConfig = Depends(get_config),
-) -> SkillDisplaySampleResponse:
- try:
- skill_name = skill_name.replace("\r\n", "").replace("\n", "")
- 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")
- sample = await _generate_skill_display_sample(skill, config)
- return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample)
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to get display sample for %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to get display sample: {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")
-
-
-@router.post("/skills/custom/{skill_name}/pin", response_model=SkillResponse, summary="Pin or Unpin Skill")
-async def pin_skill(skill_name: str, request: SkillPinRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
- """Pin a skill to prevent it from being automatically archived by the curator."""
- try:
- skill_name = skill_name.replace("\r\n", "").replace("\n", "")
- storage = get_or_new_skill_storage(app_config=config)
- if not storage.custom_skill_exists(skill_name):
- raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
- from deerflow.skills.usage import set_pinned
- set_pinned(skill_name, request.pinned)
- skills = storage.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")
- return _skill_to_response(skill)
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to pin skill %s: %s", skill_name, e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to pin skill: {str(e)}")
-
-
-@router.get("/curator/status", summary="Curator Status")
-async def curator_status(request: Request) -> dict:
- """Return the current curator state and per-lifecycle skill counts for the
- authenticated user."""
- try:
- from deerflow.skills.curator import get_curator_status
- return get_curator_status(_current_user_id(request))
- except Exception as e:
- logger.error("Failed to get curator status: %s", e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to get curator status: {str(e)}")
-
-
-@router.post("/curator/run", summary="Trigger Curator Run")
-async def run_curator(request: Request) -> dict:
- """Manually trigger a curator review pass for the authenticated user
- (bypasses the interval check, runs in a background thread).
-
- Pre-checks the master switch / curator switch / run lock so the response
- accurately reflects whether a run actually started — instead of always
- reporting success. Returns ``{"triggered": bool, "reason": str | None}``.
- """
- try:
- import threading
-
- from deerflow.skills.curator import _curator_thread_target, manual_trigger_blocked_reason
-
- user_id = _current_user_id(request)
- reason = manual_trigger_blocked_reason(user_id)
- if reason is not None:
- return {"triggered": False, "reason": reason}
- thread = threading.Thread(
- target=_curator_thread_target,
- args=(user_id,),
- daemon=True,
- name=f"skill-curator-manual-{user_id}",
- )
- thread.start()
- return {"triggered": True, "reason": None}
- except Exception as e:
- logger.error("Failed to trigger curator: %s", e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to trigger curator: {str(e)}")
-
-
-class CuratorSubConfig(BaseModel):
- """Nested curator configuration mirroring CuratorConfig."""
-
- # 与 CuratorConfig.enabled 保持一致:默认关闭,需显式 opt-in。
- enabled: bool = Field(default=False, description="Whether the curator runs automatically.")
- interval_hours: int = Field(default=168, ge=1, description="Hours between curator runs.")
- stale_after_days: int = Field(default=30, ge=1, description="Days of inactivity before a skill is marked stale.")
- archive_after_days: int = Field(default=90, ge=1, description="Days of inactivity before a stale skill is archived.")
- review_timeout_seconds: int = Field(default=600, ge=1, description="Overall timeout (seconds) for the LLM review pass.")
- model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass; null = primary model.")
-
-
-class CuratorConfigResponse(BaseModel):
- """Response model exposing the effective skill_evolution config."""
-
- enabled: bool
- curator: CuratorSubConfig
-
-
-class CuratorConfigRequest(BaseModel):
- """Payload for updating skill_evolution overrides.
-
- All fields are required so the frontend always sends a complete config;
- backend writes the entire payload to skill_evolution.json atomically.
- """
-
- enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.")
- curator: CuratorSubConfig
-
-
-class CuratorLogRecord(BaseModel):
- ts: str
- level: str
- message: str
-
-
-class CuratorLogsResponse(BaseModel):
- records: list[CuratorLogRecord]
- run_count: int
- last_run_at: str | None
-
-
-def _build_curator_config_response(evo) -> CuratorConfigResponse:
- """Pure helper so both the GET route and the post-PUT refresh share one shape.
-
- ``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user
- overrides if any).
- """
- return CuratorConfigResponse(
- enabled=evo.enabled,
- curator=CuratorSubConfig(
- enabled=evo.curator.enabled,
- interval_hours=evo.curator.interval_hours,
- stale_after_days=evo.curator.stale_after_days,
- archive_after_days=evo.curator.archive_after_days,
- review_timeout_seconds=evo.curator.review_timeout_seconds,
- model_name=evo.curator.model_name,
- ),
- )
-
-
-@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config")
-async def get_curator_config(request: Request) -> CuratorConfigResponse:
- """Return the current curator config for the authenticated user (yaml
- defaults + that user's overrides)."""
- try:
- from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
-
- evo = load_skill_evolution_config_for_user(_current_user_id(request))
- return _build_curator_config_response(evo)
- except Exception as e:
- logger.error("Failed to read curator config: %s", e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to read curator config: {str(e)}")
-
-
-@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config")
-async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse:
- """Persist per-user overrides to ``users/{user_id}/skill_evolution.json``
- and return the effective merged config.
-
- The payload is validated against the ``SkillEvolutionConfig`` shape; unknown
- fields are rejected.
- """
- try:
- from deerflow.config.skill_evolution_config import SkillEvolutionConfig
- from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides
-
- user_id = _current_user_id(request)
- payload = body.model_dump()
- # Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid.
- SkillEvolutionConfig.model_validate(payload)
- save_user_overrides(user_id, payload)
- return _build_curator_config_response(load_skill_evolution_config_for_user(user_id))
- except HTTPException:
- raise
- except Exception as e:
- logger.error("Failed to update curator config: %s", e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to update curator config: {str(e)}")
-
-
-@router.get("/curator/logs", response_model=CuratorLogsResponse, summary="Recent Curator Logs")
-async def get_curator_logs(request: Request) -> CuratorLogsResponse:
- """Return in-memory log records from the most recent curator run for the
- authenticated user.
-
- The buffer is process-local and cleared when curator starts a new run or
- when the backend restarts. Records are ordered oldest → newest.
- """
- try:
- from deerflow.skills import curator_log
- from deerflow.skills.curator import get_curator_status
-
- user_id = _current_user_id(request)
- records = curator_log.get_recent(user_id)
- status = get_curator_status(user_id)
- return CuratorLogsResponse(
- records=[CuratorLogRecord(**r) for r in records],
- run_count=int(status.get("run_count", 0)),
- last_run_at=status.get("last_run_at"),
- )
- except Exception as e:
- logger.error("Failed to read curator logs: %s", e, exc_info=True)
- raise HTTPException(status_code=500, detail=f"Failed to read curator logs: {str(e)}")
+import asyncio
+import json
+import logging
+import mimetypes
+import re
+import tempfile
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, Request, UploadFile
+from fastapi.responses import FileResponse
+from pydantic import BaseModel, Field
+from sqlalchemy import select
+
+from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
+from app.gateway.path_utils import resolve_thread_virtual_path
+from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
+from deerflow.config.app_config import AppConfig
+from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
+from deerflow.persistence.engine import get_engine, get_session_factory
+from deerflow.persistence.skills import SkillStore
+from deerflow.persistence.skills.model import SkillDisplayAdapterRow
+from deerflow.persistence.tags import TagStore
+from deerflow.runtime.user_context import get_effective_user_id
+from deerflow.skills import Skill
+from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
+from deerflow.skills.installer import SkillAlreadyExistsError
+from deerflow.skills.security_scanner import scan_skill_content
+from deerflow.skills.storage import get_or_new_skill_storage
+from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["skills"])
+
+
+# ---------------------------------------------------------------------------
+# Per-user authz helpers (mirrors routers/agents.py)
+# ---------------------------------------------------------------------------
+
+
+def _current_user_id(request: Request) -> str:
+ user = getattr(request.state, "user", None)
+ if user is not None:
+ return str(user.id)
+ return get_effective_user_id()
+
+
+def _is_admin_user(request: Request) -> bool:
+ user = getattr(request.state, "user", None)
+ return getattr(user, "system_role", None) == "admin"
+
+
+def _can_view_skill_source(
+ category: SkillCategory,
+ owner_user_id: str | None,
+ user_id: str,
+ *,
+ is_admin: bool,
+) -> bool:
+ """Whether the raw SKILL.md source is visible to a user.
+
+ Admins may view any skill's source; otherwise only the publisher (the
+ owner of a custom skill) qualifies. Built-in / public skills have no human
+ owner, so for non-admins they are never source-visible.
+ """
+ if is_admin:
+ return True
+ return category == SkillCategory.CUSTOM and owner_user_id is not None and owner_user_id == user_id
+
+
+async def _get_owned_skill_or_raise(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
+ owned = await store.get_owned(name, user_id)
+ if owned is not None:
+ return owned
+ visible = await store.get_visible(name, user_id)
+ if visible is None:
+ raise HTTPException(status_code=404, detail=f"Skill '{name}' not found")
+ raise HTTPException(status_code=403, detail="You can only modify skills that you own")
+
+
+async def _get_editable_skill_or_raise(
+ store: SkillStore,
+ name: str,
+ user_id: str,
+ *,
+ is_admin: bool,
+) -> tuple[dict[str, Any], str]:
+ """Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
+
+ Admins can edit/delete any skill (including others' published ones, since
+ the spec lets them force-takedown). Non-admins are limited to their own.
+ """
+ owned = await store.get_owned(name, user_id)
+ if owned is not None:
+ return owned, "owner"
+ record = await store.get_any(name)
+ if record is None:
+ raise HTTPException(status_code=404, detail=f"Skill '{name}' not found")
+ if is_admin:
+ return record, "admin"
+ raise HTTPException(status_code=403, detail="You can only modify skills that you own")
+
+
+class SkillResponse(BaseModel):
+ """Response model for skill information."""
+
+ name: str = Field(..., description="Name of the skill")
+ description: str = Field(..., description="Description of what the skill does")
+ license: str | None = Field(None, description="License information")
+ category: SkillCategory = Field(..., description="Category of the skill (public or custom)")
+ enabled: bool = Field(default=True, description="Whether this skill is enabled")
+ use_count: int = Field(default=0, description="Number of times the skill has been used")
+ view_count: int = Field(default=0, description="Number of times the skill has been viewed")
+ patch_count: int = Field(default=0, description="Number of times the skill has been patched")
+ last_activity_at: datetime | None = Field(default=None, description="Most recent activity timestamp")
+ state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
+ pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
+ source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
+ 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")
+ display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
+ display_sample: dict[str, Any] | None = Field(default=None, alias="displaySample", description="Persisted sample output used by the display adapter editor")
+ tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
+
+
+class SkillsListResponse(BaseModel):
+ """Response model for listing all skills."""
+
+ skills: list[SkillResponse]
+
+
+class SkillUpdateRequest(BaseModel):
+ """Request model for updating a skill."""
+
+ enabled: bool = Field(..., description="Whether to enable or disable the skill")
+
+
+class SkillDisplayUpdateRequest(BaseModel):
+ """Request model for updating a skill's frontend display adapter."""
+
+ display: SkillDisplayConfig | None = Field(default=None, description="Display adapter configuration; null clears it")
+ sample: dict[str, Any] | None = Field(default=None, description="Optional sample payload to persist for future editor sessions")
+
+
+class SkillDisplaySampleResponse(BaseModel):
+ """Sample structured output used to infer a display adapter."""
+
+ skill_name: str
+ sample: dict[str, Any]
+
+
+class PersistedSkillDisplaySampleResponse(BaseModel):
+ """Persisted sample output loaded from the database only."""
+
+ skill_name: str
+ sample: dict[str, Any] | None = None
+
+
+class SkillInstallRequest(BaseModel):
+ """Request model for installing a skill from a .skill file."""
+
+ thread_id: str = Field(..., description="The thread ID where the .skill file is located")
+ path: str = Field(..., description="Virtual path to the .skill file (e.g., mnt/user-data/outputs/my-skill.skill)")
+ target_category: SkillCategory = Field(default=SkillCategory.CUSTOM, description="Destination category: 'public' (admin only) or 'custom'")
+
+
+class SkillInstallResponse(BaseModel):
+ """Response model for skill installation."""
+
+ success: bool = Field(..., description="Whether the installation was successful")
+ skill_name: str = Field(..., description="Name of the installed skill")
+ message: str = Field(..., description="Installation result message")
+ overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
+ category: str = Field(default="custom", description="Destination category: 'public' (admin) or 'custom'")
+
+
+class SkillUploadValidateResponse(BaseModel):
+ """Response model for the pre-install validation endpoint."""
+
+ ok: bool = Field(..., description="Whether the archive passed cheap validation")
+ skill_name: str = Field(..., description="Skill name parsed from frontmatter")
+ description: str = Field(default="", description="Skill description parsed from frontmatter")
+ license: str | None = Field(default=None, description="Skill license, if declared")
+ allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke")
+ conflict: bool = Field(default=False, description="True if a skill with the same name already exists")
+ conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true")
+ warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user")
+
+
+class SkillReconcileResponse(BaseModel):
+ """Response model for reconciling custom skills found on disk."""
+
+ adopted: list[str] = Field(default_factory=list, description="Skills found on disk that had no DB row; now owned by the caller")
+ already_registered: list[str] = Field(default_factory=list, description="Skills found on disk that already had a DB row (unchanged)")
+
+
+class CustomSkillContentResponse(SkillResponse):
+ content: str = Field(..., description="Raw SKILL.md content")
+
+
+class CustomSkillUpdateRequest(BaseModel):
+ content: str = Field(..., description="Replacement SKILL.md content")
+
+
+class CustomSkillHistoryResponse(BaseModel):
+ history: list[dict]
+
+
+class SkillRollbackRequest(BaseModel):
+ history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
+
+
+def _skill_to_response(
+ skill: Skill,
+ ownership: dict[str, Any] | None = None,
+ *,
+ display_override: dict[str, Any] | None = None,
+ display_sample: dict[str, Any] | None = None,
+) -> SkillResponse:
+ """Convert a Skill object to a SkillResponse.
+
+ ``ownership`` is the skill's record from ``SkillStore`` (or None for public
+ built-in skills which never have a DB row). When present, contributes the
+ ``owner_user_id`` / ``published`` / ``builtin`` fields used for per-user
+ visibility and the frontend's owner badges.
+ """
+ from deerflow.skills.usage import get_entry, latest_activity_at
+
+ entry = get_entry(skill.name)
+ last_at_str = latest_activity_at(skill.name)
+ last_at: datetime | None = None
+ if last_at_str:
+ try:
+ last_at = datetime.fromisoformat(last_at_str)
+ except Exception:
+ pass
+
+ 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")
+
+ display = display_override
+ if display is None:
+ try:
+ skill_config = get_extensions_config().skills.get(skill.name)
+ if skill_config and skill_config.display:
+ display = skill_config.display.model_dump(by_alias=True)
+ except Exception:
+ logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True)
+
+ return SkillResponse(
+ name=skill.name,
+ description=skill.description,
+ license=skill.license,
+ category=skill.category,
+ enabled=skill.enabled,
+ use_count=entry.get("use_count", 0),
+ view_count=entry.get("view_count", 0),
+ patch_count=entry.get("patch_count", 0),
+ last_activity_at=last_at,
+ state=entry.get("state", "active"),
+ pinned=entry.get("pinned", False),
+ source=entry.get("source", "upload"),
+ owner_user_id=owner_user_id,
+ published=published,
+ builtin=builtin,
+ detail=detail,
+ display=display,
+ displaySample=display_sample,
+ )
+
+
+def _dump_extensions_config(config_obj: ExtensionsConfig) -> dict[str, Any]:
+ return {
+ "mcpServers": {name: server.model_dump(by_alias=True) for name, server in config_obj.mcp_servers.items()},
+ "skills": {name: skill_config.model_dump(by_alias=True, exclude_none=True) for name, skill_config in config_obj.skills.items()},
+ }
+
+
+async def _ensure_skill_display_table() -> bool:
+ """Create the display adapter table when DB persistence is enabled."""
+ engine = get_engine()
+ sf = get_session_factory()
+ if engine is None or sf is None:
+ return False
+ async with engine.begin() as conn:
+ await conn.run_sync(SkillDisplayAdapterRow.__table__.create, checkfirst=True)
+ return True
+
+
+def _json_text_loads(value: str | None) -> dict[str, Any] | None:
+ if not value:
+ return None
+ try:
+ parsed = json.loads(value)
+ return parsed if isinstance(parsed, dict) else None
+ except Exception:
+ logger.warning("Failed to parse persisted skill display JSON", exc_info=True)
+ return None
+
+
+def _json_text_dumps(value: dict[str, Any] | None) -> str | None:
+ if value is None:
+ return None
+ return json.dumps(value, ensure_ascii=False)
+
+
+async def _get_skill_display_adapter(skill_name: str) -> dict[str, Any] | None:
+ if not await _ensure_skill_display_table():
+ return None
+ sf = get_session_factory()
+ if sf is None:
+ return None
+ async with sf() as session:
+ row = await session.get(SkillDisplayAdapterRow, skill_name)
+ if row is None:
+ return None
+ return {
+ "display": _json_text_loads(row.display_config),
+ "sample": _json_text_loads(row.sample_payload),
+ }
+
+
+async def _list_skill_display_adapters(skill_names: list[str]) -> dict[str, dict[str, Any]]:
+ if not skill_names or not await _ensure_skill_display_table():
+ return {}
+ sf = get_session_factory()
+ if sf is None:
+ return {}
+ async with sf() as session:
+ result = await session.execute(
+ select(SkillDisplayAdapterRow).where(SkillDisplayAdapterRow.skill_name.in_(skill_names))
+ )
+ return {
+ row.skill_name: {
+ "display": _json_text_loads(row.display_config),
+ "sample": _json_text_loads(row.sample_payload),
+ }
+ for row in result.scalars()
+ }
+
+
+async def _upsert_skill_display_adapter(
+ skill_name: str,
+ *,
+ display: dict[str, Any] | None | object = ...,
+ sample: dict[str, Any] | None | object = ...,
+) -> None:
+ if not await _ensure_skill_display_table():
+ return
+ sf = get_session_factory()
+ if sf is None:
+ return
+ async with sf() as session:
+ row = await session.get(SkillDisplayAdapterRow, skill_name)
+ if row is None:
+ row = SkillDisplayAdapterRow(skill_name=skill_name)
+ session.add(row)
+ if display is not ...:
+ row.display_config = _json_text_dumps(display) # type: ignore[arg-type]
+ if sample is not ...:
+ row.sample_payload = _json_text_dumps(sample) # type: ignore[arg-type]
+ row.updated_at = datetime.now()
+ await session.commit()
+
+
+_SKILL_SAMPLE_SYSTEM_PROMPT = (
+ "You infer realistic JSON return shapes for AI agent skills. "
+ "Given a skill's name, description, and full SKILL.md, output a SINGLE JSON object that "
+ "represents a plausible successful response from this skill's primary action. "
+ "Rules:\n"
+ "- Output JSON ONLY. No prose. No markdown fences. No comments.\n"
+ "- The top level must be a JSON object (not an array).\n"
+ "- Include a results/items/list/records array (pick the most natural key) with 2-3 example entries.\n"
+ "- Use realistic, idiomatic field names (e.g. title, content, source, publish_time, score, url, id) that match the skill's domain.\n"
+ "- Each entry should have enough fields to drive a citation/list/table UI: a title, a short snippet/content, a source, a time/date, and an id or url when relevant.\n"
+ "- Numbers stay numbers, dates stay ISO strings, scores between 0 and 1 when applicable.\n"
+ "- Do not invent fields that contradict the skill description."
+)
+
+
+def _extract_json_from_llm_output(raw: str) -> dict[str, Any] | None:
+ """Best-effort extraction of a JSON object from an LLM response."""
+ text = (raw or "").strip()
+ if not text:
+ return None
+ fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL | re.IGNORECASE)
+ if fence:
+ text = fence.group(1).strip()
+ try:
+ parsed = json.loads(text)
+ return parsed if isinstance(parsed, dict) else None
+ except json.JSONDecodeError:
+ pass
+ match = re.search(r"\{.*\}", text, re.DOTALL)
+ if not match:
+ return None
+ try:
+ parsed = json.loads(match.group(0))
+ return parsed if isinstance(parsed, dict) else None
+ except json.JSONDecodeError:
+ return None
+
+
+async def _generate_skill_display_sample(skill: Skill, app_config: AppConfig) -> dict[str, Any]:
+ """Generate a realistic return-shape sample for ``skill`` via the LLM.
+
+ Reads SKILL.md, asks the configured curator model (or main model) to infer
+ a plausible JSON response. Falls back to :func:`_mock_skill_display_sample`
+ when the model is unconfigured, the call times out, or the output cannot
+ be parsed as a JSON object.
+ """
+ try:
+ skill_content = skill.skill_file.read_text(encoding="utf-8")
+ except OSError as exc:
+ logger.debug("Cannot read SKILL.md for %s (%s); falling back to description only", skill.name, exc)
+ skill_content = ""
+ # Cap the SKILL.md body so very large workflow docs don't blow the context.
+ max_chars = 6000
+ if len(skill_content) > max_chars:
+ skill_content = skill_content[:max_chars] + "\n…(truncated)"
+
+ user_prompt = (
+ f"Skill name: {skill.name}\n"
+ f"Short description: {skill.description}\n\n"
+ f"SKILL.md:\n-----\n{skill_content or '(SKILL.md unavailable; rely on the short description above.)'}\n-----\n\n"
+ "Return one JSON object that matches what this skill would actually produce on success."
+ )
+
+ try:
+ from deerflow.models import create_chat_model
+
+ # Reuse the curator/security-scan model setting; falls back to the main model when unset.
+ model_name = app_config.skill_evolution.curator.model_name if app_config.skill_evolution and app_config.skill_evolution.curator else None
+ model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config)
+ response = await asyncio.wait_for(
+ model.ainvoke(
+ [
+ {"role": "system", "content": _SKILL_SAMPLE_SYSTEM_PROMPT},
+ {"role": "user", "content": user_prompt},
+ ],
+ config={"run_name": "skill_display_sample"},
+ ),
+ timeout=45,
+ )
+ parsed = _extract_json_from_llm_output(str(getattr(response, "content", "") or ""))
+ if parsed:
+ return parsed
+ logger.warning("Skill display sample model returned non-JSON for %s; falling back to static mock", skill.name)
+ except asyncio.TimeoutError:
+ logger.warning("Skill display sample LLM call timed out for %s; falling back to static mock", skill.name)
+ except Exception:
+ logger.warning("Skill display sample LLM call failed for %s; falling back to static mock", skill.name, exc_info=True)
+ return _mock_skill_display_sample(skill.name)
+
+
+def _mock_skill_display_sample(skill_name: str) -> dict[str, Any]:
+ if skill_name == "knowledge-search-v2":
+ return {
+ "success": True,
+ "query": "参考文献模式测试 知识库固定测试",
+ "count": 4,
+ "source": "fixed-display-sample",
+ "results": [
+ {
+ "index": 1,
+ "recUuid": "ksv2-fixed-001",
+ "relevance_score": 0.982,
+ "title": "参考文献模式测试:参考文献模式固定测试数据一",
+ "source_category": "知识库/测试资料",
+ "source": "knowledge-search-v2/fixed/source-001",
+ "publish_time": "2026-05-25",
+ "content_preview": "这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。",
+ "content_length": 168,
+ },
+ {
+ "index": 2,
+ "recUuid": "ksv2-fixed-002",
+ "relevance_score": 0.936,
+ "title": "参考文献模式测试:结构化字段映射验证",
+ "source_category": "知识库/字段映射",
+ "source": "knowledge-search-v2/fixed/source-002",
+ "publish_time": "2026-05-24",
+ "content_preview": "本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。",
+ "content_length": 154,
+ },
+ {
+ "index": 3,
+ "recUuid": "ksv2-fixed-003",
+ "relevance_score": 0.887,
+ "title": "参考文献模式测试:多引用排序验证",
+ "source_category": "知识库/排序验证",
+ "source": "knowledge-search-v2/fixed/source-003",
+ "publish_time": "2026-05-23",
+ "content_preview": "本条用于验证多条参考文献的编号顺序是否与 results 数组顺序一致,并验证点击正文编号时能定位到右侧对应参考文献。",
+ "content_length": 132,
+ },
+ {
+ "index": 4,
+ "recUuid": "",
+ "relevance_score": 0.801,
+ "title": "参考文献模式测试:无 recUuid 跳转隐藏验证",
+ "source_category": "知识库/边界场景",
+ "source": "knowledge-search-v2/fixed/source-004",
+ "publish_time": "2026-05-22",
+ "content_preview": "本条故意把 recUuid 置为空,用于验证右侧弹框仍展示详情,但不展示外部跳转按钮。",
+ "content_length": 98,
+ },
+ ],
+ }
+ return {
+ "success": True,
+ "query": "sample query",
+ "results": [
+ {
+ "id": "sample-001",
+ "title": "Sample result",
+ "summary": "Sample structured output used to infer field mapping.",
+ "source": "sample",
+ "updatedAt": "2026-05-25",
+ }
+ ],
+ }
+
+
+async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[str, dict[str, Any]]:
+ """Index visible-to-user skill records by name. Internal call cache for list endpoints."""
+ records = await skill_store.list_visible(user_id)
+ return {record["name"]: record for record in records}
+
+
+async def _attach_and_filter_tags(
+ tag_store: TagStore,
+ responses: list[SkillResponse],
+ search: str | None,
+ tag_ids: list[str] | None,
+) -> list[SkillResponse]:
+ """Bulk-attach tags to skill responses, then apply name/tag filters."""
+ assignments = await tag_store.list_assignments("skill", [r.name for r in responses])
+ for resp in responses:
+ resp.tags = assignments.get(resp.name, [])
+ if search:
+ needle = search.strip().lower()
+ responses = [r for r in responses if needle in r.name.lower()]
+ if tag_ids:
+ wanted = set(tag_ids)
+ responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
+ return responses
+
+
+@router.get(
+ "/skills",
+ response_model=SkillsListResponse,
+ summary="List All Skills",
+ description="Retrieve a list of all available skills from both public and custom directories.",
+)
+async def list_skills(
+ request: Request,
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+ tag_store: TagStore = Depends(get_tag_store),
+ search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
+ tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
+) -> SkillsListResponse:
+ try:
+ user_id = _current_user_id(request)
+ all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
+ ownership = await _build_ownership_index(skill_store, user_id)
+ display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
+ responses: list[SkillResponse] = []
+ for skill in all_skills:
+ adapter = display_adapters.get(skill.name, {})
+ # 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:
+ # 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),
+ display_override=adapter.get("display"),
+ display_sample=adapter.get("sample"),
+ )
+ )
+ continue
+ record = ownership.get(skill.name)
+ if record is None:
+ continue
+ responses.append(
+ _skill_to_response(
+ skill,
+ record,
+ display_override=adapter.get("display"),
+ display_sample=adapter.get("sample"),
+ )
+ )
+ responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
+ return SkillsListResponse(skills=responses)
+ except Exception as e:
+ logger.error(f"Failed to load skills: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
+
+
+_MAX_SKILL_UPLOAD_BYTES = 512 * 1024 * 1024
+_ZIP_MAGIC = b"PK\x03\x04"
+
+
+async def _persist_uploaded_skill(file: UploadFile) -> Path:
+ """Stream an uploaded skill archive to a temp file. Caller must unlink it.
+
+ Validates the leading bytes look like a ZIP archive and enforces the same
+ 512 MB cap that the extractor uses, so we reject obvious garbage early.
+ """
+ if file.filename is None:
+ raise HTTPException(status_code=400, detail="No file provided")
+ suffix = Path(file.filename).suffix.lower()
+ if suffix not in {".zip", ".skill"}:
+ raise HTTPException(status_code=400, detail="Skill upload must be a .zip or .skill file")
+
+ fd, tmp_name = tempfile.mkstemp(suffix=".skill", prefix="skill-upload-")
+ tmp_path = Path(tmp_name)
+ total = 0
+ head: bytes = b""
+ try:
+ with open(fd, "wb") as out:
+ while True:
+ chunk = await file.read(1024 * 1024)
+ if not chunk:
+ break
+ if not head:
+ head = chunk[: len(_ZIP_MAGIC)]
+ total += len(chunk)
+ if total > _MAX_SKILL_UPLOAD_BYTES:
+ raise HTTPException(status_code=400, detail="Uploaded skill archive exceeds the 512 MB limit")
+ out.write(chunk)
+ except HTTPException:
+ tmp_path.unlink(missing_ok=True)
+ raise
+ except Exception:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+ if not head.startswith(_ZIP_MAGIC):
+ tmp_path.unlink(missing_ok=True)
+ raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
+ return tmp_path
+
+
+@router.post(
+ "/skills/validate",
+ response_model=SkillUploadValidateResponse,
+ summary="Validate Skill Archive (No Install)",
+ description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.",
+)
+async def validate_skill_upload(
+ file: UploadFile = File(..., description="The .zip or .skill archive to validate"),
+ config: AppConfig = Depends(get_config),
+) -> SkillUploadValidateResponse:
+ tmp_path = await _persist_uploaded_skill(file)
+ try:
+ result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path)
+ return SkillUploadValidateResponse(**result)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to validate uploaded skill: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to validate skill: {e}")
+ finally:
+ tmp_path.unlink(missing_ok=True)
+
+
+@router.post(
+ "/skills/install-upload",
+ response_model=SkillInstallResponse,
+ summary="Install Skill from Upload",
+ description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
+)
+async def install_skill_upload(
+ request: Request,
+ file: UploadFile = File(..., description="The .zip or .skill archive to install"),
+ overwrite: bool = False,
+ target_category: SkillCategory = SkillCategory.CUSTOM,
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+) -> SkillInstallResponse:
+ user_id = _current_user_id(request)
+ is_admin = _is_admin_user(request)
+ # Only admins may install to the public (built-in) directory.
+ if target_category == SkillCategory.PUBLIC and not is_admin:
+ raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
+ tmp_path = await _persist_uploaded_skill(file)
+ try:
+ storage = get_or_new_skill_storage(app_config=config)
+ # Pre-parse the archive to learn the skill name without writing anything,
+ # so we can reject the upload before touching the filesystem if another
+ # user already owns this name. Admins bypass the ownership check since
+ # they install to the shared public directory.
+ try:
+ validation = storage.validate_skill_archive(tmp_path)
+ preview_name = validation.get("skill_name") if isinstance(validation, dict) else None
+ except Exception:
+ preview_name = None
+ if preview_name and not is_admin:
+ existing = await skill_store.get_any(preview_name)
+ if existing and existing.get("owner_user_id") not in (None, user_id):
+ raise HTTPException(
+ status_code=409,
+ detail=f"Skill '{preview_name}' already exists and is owned by another user",
+ )
+ result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category)
+ try:
+ from deerflow.skills.usage import mark_uploaded
+ mark_uploaded(result["skill_name"])
+ except Exception:
+ pass
+ # For public installs (admin), register as built-in with no owner.
+ # For custom installs, register ownership under the uploading user.
+ if target_category == SkillCategory.PUBLIC:
+ await skill_store.ensure_legacy(
+ {"name": result["skill_name"], "owner_user_id": None, "published": True}
+ )
+ else:
+ # ``ensure_legacy`` is idempotent and only patches a NULL owner; it
+ # never overwrites an existing owner, so the pre-parse check above
+ # is what really guards cross-user steals.
+ await skill_store.ensure_legacy(
+ {"name": result["skill_name"], "owner_user_id": user_id, "published": False}
+ )
+ await refresh_skills_system_prompt_cache_async()
+ return SkillInstallResponse(**result)
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except SkillAlreadyExistsError as e:
+ raise HTTPException(status_code=409, detail=str(e))
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to install uploaded skill: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to install skill: {e}")
+ finally:
+ tmp_path.unlink(missing_ok=True)
+
+
+@router.post(
+ "/skills/install",
+ response_model=SkillInstallResponse,
+ summary="Install Skill",
+ description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
+)
+async def install_skill(
+ body: SkillInstallRequest,
+ request: Request,
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+) -> SkillInstallResponse:
+ try:
+ user_id = _current_user_id(request)
+ is_admin = _is_admin_user(request)
+ target_category = body.target_category
+ if target_category == SkillCategory.PUBLIC and not is_admin:
+ raise HTTPException(status_code=403, detail="Only admin users can install skills to the public directory")
+ skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path)
+ result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path, target_category=target_category)
+ try:
+ from deerflow.skills.usage import mark_uploaded
+ mark_uploaded(result["skill_name"])
+ except Exception:
+ pass
+ if target_category == SkillCategory.PUBLIC:
+ await skill_store.ensure_legacy(
+ {"name": result["skill_name"], "owner_user_id": None, "published": True}
+ )
+ else:
+ await skill_store.ensure_legacy(
+ {"name": result["skill_name"], "owner_user_id": user_id, "published": False}
+ )
+ await refresh_skills_system_prompt_cache_async()
+ return SkillInstallResponse(**result)
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except SkillAlreadyExistsError as e:
+ raise HTTPException(status_code=409, detail=str(e))
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to install skill: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
+
+
+@router.post(
+ "/skills/reconcile-custom",
+ response_model=SkillReconcileResponse,
+ summary="Adopt Orphan Custom Skills",
+ description=(
+ "Scan the on-disk ``skills/custom/`` directory and register any skill that has a "
+ "valid SKILL.md but no DB ownership row. Adopted skills are recorded as owned by "
+ "the calling user with ``published=false``. Skips skills that already have a row "
+ "(regardless of owner). Useful after manually unzipping skills onto the filesystem."
+ ),
+)
+async def reconcile_custom_skills(
+ request: Request,
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+) -> SkillReconcileResponse:
+ try:
+ user_id = _current_user_id(request)
+ storage = get_or_new_skill_storage(app_config=config)
+ custom_skills = [s for s in storage.load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
+
+ adopted: list[str] = []
+ already: list[str] = []
+ for skill in custom_skills:
+ existing = await skill_store.get_any(skill.name)
+ if existing is not None:
+ already.append(skill.name)
+ continue
+ await skill_store.ensure_legacy(
+ {"name": skill.name, "owner_user_id": user_id, "published": False}
+ )
+ try:
+ from deerflow.skills.usage import mark_uploaded
+ mark_uploaded(skill.name)
+ except Exception:
+ pass
+ adopted.append(skill.name)
+
+ if adopted:
+ await refresh_skills_system_prompt_cache_async()
+ return SkillReconcileResponse(adopted=adopted, already_registered=already)
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to reconcile custom skills: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to reconcile custom skills: {e}")
+
+
+@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
+async def list_custom_skills(
+ request: Request,
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+) -> SkillsListResponse:
+ try:
+ user_id = _current_user_id(request)
+ custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
+ ownership = await _build_ownership_index(skill_store, user_id)
+ display_adapters = await _list_skill_display_adapters([skill.name for skill in custom])
+ responses = [
+ _skill_to_response(
+ skill,
+ ownership[skill.name],
+ display_override=display_adapters.get(skill.name, {}).get("display"),
+ display_sample=display_adapters.get(skill.name, {}).get("sample"),
+ )
+ for skill in custom
+ if skill.name in ownership
+ ]
+ return SkillsListResponse(skills=responses)
+ except Exception as e:
+ logger.error("Failed to list custom skills: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}")
+
+
+@router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content")
+async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
+ try:
+ skill_name = skill_name.replace("\r\n", "").replace("\n", "")
+ 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 and s.category == SkillCategory.CUSTOM), None)
+ if skill is None:
+ raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
+ return CustomSkillContentResponse(**_skill_to_response(skill).model_dump(), content=get_or_new_skill_storage(app_config=config).read_custom_skill(skill_name))
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to get custom skill %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to get custom skill: {str(e)}")
+
+
+@router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill")
+async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
+ try:
+ skill_name = skill_name.replace("\r\n", "").replace("\n", "")
+ storage = get_or_new_skill_storage(app_config=config)
+ storage.ensure_custom_skill_is_editable(skill_name)
+ storage.validate_skill_markdown_content(skill_name, request.content)
+ scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
+ if scan.decision == "block":
+ raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}")
+ prev_content = storage.read_custom_skill(skill_name)
+ storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content)
+ storage.append_history(
+ skill_name,
+ {
+ "action": "human_edit",
+ "author": "human",
+ "thread_id": None,
+ "file_path": SKILL_MD_FILE,
+ "prev_content": prev_content,
+ "new_content": request.content,
+ "scanner": {"decision": scan.decision, "reason": scan.reason},
+ },
+ )
+ try:
+ from deerflow.skills.usage import bump_patch
+ bump_patch(skill_name)
+ except Exception:
+ pass
+ await refresh_skills_system_prompt_cache_async()
+ return await get_custom_skill(skill_name, config)
+ except HTTPException:
+ raise
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ logger.error("Failed to update custom skill %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to update custom skill: {str(e)}")
+
+
+@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
+async def delete_custom_skill(
+ skill_name: str,
+ request: Request,
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+ agent_store=Depends(get_agent_store),
+) -> dict[str, bool]:
+ try:
+ skill_name = skill_name.replace("\r\n", "").replace("\n", "")
+ user_id = _current_user_id(request)
+ is_admin = _is_admin_user(request)
+ record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
+ operation = "admin_takedown" if mode == "admin" and record.get("owner_user_id") not in (None, user_id) else "delete"
+
+ storage = get_or_new_skill_storage(app_config=config)
+ storage.delete_custom_skill(
+ skill_name,
+ history_meta={
+ "action": "human_delete",
+ "author": "human",
+ "thread_id": None,
+ "file_path": SKILL_MD_FILE,
+ "prev_content": None,
+ "new_content": None,
+ "scanner": {"decision": "allow", "reason": "Deletion requested."},
+ },
+ )
+ try:
+ from deerflow.skills.usage import forget
+ forget(skill_name)
+ except Exception:
+ pass
+
+ # Persist ownership record removal so the DB stays consistent.
+ if mode == "admin":
+ await skill_store.delete_admin(skill_name)
+ else:
+ await skill_store.delete(skill_name, user_id)
+
+ try:
+ await get_tag_store(request).unassign_all("skill", skill_name)
+ except Exception: # noqa: BLE001 — tag cleanup must not block deletion
+ pass
+
+ # Strip references from every agent that pulled this skill in,
+ # and notify their owners (no-op for self).
+ await cascade_skill_unpublished_or_deleted(
+ skill_name,
+ actor_user_id=user_id,
+ operation=operation,
+ skill_owner_user_id=record.get("owner_user_id"),
+ agent_store=agent_store,
+ )
+
+ await refresh_skills_system_prompt_cache_async()
+ return {"success": True}
+ except HTTPException:
+ raise
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ logger.error("Failed to delete custom skill %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
+
+
+class SkillPublishRequest(BaseModel):
+ """Request body for toggling a custom skill's published flag."""
+
+ published: bool = Field(..., description="Whether to publish (True) or unpublish (False)")
+
+
+class SkillPublishResponse(BaseModel):
+ name: str
+ owner_user_id: str | None
+ published: bool
+
+
+@router.put(
+ "/skills/custom/{skill_name}/published",
+ response_model=SkillPublishResponse,
+ summary="Toggle Custom Skill Published State",
+)
+async def update_custom_skill_published(
+ skill_name: str,
+ body: SkillPublishRequest,
+ request: Request,
+ skill_store: SkillStore = Depends(get_skill_store),
+ agent_store=Depends(get_agent_store),
+) -> SkillPublishResponse:
+ try:
+ skill_name = skill_name.replace("\r\n", "").replace("\n", "")
+ user_id = _current_user_id(request)
+ is_admin = _is_admin_user(request)
+ record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
+
+ was_published = bool(record.get("published", False))
+ if mode == "admin":
+ updated = await skill_store.update_admin(skill_name, {"published": body.published})
+ else:
+ updated = await skill_store.update(skill_name, user_id, {"published": body.published})
+ if updated is None:
+ raise HTTPException(status_code=500, detail="Failed to persist published state")
+
+ # If we are *removing* visibility (was published, now not), strip from
+ # other users' agents and notify them. admin takedown is signaled by
+ # the actor not being the owner.
+ if was_published and not body.published:
+ skill_owner = record.get("owner_user_id")
+ operation = "admin_takedown" if mode == "admin" and skill_owner not in (None, user_id) else "unpublish"
+ await cascade_skill_unpublished_or_deleted(
+ skill_name,
+ actor_user_id=user_id,
+ operation=operation,
+ skill_owner_user_id=skill_owner,
+ agent_store=agent_store,
+ )
+ await refresh_skills_system_prompt_cache_async()
+
+ return SkillPublishResponse(
+ name=updated["name"],
+ owner_user_id=updated.get("owner_user_id"),
+ published=bool(updated.get("published", False)),
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to update published state for %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to update published state: {str(e)}")
+
+
+_ALLOWED_USER_STATES = {"active", "archived"}
+
+
+class SkillStateRequest(BaseModel):
+ """Request body for switching a custom skill's lifecycle state."""
+
+ state: str = Field(..., description="New state: 'active' (restore) or 'archived' (soft archive)")
+
+
+class SkillPinRequest(BaseModel):
+ """Request body for pinning / unpinning a custom skill."""
+
+ pinned: bool = Field(..., description="Whether to pin (true) so the curator never auto-archives this skill")
+
+
+def _reload_skill_response(skill_name: str, ownership: dict[str, Any], config: AppConfig) -> SkillResponse:
+ """Reload a single skill from disk and return its API response form."""
+ 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 on disk")
+ return _skill_to_response(skill, ownership)
+
+
+@router.put(
+ "/skills/custom/{skill_name}/state",
+ response_model=SkillResponse,
+ summary="Update Custom Skill Lifecycle State",
+ description="Restore (active) or soft-archive a custom skill. Only the owner or admins may invoke.",
+)
+async def update_custom_skill_state(
+ skill_name: str,
+ body: SkillStateRequest,
+ 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", "")
+ if body.state not in _ALLOWED_USER_STATES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"state must be one of {sorted(_ALLOWED_USER_STATES)}",
+ )
+ user_id = _current_user_id(request)
+ is_admin = _is_admin_user(request)
+ record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
+
+ from deerflow.skills.usage import set_state as _usage_set_state
+
+ _usage_set_state(skill_name, body.state)
+ await refresh_skills_system_prompt_cache_async()
+ return _reload_skill_response(skill_name, record, config)
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to update state for %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to update state: {e}")
+
+
+@router.put(
+ "/skills/custom/{skill_name}/pinned",
+ response_model=SkillResponse,
+ summary="Pin or Unpin a Custom Skill",
+ description="Pin a skill so the curator never auto-archives it. Only the owner or admins may invoke.",
+)
+async def update_custom_skill_pinned(
+ skill_name: str,
+ body: SkillPinRequest,
+ 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", "")
+ user_id = _current_user_id(request)
+ is_admin = _is_admin_user(request)
+ record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
+
+ from deerflow.skills.usage import set_pinned as _usage_set_pinned
+
+ _usage_set_pinned(skill_name, body.pinned)
+ return _reload_skill_response(skill_name, record, config)
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to update pinned for %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}")
+
+
+@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
+async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
+ try:
+ skill_name = skill_name.replace("\r\n", "").replace("\n", "")
+ storage = get_or_new_skill_storage(app_config=config)
+ if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
+ raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
+ return CustomSkillHistoryResponse(history=storage.read_history(skill_name))
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to read history for %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to read history: {str(e)}")
+
+
+@router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill")
+async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse:
+ try:
+ storage = get_or_new_skill_storage(app_config=config)
+ if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists():
+ raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
+ history = storage.read_history(skill_name)
+ if not history:
+ raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history")
+ record = history[request.history_index]
+ target_content = record.get("prev_content")
+ if target_content is None:
+ raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to")
+ storage.validate_skill_markdown_content(skill_name, target_content)
+ scan = await scan_skill_content(target_content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config)
+ skill_file = storage.get_custom_skill_file(skill_name)
+ current_content = skill_file.read_text(encoding="utf-8") if skill_file.exists() else None
+ history_entry = {
+ "action": "rollback",
+ "author": "human",
+ "thread_id": None,
+ "file_path": SKILL_MD_FILE,
+ "prev_content": current_content,
+ "new_content": target_content,
+ "rollback_from_ts": record.get("ts"),
+ "scanner": {"decision": scan.decision, "reason": scan.reason},
+ }
+ if scan.decision == "block":
+ storage.append_history(skill_name, history_entry)
+ raise HTTPException(status_code=400, detail=f"Rollback blocked by security scanner: {scan.reason}")
+ storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content)
+ storage.append_history(skill_name, history_entry)
+ await refresh_skills_system_prompt_cache_async()
+ return await get_custom_skill(skill_name, config)
+ except HTTPException:
+ raise
+ except IndexError:
+ raise HTTPException(status_code=400, detail="history_index is out of range")
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ logger.error("Failed to roll back custom skill %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to roll back custom skill: {str(e)}")
+
+
+@router.get(
+ "/skills/{skill_name}",
+ response_model=SkillResponse,
+ summary="Get Skill Details",
+ description="Retrieve detailed information about a specific skill by its name.",
+)
+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)
+ 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")
+
+ # 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:
+ logger.error(f"Failed to get skill {skill_name}: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
+
+
+class SkillContentResponse(BaseModel):
+ """SKILL.md 原文内容,适用于公共和自定义技能。"""
+
+ name: str
+ category: SkillCategory
+ content: str
+
+
+@router.get(
+ "/skills/{skill_name}/content",
+ response_model=SkillContentResponse,
+ summary="Get Skill Markdown Content",
+ description="Retrieve the raw SKILL.md content of a skill. Readable by any user the skill is visible to (built-in, published, or owned).",
+)
+async def get_skill_content(
+ skill_name: str,
+ request: Request,
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+) -> SkillContentResponse:
+ try:
+ skill_name = skill_name.replace("\r\n", "").replace("\n", "")
+ 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")
+ user_id = _current_user_id(request)
+ # SKILL.md describes how to use a skill — visible to anyone who can see
+ # the skill itself (built-in, published, or owned). Admins always pass.
+ if not _is_admin_user(request):
+ record = await skill_store.get_any(skill_name)
+ if skill.category == SkillCategory.CUSTOM:
+ if record is None:
+ raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
+ if not record.get("published") and record.get("owner_user_id") != user_id:
+ raise HTTPException(status_code=403, detail="You can only view SKILL.md for skills visible to you")
+ content = skill.skill_file.read_text(encoding="utf-8")
+ return SkillContentResponse(name=skill.name, category=skill.category, content=content)
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to get skill content %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}")
+
+
+# ---------------------------------------------------------------------------
+# Skill files browser (tree + per-file read / write)
+# ---------------------------------------------------------------------------
+
+
+_SKILL_FILE_TEXT_EXTS = {
+ ".md", ".markdown", ".txt", ".rst",
+ ".py", ".pyi",
+ ".json", ".jsonl",
+ ".yaml", ".yml",
+ ".toml", ".ini", ".cfg", ".env",
+ ".sh", ".bash", ".zsh", ".ps1",
+ ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
+ ".html", ".htm", ".css", ".scss", ".sass", ".xml", ".svg",
+ ".csv", ".tsv",
+ ".sql",
+}
+_SKILL_FILE_TREE_SKIP_NAMES = {
+ "__pycache__", "node_modules", ".git", ".DS_Store", ".idea", ".vscode",
+}
+_SKILL_FILE_MAX_NODES = 2000
+_SKILL_FILE_MAX_DEPTH = 8
+_SKILL_FILE_READ_BYTES = 2 * 1024 * 1024 # 2 MiB cap on JSON content payload
+_SKILL_FILE_WRITE_BYTES = 1 * 1024 * 1024 # 1 MiB cap on edits
+
+
+class SkillFileNode(BaseModel):
+ """One entry in a skill's file tree.
+
+ ``children`` is only set for directories. ``path`` is POSIX-style and is
+ always relative to the skill's own directory (never absolute).
+ """
+
+ name: str = Field(..., description="Base name (no slashes)")
+ type: str = Field(..., description="'file' or 'dir'")
+ path: str = Field(..., description="Path relative to the skill directory, POSIX-separated")
+ size: int | None = Field(default=None, description="File size in bytes (files only)")
+ mtime: float | None = Field(default=None, description="Modification time as POSIX seconds (files only)")
+ children: list["SkillFileNode"] | None = Field(default=None, description="Child entries (directories only)")
+
+
+SkillFileNode.model_rebuild()
+
+
+class SkillFilesResponse(BaseModel):
+ """Tree of files under a skill directory."""
+
+ name: str
+ category: SkillCategory
+ editable: bool = Field(..., description="True when the caller may PUT new content into any file under this tree")
+ root: SkillFileNode
+
+
+class SkillFileContentResponse(BaseModel):
+ """JSON payload for reading a single file inside a skill."""
+
+ name: str
+ category: SkillCategory
+ path: str
+ mime: str
+ size: int
+ mtime: float
+ is_text: bool = Field(..., description="True when ``content`` carries the decoded text; otherwise download via the binary endpoint")
+ editable: bool = Field(..., description="True when the caller may PUT new content for this file")
+ content: str | None = Field(default=None, description="Decoded text content when ``is_text`` is True")
+
+
+class SkillFileSaveRequest(BaseModel):
+ """Request body for writing a text file inside a skill."""
+
+ path: str = Field(..., description="File path relative to the skill directory, POSIX-separated")
+ content: str = Field(..., description="Replacement text content")
+
+
+class SkillFileSaveResponse(BaseModel):
+ name: str
+ path: str
+ size: int
+ mtime: float
+
+
+def _normalize_skill_name_param(skill_name: str) -> str:
+ """Strip CR/LF the same way other skill routes do before lookup."""
+ return skill_name.replace("\r\n", "").replace("\n", "")
+
+
+def _load_skill_or_404(skill_name: str, config: AppConfig) -> Skill:
+ 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")
+ return skill
+
+
+async def _ensure_files_view_allowed(
+ skill: Skill,
+ request: Request,
+ skill_store: SkillStore,
+) -> dict[str, Any] | None:
+ """Mirror _can_view_skill_source: admins or the publisher (custom owner)."""
+ record = await skill_store.get_any(skill.name)
+ owner_user_id = record.get("owner_user_id") if record else None
+ user_id = _current_user_id(request)
+ if not _can_view_skill_source(skill.category, owner_user_id, user_id, is_admin=_is_admin_user(request)):
+ raise HTTPException(status_code=403, detail="Only the skill publisher or an admin can browse skill files")
+ return record
+
+
+def _caller_can_edit_skill_files(
+ skill: Skill,
+ record: dict[str, Any] | None,
+ request: Request,
+) -> bool:
+ """Editing is limited to custom skills, and to either owner or admin."""
+ if skill.category != SkillCategory.CUSTOM:
+ return False
+ if _is_admin_user(request):
+ return True
+ if record is None:
+ return False
+ return record.get("owner_user_id") == _current_user_id(request)
+
+
+def _resolve_skill_relative_path(skill: Skill, relative_path: str) -> Path:
+ """Validate *relative_path* against the skill directory and return the absolute Path.
+
+ Rejects empty, absolute, parent-traversing, or out-of-tree paths so a
+ malicious caller can never escape the skill's own directory.
+ """
+ if not relative_path or relative_path.strip() == "":
+ raise HTTPException(status_code=400, detail="path must not be empty")
+ candidate = Path(relative_path)
+ if candidate.is_absolute() or any(part in {"..", ""} for part in candidate.parts):
+ raise HTTPException(status_code=400, detail="path must be a relative path without '..' segments")
+ skill_dir = skill.skill_file.parent.resolve()
+ target = (skill_dir / candidate).resolve()
+ try:
+ target.relative_to(skill_dir)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="path must stay inside the skill directory")
+ return target
+
+
+def _file_mime(path: Path) -> str:
+ """Best-effort MIME guess; defaults to ``application/octet-stream``."""
+ guessed, _ = mimetypes.guess_type(path.name)
+ if guessed:
+ return guessed
+ if path.suffix.lower() in _SKILL_FILE_TEXT_EXTS:
+ return "text/plain"
+ return "application/octet-stream"
+
+
+def _is_text_file(path: Path) -> bool:
+ """Treat known text extensions as text; everything else as binary."""
+ return path.suffix.lower() in _SKILL_FILE_TEXT_EXTS
+
+
+def _build_skill_file_tree(skill_dir: Path) -> SkillFileNode:
+ """Walk *skill_dir* and build a SkillFileNode tree, depth/count capped.
+
+ Hidden files (``.foo``) and a small block-list of noise directories
+ (``__pycache__`` etc.) are skipped so the UI does not drown in housekeeping
+ artefacts.
+ """
+ counter = {"n": 0}
+
+ def build(current_dir: Path, depth: int) -> SkillFileNode:
+ rel = current_dir.relative_to(skill_dir).as_posix()
+ node = SkillFileNode(
+ name=current_dir.name if rel != "." else skill_dir.name,
+ type="dir",
+ path="" if rel == "." else rel,
+ children=[],
+ )
+ if depth >= _SKILL_FILE_MAX_DEPTH:
+ return node
+ try:
+ entries = sorted(current_dir.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
+ except OSError:
+ return node
+ for entry in entries:
+ if counter["n"] >= _SKILL_FILE_MAX_NODES:
+ break
+ if entry.name.startswith(".") or entry.name in _SKILL_FILE_TREE_SKIP_NAMES:
+ continue
+ counter["n"] += 1
+ if entry.is_dir():
+ node.children.append(build(entry, depth + 1))
+ continue
+ try:
+ stat = entry.stat()
+ size = stat.st_size
+ mtime = stat.st_mtime
+ except OSError:
+ size = 0
+ mtime = 0.0
+ node.children.append(
+ SkillFileNode(
+ name=entry.name,
+ type="file",
+ path=entry.relative_to(skill_dir).as_posix(),
+ size=size,
+ mtime=mtime,
+ )
+ )
+ return node
+
+ return build(skill_dir, 0)
+
+
+@router.get(
+ "/skills/{skill_name}/files",
+ response_model=SkillFilesResponse,
+ summary="List Skill Files (Tree)",
+ description=(
+ "Return the full file tree under a skill directory. Only an admin or the "
+ "skill's publisher (owner of a custom skill) may browse files; other users "
+ "get 403."
+ ),
+)
+async def list_skill_files(
+ skill_name: str,
+ request: Request,
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+) -> SkillFilesResponse:
+ try:
+ skill_name = _normalize_skill_name_param(skill_name)
+ skill = _load_skill_or_404(skill_name, config)
+ record = await _ensure_files_view_allowed(skill, request, skill_store)
+ skill_dir = skill.skill_file.parent
+ root = _build_skill_file_tree(skill_dir)
+ return SkillFilesResponse(
+ name=skill.name,
+ category=skill.category,
+ editable=_caller_can_edit_skill_files(skill, record, request),
+ root=root,
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to list skill files for %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to list skill files: {e}")
+
+
+@router.get(
+ "/skills/{skill_name}/files/content",
+ response_model=SkillFileContentResponse,
+ summary="Read Skill File (Text)",
+ description=(
+ "Return JSON metadata for a single file under a skill. Text files include "
+ "their decoded ``content``; binary files omit ``content`` and should be "
+ "fetched from the ``files/download`` endpoint for inline display or "
+ "download. Same view authorisation as the file tree endpoint."
+ ),
+)
+async def get_skill_file_content(
+ skill_name: str,
+ request: Request,
+ path: str = Query(..., description="File path relative to the skill directory"),
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+) -> SkillFileContentResponse:
+ try:
+ skill_name = _normalize_skill_name_param(skill_name)
+ skill = _load_skill_or_404(skill_name, config)
+ record = await _ensure_files_view_allowed(skill, request, skill_store)
+ target = _resolve_skill_relative_path(skill, path)
+ if not target.exists() or not target.is_file():
+ raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'")
+ stat = target.stat()
+ mime = _file_mime(target)
+ is_text = _is_text_file(target)
+ content: str | None = None
+ if is_text:
+ if stat.st_size > _SKILL_FILE_READ_BYTES:
+ raise HTTPException(status_code=413, detail=f"File exceeds the {_SKILL_FILE_READ_BYTES // 1024} KiB preview limit")
+ try:
+ content = target.read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ # File has a text-y extension but is not UTF-8 — treat as binary.
+ is_text = False
+ return SkillFileContentResponse(
+ name=skill.name,
+ category=skill.category,
+ path=target.relative_to(skill.skill_file.parent.resolve()).as_posix(),
+ mime=mime,
+ size=stat.st_size,
+ mtime=stat.st_mtime,
+ is_text=is_text,
+ editable=is_text and _caller_can_edit_skill_files(skill, record, request),
+ content=content,
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to read skill file %s/%s: %s", skill_name, path, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to read skill file: {e}")
+
+
+@router.get(
+ "/skills/{skill_name}/files/download",
+ summary="Download Skill File (Raw Bytes)",
+ description=(
+ "Stream the raw bytes of a single file under a skill, suitable for "
+ "embedding in an ``
![]()
`` tag or for direct download. Same view "
+ "authorisation as the file tree endpoint."
+ ),
+)
+async def download_skill_file(
+ skill_name: str,
+ request: Request,
+ path: str = Query(..., description="File path relative to the skill directory"),
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+):
+ skill_name = _normalize_skill_name_param(skill_name)
+ skill = _load_skill_or_404(skill_name, config)
+ await _ensure_files_view_allowed(skill, request, skill_store)
+ target = _resolve_skill_relative_path(skill, path)
+ if not target.exists() or not target.is_file():
+ raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'")
+ return FileResponse(
+ path=target,
+ media_type=_file_mime(target),
+ filename=target.name,
+ )
+
+
+@router.put(
+ "/skills/{skill_name}/files/content",
+ response_model=SkillFileSaveResponse,
+ summary="Write Skill File (Text)",
+ description=(
+ "Replace the contents of a text file under a custom skill. Only the "
+ "skill's owner or an admin may write; built-in (public) skills are "
+ "read-only. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
+ "refresh the system-prompt cache."
+ ),
+)
+async def save_skill_file(
+ skill_name: str,
+ body: SkillFileSaveRequest,
+ request: Request,
+ config: AppConfig = Depends(get_config),
+ skill_store: SkillStore = Depends(get_skill_store),
+) -> SkillFileSaveResponse:
+ try:
+ skill_name = _normalize_skill_name_param(skill_name)
+ skill = _load_skill_or_404(skill_name, config)
+ if skill.category != SkillCategory.CUSTOM:
+ raise HTTPException(status_code=403, detail="Built-in skills are read-only")
+ record = await skill_store.get_any(skill.name)
+ if not _caller_can_edit_skill_files(skill, record, request):
+ raise HTTPException(status_code=403, detail="Only the skill owner or an admin can edit files in this skill")
+
+ target = _resolve_skill_relative_path(skill, body.path)
+ if not target.exists():
+ raise HTTPException(status_code=404, detail=f"File '{body.path}' not found in skill '{skill_name}'")
+ if target.is_dir():
+ raise HTTPException(status_code=400, detail="path points to a directory")
+ if not _is_text_file(target):
+ raise HTTPException(status_code=400, detail=f"File type '{target.suffix or target.name}' is not editable")
+
+ if len(body.content.encode("utf-8")) > _SKILL_FILE_WRITE_BYTES:
+ raise HTTPException(status_code=413, detail=f"Content exceeds the {_SKILL_FILE_WRITE_BYTES // 1024} KiB edit limit")
+
+ relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix()
+ storage = get_or_new_skill_storage(app_config=config)
+
+ is_skill_md = relative_posix == SKILL_MD_FILE
+ prev_content: str | None = None
+ if target.exists():
+ try:
+ prev_content = target.read_text(encoding="utf-8")
+ except UnicodeDecodeError:
+ prev_content = None
+
+ if is_skill_md:
+ # SKILL.md edits must keep their frontmatter valid (name must match).
+ storage.validate_skill_markdown_content(skill.name, body.content)
+
+ storage.write_custom_skill(skill.name, relative_posix, body.content)
+
+ try:
+ storage.append_history(
+ skill.name,
+ {
+ "action": "human_edit_file",
+ "author": "human",
+ "thread_id": None,
+ "file_path": relative_posix,
+ "prev_content": prev_content,
+ "new_content": body.content,
+ },
+ )
+ except Exception as history_err: # noqa: BLE001 — history is best-effort
+ logger.warning("Failed to append edit history for %s/%s: %s", skill.name, relative_posix, history_err)
+
+ if is_skill_md:
+ try:
+ from deerflow.skills.usage import bump_patch
+ bump_patch(skill.name)
+ except Exception:
+ pass
+ await refresh_skills_system_prompt_cache_async()
+
+ stat = target.stat()
+ return SkillFileSaveResponse(
+ name=skill.name,
+ path=relative_posix,
+ size=stat.st_size,
+ mtime=stat.st_mtime,
+ )
+ except HTTPException:
+ raise
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except FileNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except Exception as e:
+ logger.error("Failed to save skill file %s/%s: %s", skill_name, body.path, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to save skill file: {e}")
+
+
+@router.put(
+ "/skills/{skill_name}",
+ response_model=SkillResponse,
+ summary="Update Skill",
+ description="Update a skill's enabled status by modifying the extensions_config.json file.",
+)
+async def update_skill(skill_name: str, request: SkillUpdateRequest, config: AppConfig = Depends(get_config)) -> 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)
+ 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")
+
+ config_path = ExtensionsConfig.resolve_config_path()
+ if config_path is None:
+ config_path = Path.cwd().parent / "extensions_config.json"
+ logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
+
+ extensions_config = get_extensions_config()
+ current_skill_config = extensions_config.skills.get(skill_name)
+ extensions_config.skills[skill_name] = SkillStateConfig(
+ enabled=request.enabled,
+ display=current_skill_config.display if current_skill_config else None,
+ )
+
+ config_data = _dump_extensions_config(extensions_config)
+
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(config_data, f, indent=2)
+
+ logger.info(f"Skills configuration updated and saved to: {config_path}")
+ reload_extensions_config()
+ await refresh_skills_system_prompt_cache_async()
+
+ skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
+ updated_skill = next((s for s in skills if s.name == skill_name), None)
+
+ if updated_skill is None:
+ raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
+
+ logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}")
+ return _skill_to_response(updated_skill)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to update skill {skill_name}: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
+
+
+@router.put(
+ "/skills/{skill_name}/display",
+ response_model=SkillResponse,
+ summary="Update Skill Display Adapter",
+ description="Update a skill's frontend display adapter in extensions_config.json.",
+)
+async def update_skill_display(
+ skill_name: str,
+ request: SkillDisplayUpdateRequest,
+ config: AppConfig = Depends(get_config),
+) -> 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)
+ 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")
+
+ config_path = ExtensionsConfig.resolve_config_path()
+ if config_path is None:
+ config_path = Path.cwd().parent / "extensions_config.json"
+ logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
+
+ extensions_config = get_extensions_config()
+ current_skill_config = extensions_config.skills.get(skill_name)
+ extensions_config.skills[skill_name] = SkillStateConfig(
+ enabled=current_skill_config.enabled if current_skill_config else skill.enabled,
+ display=request.display,
+ )
+
+ display_payload = request.display.model_dump(by_alias=True) if request.display is not None else None
+ sample_payload = request.sample if "sample" in request.model_fields_set else ...
+ await _upsert_skill_display_adapter(
+ skill_name,
+ display=display_payload,
+ sample=sample_payload,
+ )
+
+ with open(config_path, "w", encoding="utf-8") as f:
+ json.dump(_dump_extensions_config(extensions_config), f, indent=2, ensure_ascii=False)
+
+ reload_extensions_config()
+ await refresh_skills_system_prompt_cache_async()
+ skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
+ updated_skill = next((s for s in skills if s.name == skill_name), None)
+ if updated_skill is None:
+ raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update")
+ adapter = await _get_skill_display_adapter(skill_name) or {}
+ return _skill_to_response(
+ updated_skill,
+ display_override=adapter.get("display"),
+ display_sample=adapter.get("sample"),
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to update display adapter for %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to update skill display adapter: {str(e)}")
+
+
+@router.get(
+ "/skills/{skill_name}/display-sample",
+ response_model=PersistedSkillDisplaySampleResponse,
+ summary="Get Persisted Skill Display Sample",
+ description="Return only the sample stored in the database. Does not generate a fallback sample.",
+)
+async def get_persisted_skill_display_sample(
+ skill_name: str,
+ config: AppConfig = Depends(get_config),
+) -> PersistedSkillDisplaySampleResponse:
+ try:
+ skill_name = skill_name.replace("\r\n", "").replace("\n", "")
+ 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")
+ adapter = await _get_skill_display_adapter(skill_name)
+ sample = adapter.get("sample") if adapter else None
+ return PersistedSkillDisplaySampleResponse(
+ skill_name=skill_name,
+ sample=sample if isinstance(sample, dict) else None,
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to get persisted display sample for %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to get persisted display sample: {str(e)}")
+
+
+@router.post(
+ "/skills/{skill_name}/display-sample",
+ response_model=SkillDisplaySampleResponse,
+ summary="Get Skill Display Sample",
+ description="Generate or return a structured sample response used by the frontend to infer display mapping.",
+)
+async def get_skill_display_sample(
+ skill_name: str,
+ config: AppConfig = Depends(get_config),
+) -> SkillDisplaySampleResponse:
+ try:
+ skill_name = skill_name.replace("\r\n", "").replace("\n", "")
+ 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")
+ adapter = await _get_skill_display_adapter(skill_name)
+ if adapter and isinstance(adapter.get("sample"), dict):
+ return SkillDisplaySampleResponse(skill_name=skill_name, sample=adapter["sample"])
+ sample = await _generate_skill_display_sample(skill, config)
+ await _upsert_skill_display_adapter(skill_name, sample=sample)
+ return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample)
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to get display sample for %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to get display sample: {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")
+
+
+@router.post("/skills/custom/{skill_name}/pin", response_model=SkillResponse, summary="Pin or Unpin Skill")
+async def pin_skill(skill_name: str, request: SkillPinRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
+ """Pin a skill to prevent it from being automatically archived by the curator."""
+ try:
+ skill_name = skill_name.replace("\r\n", "").replace("\n", "")
+ storage = get_or_new_skill_storage(app_config=config)
+ if not storage.custom_skill_exists(skill_name):
+ raise HTTPException(status_code=404, detail=f"Custom skill '{skill_name}' not found")
+ from deerflow.skills.usage import set_pinned
+ set_pinned(skill_name, request.pinned)
+ skills = storage.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")
+ return _skill_to_response(skill)
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to pin skill %s: %s", skill_name, e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to pin skill: {str(e)}")
+
+
+@router.get("/curator/status", summary="Curator Status")
+async def curator_status(request: Request) -> dict:
+ """Return the current curator state and per-lifecycle skill counts for the
+ authenticated user."""
+ try:
+ from deerflow.skills.curator import get_curator_status
+ return get_curator_status(_current_user_id(request))
+ except Exception as e:
+ logger.error("Failed to get curator status: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to get curator status: {str(e)}")
+
+
+@router.post("/curator/run", summary="Trigger Curator Run")
+async def run_curator(request: Request) -> dict:
+ """Manually trigger a curator review pass for the authenticated user
+ (bypasses the interval check, runs in a background thread).
+
+ Pre-checks the master switch / curator switch / run lock so the response
+ accurately reflects whether a run actually started — instead of always
+ reporting success. Returns ``{"triggered": bool, "reason": str | None}``.
+ """
+ try:
+ import threading
+
+ from deerflow.skills.curator import _curator_thread_target, manual_trigger_blocked_reason
+
+ user_id = _current_user_id(request)
+ reason = manual_trigger_blocked_reason(user_id)
+ if reason is not None:
+ return {"triggered": False, "reason": reason}
+ thread = threading.Thread(
+ target=_curator_thread_target,
+ args=(user_id,),
+ daemon=True,
+ name=f"skill-curator-manual-{user_id}",
+ )
+ thread.start()
+ return {"triggered": True, "reason": None}
+ except Exception as e:
+ logger.error("Failed to trigger curator: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to trigger curator: {str(e)}")
+
+
+class CuratorSubConfig(BaseModel):
+ """Nested curator configuration mirroring CuratorConfig."""
+
+ # 与 CuratorConfig.enabled 保持一致:默认关闭,需显式 opt-in。
+ enabled: bool = Field(default=False, description="Whether the curator runs automatically.")
+ interval_hours: int = Field(default=168, ge=1, description="Hours between curator runs.")
+ stale_after_days: int = Field(default=30, ge=1, description="Days of inactivity before a skill is marked stale.")
+ archive_after_days: int = Field(default=90, ge=1, description="Days of inactivity before a stale skill is archived.")
+ review_timeout_seconds: int = Field(default=600, ge=1, description="Overall timeout (seconds) for the LLM review pass.")
+ model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass; null = primary model.")
+
+
+class CuratorConfigResponse(BaseModel):
+ """Response model exposing the effective skill_evolution config."""
+
+ enabled: bool
+ curator: CuratorSubConfig
+
+
+class CuratorConfigRequest(BaseModel):
+ """Payload for updating skill_evolution overrides.
+
+ All fields are required so the frontend always sends a complete config;
+ backend writes the entire payload to skill_evolution.json atomically.
+ """
+
+ enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.")
+ curator: CuratorSubConfig
+
+
+class CuratorLogRecord(BaseModel):
+ ts: str
+ level: str
+ message: str
+
+
+class CuratorLogsResponse(BaseModel):
+ records: list[CuratorLogRecord]
+ run_count: int
+ last_run_at: str | None
+
+
+def _build_curator_config_response(evo) -> CuratorConfigResponse:
+ """Pure helper so both the GET route and the post-PUT refresh share one shape.
+
+ ``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user
+ overrides if any).
+ """
+ return CuratorConfigResponse(
+ enabled=evo.enabled,
+ curator=CuratorSubConfig(
+ enabled=evo.curator.enabled,
+ interval_hours=evo.curator.interval_hours,
+ stale_after_days=evo.curator.stale_after_days,
+ archive_after_days=evo.curator.archive_after_days,
+ review_timeout_seconds=evo.curator.review_timeout_seconds,
+ model_name=evo.curator.model_name,
+ ),
+ )
+
+
+@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config")
+async def get_curator_config(request: Request) -> CuratorConfigResponse:
+ """Return the current curator config for the authenticated user (yaml
+ defaults + that user's overrides)."""
+ try:
+ from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
+
+ evo = load_skill_evolution_config_for_user(_current_user_id(request))
+ return _build_curator_config_response(evo)
+ except Exception as e:
+ logger.error("Failed to read curator config: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to read curator config: {str(e)}")
+
+
+@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config")
+async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse:
+ """Persist per-user overrides to ``users/{user_id}/skill_evolution.json``
+ and return the effective merged config.
+
+ The payload is validated against the ``SkillEvolutionConfig`` shape; unknown
+ fields are rejected.
+ """
+ try:
+ from deerflow.config.skill_evolution_config import SkillEvolutionConfig
+ from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides
+
+ user_id = _current_user_id(request)
+ payload = body.model_dump()
+ # Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid.
+ SkillEvolutionConfig.model_validate(payload)
+ save_user_overrides(user_id, payload)
+ return _build_curator_config_response(load_skill_evolution_config_for_user(user_id))
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("Failed to update curator config: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to update curator config: {str(e)}")
+
+
+@router.get("/curator/logs", response_model=CuratorLogsResponse, summary="Recent Curator Logs")
+async def get_curator_logs(request: Request) -> CuratorLogsResponse:
+ """Return in-memory log records from the most recent curator run for the
+ authenticated user.
+
+ The buffer is process-local and cleared when curator starts a new run or
+ when the backend restarts. Records are ordered oldest → newest.
+ """
+ try:
+ from deerflow.skills import curator_log
+ from deerflow.skills.curator import get_curator_status
+
+ user_id = _current_user_id(request)
+ records = curator_log.get_recent(user_id)
+ status = get_curator_status(user_id)
+ return CuratorLogsResponse(
+ records=[CuratorLogRecord(**r) for r in records],
+ run_count=int(status.get("run_count", 0)),
+ last_run_at=status.get("last_run_at"),
+ )
+ except Exception as e:
+ logger.error("Failed to read curator logs: %s", e, exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to read curator logs: {str(e)}")
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
index 3b19bb8d..2adfd763 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
@@ -30,7 +30,7 @@ from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskSubscriptionRow,
SchedulerThreadRow,
)
-from deerflow.persistence.skills.model import SkillRow
+from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.thread_shares.model import ThreadShareRow
@@ -51,6 +51,7 @@ __all__ = [
"ScheduledTaskSubscriptionRow",
"SchedulerThreadRow",
"SkillRow",
+ "SkillDisplayAdapterRow",
"TagAssignmentRow",
"TagRow",
"ThreadMetaRow",
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/model.py
index fa6f1d86..ff981c15 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/model.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/skills/model.py
@@ -31,3 +31,16 @@ class SkillRow(Base):
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))
+
+
+class SkillDisplayAdapterRow(Base):
+ __tablename__ = "skill_display_adapters"
+
+ skill_name: Mapped[str] = mapped_column(String(64), primary_key=True)
+ # Store JSON as text for maximum MySQL compatibility. Some deployments still
+ # run MySQL variants where native JSON columns or unquoted names such as
+ # ``display`` are not accepted consistently.
+ display_config: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
+ sample_payload: 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))
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py b/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py
index b4016822..23b25dbe 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py
@@ -37,14 +37,26 @@ class LocalSandbox(Sandbox):
"""Return whether the selected shell is a PowerShell executable."""
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
- @staticmethod
- def _is_cmd_shell(shell: str) -> bool:
- """Return whether the selected shell is cmd.exe."""
- return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
-
- @staticmethod
- def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
- """Return the first executable shell path or command found from candidates."""
+ @staticmethod
+ def _is_cmd_shell(shell: str) -> bool:
+ """Return whether the selected shell is cmd.exe."""
+ return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
+
+ @staticmethod
+ def _normalize_powershell_command(command: str) -> str:
+ """Make bash-style command glue usable in Windows PowerShell 5.
+
+ The public tool is still named ``bash`` for model compatibility, but on
+ Windows local sandbox it may fall back to PowerShell. PowerShell 5 does
+ not support ``&&``, while the sandbox layer prepends commands with
+ ``cd
&&``. Rewriting the separator keeps skill scripts
+ executable in that fallback shell.
+ """
+ return command.replace("&&", ";")
+
+ @staticmethod
+ def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
+ """Return the first executable shell path or command found from candidates."""
for shell in candidates:
if os.path.isabs(shell):
if os.path.isfile(shell) and os.access(shell, os.X_OK):
@@ -302,30 +314,39 @@ class LocalSandbox(Sandbox):
resolved_command = self._resolve_paths_in_command(command)
shell = self._get_shell()
- if os.name == "nt":
- if self._is_powershell(shell):
- args = [shell, "-NoProfile", "-Command", resolved_command]
+ if os.name == "nt":
+ if self._is_powershell(shell):
+ args = [
+ shell,
+ "-NoProfile",
+ "-Command",
+ self._normalize_powershell_command(resolved_command),
+ ]
elif self._is_cmd_shell(shell):
args = [shell, "/c", resolved_command]
else:
args = [shell, "-c", resolved_command]
- result = subprocess.run(
- args,
- shell=False,
- capture_output=True,
- text=True,
- timeout=600,
- )
- else:
- args = [shell, "-c", resolved_command]
- result = subprocess.run(
- args,
- shell=False,
- capture_output=True,
- text=True,
- timeout=600,
- )
+ result = subprocess.run(
+ args,
+ shell=False,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=600,
+ )
+ else:
+ args = [shell, "-c", resolved_command]
+ result = subprocess.run(
+ args,
+ shell=False,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ timeout=600,
+ )
output = result.stdout
if result.stderr:
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr
From 26a388e3bf1fa973539a0529cd2adf5ec745c40a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?=
Date: Tue, 26 May 2026 14:24:29 +0800
Subject: [PATCH 05/15] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=99=BA=E8=83=BD?=
=?UTF-8?q?=E4=BD=93=E7=A7=81=E5=AF=86=E5=B9=BF=E5=9C=BA=EF=BC=88is=5Fpriv?=
=?UTF-8?q?ate=20+=20=E7=AE=A1=E7=90=86=E5=91=98=E5=AF=86=E7=A0=81?=
=?UTF-8?q?=E9=97=A8=E6=8E=A7=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- agents 表新增 is_private 列与迁移 20260526_01;列表 filter 拆分为公开
广场用的 _listing_filter(剔除私密)与单条访问用的 _visible_filter(保留可达)
- system_settings 增 PrivateSquareSettings(enabled + password_hash),
对应 GET/PUT /api/system-settings/private-square 由管理员管理
- /api/agents 接口:列表/创建/更新新增 is_private 字段;新增
POST /api/agents/private-square/list(密码校验后返回私密智能体)与
PUT /api/agents/{id}/private(管理员跨用户搬动智能体)
- 前端「智能体」页签新增「私密广场」入口(密码校验 + sessionStorage 缓存 +
锁定按钮),卡片增加紫色私密徽章与盾牌切换按钮(owner 自助 / 管理员跨用户),
「广场」与首页推荐候选均剔除私密智能体
- 新增管理员配置页 /settings/private-square 与侧边栏入口
Co-Authored-By: Claude Opus 4.7 (1M context)
---
frontend-web/src/components/page-sidebar.tsx | 8 +
.../workspace/agents/agent-card.tsx | 54 ++++-
.../workspace/agents/agent-gallery.tsx | 198 +++++++++++++++++-
.../workspace/agents/agent-manage-dialog.tsx | 7 +-
.../workspace/workspace-nav-menu.tsx | 11 +
frontend-web/src/core/agents/api.ts | 40 ++++
frontend-web/src/core/agents/hooks.ts | 33 +++
frontend-web/src/core/agents/types.ts | 3 +
frontend-web/src/core/system-settings/api.ts | 42 ++++
.../src/core/system-settings/hooks.ts | 24 +++
.../src/pages/PrivateSquareSettingsPage.tsx | 155 ++++++++++++++
frontend-web/src/pages/StrategyRoutes.tsx | 11 +
frontend-web/src/pages/WorkspaceRoutes.tsx | 13 ++
.../backend/app/gateway/routers/agents.py | 105 ++++++++++
.../app/gateway/routers/system_settings.py | 63 +++++-
.../deerflow/config/system_settings.py | 15 ++
.../deerflow/persistence/agents/base.py | 9 +
.../deerflow/persistence/agents/model.py | 6 +
.../deerflow/persistence/agents/sql.py | 40 +++-
.../versions/20260526_01_agents_is_private.py | 62 ++++++
20 files changed, 879 insertions(+), 20 deletions(-)
create mode 100644 frontend-web/src/pages/PrivateSquareSettingsPage.tsx
create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260526_01_agents_is_private.py
diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx
index 3b476e14..90b67c3c 100644
--- a/frontend-web/src/components/page-sidebar.tsx
+++ b/frontend-web/src/components/page-sidebar.tsx
@@ -26,6 +26,7 @@ import {
ListTodo,
MessageSquareCode,
MessageSquarePlus,
+ ShieldCheck,
Network,
NotebookPen,
Plus,
@@ -191,6 +192,13 @@ const menuItems: MenuItem[] = [
path: "/page/strategy/settings/prompt-prefix",
adminOnly: true,
},
+ {
+ id: "mgmt-private-square",
+ label: "私密广场配置",
+ icon: ShieldCheck,
+ path: "/page/strategy/settings/private-square",
+ adminOnly: true,
+ },
{
id: "mgmt-llm-metrics",
label: "大模型调用统计",
diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx
index 0842da4e..aac8f52b 100644
--- a/frontend-web/src/components/workspace/agents/agent-card.tsx
+++ b/frontend-web/src/components/workspace/agents/agent-card.tsx
@@ -6,6 +6,7 @@ import {
InfoIcon,
LockIcon,
MessageSquareIcon,
+ ShieldIcon,
SparklesIcon,
SquarePenIcon,
TagsIcon,
@@ -42,6 +43,7 @@ import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
useDeleteAgent,
useSetAgentFeatured,
+ useSetAgentPrivate,
useUpdateAgent,
} from "@/core/agents";
import type { Agent } from "@/core/agents";
@@ -61,6 +63,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const deleteAgent = useDeleteAgent();
const updateAgent = useUpdateAgent();
const setFeatured = useSetAgentFeatured();
+ const setPrivate = useSetAgentPrivate();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -155,6 +158,27 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
+ async function handleTogglePrivate() {
+ const turningOn = !agent.is_private;
+ try {
+ if (isOwned) {
+ // Owners can re-categorize their own agents through the normal update
+ // endpoint — keeps publish/private edits in one transaction.
+ await updateAgent.mutateAsync({
+ id: agent.id,
+ request: { is_private: turningOn },
+ });
+ } else {
+ // Admins can move any agent (built-in or other users') via the
+ // dedicated admin endpoint.
+ await setPrivate.mutateAsync({ id: agent.id, isPrivate: turningOn });
+ }
+ toast.success(turningOn ? "已加入私密广场" : "已移出私密广场");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : String(err));
+ }
+ }
+
async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
// dialog. Featured agents get appended to the end on add (Date.now() is a
@@ -229,7 +253,7 @@ export function AgentCard({ agent }: AgentCardProps) {
来自他人
)}
- {isOwned && agent.published && (
+ {isOwned && agent.published && !agent.is_private && (
)}
+ {agent.is_private && (
+
+
+ 私密
+
+ )}
{agent.featured_order != null && (
)}
+ {(isOwned || isAdmin) && (
+
+ )}
{canEdit && (