- 智能体支持从广场收藏到「我的」(新增 agent_favorites 表与收藏/取消收藏接口) - 定时任务新增「必须生成Markdown文件」开关:注入提示词并校验生成的 .md 文件且正文超过100字,否则记为失败(存于 execution_context,无需迁移) - 聊天问答展示时间(年月日 时分):问题在右下角、回答在左下角 - 常用提示词改为与账号绑定(新增 user_custom_prompts 表与 /api/user-prompts 接口),仅在首页输入框上方以居中标签 + 轮播展示,齿轮置于最右侧管理 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
986 lines
40 KiB
Python
986 lines
40 KiB
Python
"""CRUD API for custom agents."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import shutil
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
import yaml
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.auth.password import verify_password_async
|
|
from app.gateway.deps import get_agent_store, get_config, get_tag_store
|
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
|
from deerflow.config.agents_api_config import get_agents_api_config
|
|
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.paths import get_paths
|
|
from deerflow.config.system_settings import load_system_settings
|
|
from deerflow.persistence.agents import AgentStore
|
|
from deerflow.persistence.tags import TagStore
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
from deerflow.skills.cascade import sync_agent_skills_to_available
|
|
from deerflow.skills.storage import get_or_new_skill_storage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api", tags=["agents"])
|
|
|
|
|
|
class AgentResponse(BaseModel):
|
|
"""Response model for an agent."""
|
|
|
|
id: str = Field(..., description="Agent id used for routing and filesystem storage")
|
|
name: str = Field(..., description="Display name. Chinese and duplicate names are allowed.")
|
|
description: str = Field(default="", description="Agent description")
|
|
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
|
|
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
|
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
|
|
builtin: bool = Field(default=False, description="True when user_id is null")
|
|
created_at: datetime | str | None = None
|
|
updated_at: datetime | str | None = None
|
|
soul: str | None = Field(default=None, description="SOUL.md content")
|
|
|
|
# Deprecated compatibility fields. Agent runtime configuration is no longer
|
|
# stored in the agents table, but older frontends may still read these keys.
|
|
model: str | None = None
|
|
tool_groups: list[str] | None = None
|
|
skills: list[str] | None = None
|
|
|
|
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
|
|
|
|
featured_order: int | None = Field(
|
|
default=None,
|
|
description="Admin-curated homepage rank. Null = not featured. Lower sorts first.",
|
|
)
|
|
|
|
is_favorite: bool = Field(
|
|
default=False,
|
|
description="Whether the requesting user has favorited (收藏) this agent.",
|
|
)
|
|
|
|
|
|
class AgentsListResponse(BaseModel):
|
|
"""Response model for listing all visible agents."""
|
|
|
|
agents: list[AgentResponse]
|
|
|
|
|
|
class AgentCreateRequest(BaseModel):
|
|
"""Request body for creating a custom agent."""
|
|
|
|
id: str | None = Field(
|
|
default=None,
|
|
description="Optional explicit storage id. Must match the storage id pattern. If omitted, a UUID is generated.",
|
|
)
|
|
name: str = Field(..., description="Display name; Chinese and duplicate names are allowed")
|
|
description: str = Field(default="", description="Agent description")
|
|
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
|
published: bool = Field(default=False, description="Whether the new agent should be public")
|
|
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
|
|
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
|
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
|
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
|
|
|
|
|
class AgentUpdateRequest(BaseModel):
|
|
"""Request body for updating a custom agent."""
|
|
|
|
name: str | None = Field(default=None, description="Updated display name")
|
|
description: str | None = Field(default=None, description="Updated description")
|
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
|
published: bool | None = Field(default=None, description="Updated publication state")
|
|
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
|
|
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
|
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
|
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
|
|
|
|
|
class AgentSelectorResponse(BaseModel):
|
|
"""Response for the homepage agent selector dropdown.
|
|
|
|
The list is fully curated by admins (``featured_order`` on each row).
|
|
Non-admin users get a read-only view of the same list.
|
|
"""
|
|
|
|
agents: list[AgentResponse] = Field(
|
|
default_factory=list,
|
|
description="Admin-curated agents, sorted by featured_order ascending.",
|
|
)
|
|
|
|
|
|
class AgentFeaturedRequest(BaseModel):
|
|
"""Request body for setting/clearing an agent's homepage featured rank."""
|
|
|
|
order: int | None = Field(
|
|
default=None,
|
|
description="Featured rank. Lower sorts first. Pass null to remove the agent from the homepage selector.",
|
|
)
|
|
|
|
|
|
def _require_agents_api_enabled() -> None:
|
|
"""Reject access unless the custom-agent management API is explicitly enabled."""
|
|
if not get_agents_api_config().enabled:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=("Custom-agent management API is disabled. Set agents_api.enabled=true to expose agent and user-profile routes over HTTP."),
|
|
)
|
|
|
|
|
|
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:
|
|
"""Return True when the request is authenticated as a system admin."""
|
|
user = getattr(request.state, "user", None)
|
|
return getattr(user, "system_role", None) == "admin"
|
|
|
|
|
|
def _validate_display_name(name: str) -> str:
|
|
normalized = name.strip()
|
|
if not normalized:
|
|
raise HTTPException(status_code=422, detail="Agent name cannot be empty")
|
|
return normalized
|
|
|
|
|
|
def _validate_storage_id(agent_id: str) -> str:
|
|
try:
|
|
return validate_agent_id(agent_id) or agent_id
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
|
|
|
|
def _write_agent_config(
|
|
agent_id: str,
|
|
*,
|
|
name: str,
|
|
description: str,
|
|
skills: list[str] | None = None,
|
|
model: str | None = None,
|
|
tool_groups: list[str] | None = None,
|
|
) -> None:
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
config_file = agent_dir / "config.yaml"
|
|
config_data: dict[str, Any] = {
|
|
"id": agent_id,
|
|
"name": name,
|
|
"description": description or "",
|
|
}
|
|
if skills is not None:
|
|
config_data["skills"] = skills
|
|
if model is not None:
|
|
config_data["model"] = model
|
|
if tool_groups is not None:
|
|
config_data["tool_groups"] = tool_groups
|
|
with open(config_file, "w", encoding="utf-8") as f:
|
|
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
|
|
|
|
|
def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
|
"""Read skills/model/tool_groups from the agent's config.yaml, tolerant of missing files."""
|
|
try:
|
|
cfg = load_agent_config(agent_id)
|
|
except (FileNotFoundError, ValueError):
|
|
return {"skills": None, "model": None, "tool_groups": None}
|
|
if cfg is None:
|
|
return {"skills": None, "model": None, "tool_groups": None}
|
|
return {"skills": cfg.skills, "model": cfg.model, "tool_groups": cfg.tool_groups}
|
|
|
|
|
|
def _write_agent_soul(agent_id: str, soul: str) -> None:
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
(agent_dir / "SOUL.md").write_text(soul, encoding="utf-8")
|
|
|
|
|
|
async def _sync_legacy_agents(store: AgentStore) -> None:
|
|
"""Import existing folder-backed agents as built-in DB records.
|
|
|
|
This preserves old deployments where agents already existed under
|
|
``.deer-flow/agents/{name}`` before the agents table was introduced.
|
|
"""
|
|
try:
|
|
for agent_cfg in list_custom_agents():
|
|
agent_id = agent_cfg.id or agent_cfg.name
|
|
if not agent_id:
|
|
continue
|
|
await store.ensure_builtin(
|
|
{
|
|
"id": agent_id,
|
|
"name": agent_cfg.name,
|
|
"description": agent_cfg.description or "",
|
|
"published": True,
|
|
}
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to sync legacy folder-backed agents")
|
|
|
|
|
|
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
|
|
"""Lazily restore an agent's on-disk artifacts from its DB record.
|
|
|
|
The DB row is authoritative for the agent's existence. If the matching
|
|
``{base_dir}/agents/{id}/`` directory is missing (data dir moved between
|
|
machines, filesystem wiped, etc.) we recreate it with a minimal
|
|
``config.yaml`` + empty ``SOUL.md`` synthesized from the row. This restores
|
|
a usable agent without requiring the admin to manually delete + recreate.
|
|
"""
|
|
agent_id = record.get("id")
|
|
if not agent_id:
|
|
return
|
|
try:
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
if agent_dir.exists():
|
|
return
|
|
logger.warning(
|
|
"Agent dir missing for '%s' — rebuilding minimal config.yaml + SOUL.md from DB",
|
|
agent_id,
|
|
)
|
|
_write_agent_config(
|
|
agent_id,
|
|
name=record.get("name") or agent_id,
|
|
description=record.get("description") or "",
|
|
)
|
|
_write_agent_soul(agent_id, "")
|
|
except Exception:
|
|
# Self-heal is best-effort. ``load_agent_config`` already tolerates a
|
|
# missing dir, so the worst case is the agent runs with default
|
|
# settings until someone re-edits it.
|
|
logger.exception("Failed to self-heal agent dir for '%s'", agent_id)
|
|
|
|
|
|
def _record_to_response(
|
|
record: dict[str, Any],
|
|
*,
|
|
include_soul: bool = False,
|
|
) -> AgentResponse:
|
|
_ensure_agent_dir_from_record(record)
|
|
soul: str | None = None
|
|
if include_soul:
|
|
soul = load_agent_soul(record["id"]) or ""
|
|
|
|
extras = _load_config_extras(record["id"])
|
|
|
|
return AgentResponse(
|
|
id=record["id"],
|
|
name=record["name"],
|
|
description=record.get("description") or "",
|
|
user_id=record.get("user_id"),
|
|
published=bool(record.get("published", False)),
|
|
is_private=bool(record.get("is_private", False)),
|
|
builtin=record.get("user_id") is None,
|
|
created_at=record.get("created_at"),
|
|
updated_at=record.get("updated_at"),
|
|
soul=soul,
|
|
model=extras["model"],
|
|
tool_groups=extras["tool_groups"],
|
|
skills=extras["skills"],
|
|
featured_order=record.get("featured_order"),
|
|
)
|
|
|
|
|
|
async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]:
|
|
owned = await store.get_owned(agent_id, user_id)
|
|
if owned is not None:
|
|
return owned
|
|
visible = await store.get_visible(agent_id, user_id)
|
|
if visible is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
|
|
|
|
|
async def _get_editable_or_raise(
|
|
store: AgentStore,
|
|
agent_id: str,
|
|
user_id: str,
|
|
*,
|
|
is_admin: bool,
|
|
) -> tuple[dict[str, Any], str]:
|
|
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
|
|
|
Admins may edit any agent — built-in (``user_id IS NULL``) or owned by
|
|
another user — so the admin console can manage the full agent catalog.
|
|
Non-admin callers are still restricted to agents they own.
|
|
"""
|
|
owned = await store.get_owned(agent_id, user_id)
|
|
if owned is not None:
|
|
return owned, "owner"
|
|
if is_admin:
|
|
record = await store.get_any(agent_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
return record, "admin"
|
|
visible = await store.get_visible(agent_id, user_id)
|
|
if visible is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
|
|
|
|
|
@router.get(
|
|
"/agents",
|
|
response_model=AgentsListResponse,
|
|
summary="List Agents",
|
|
description="List built-in agents, the current user's agents, and published agents.",
|
|
)
|
|
async def list_agents(
|
|
request: Request,
|
|
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
|
|
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
|
|
admin: bool = Query(
|
|
default=False,
|
|
description=(
|
|
"Admin-only override. When ``true`` and the caller is a system admin, "
|
|
"every agent is returned (built-in + every user's agents + private-square "
|
|
"entries) regardless of the usual ownership/visibility filter."
|
|
),
|
|
),
|
|
) -> AgentsListResponse:
|
|
_require_agents_api_enabled()
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
tag_store: TagStore = get_tag_store(request)
|
|
await _sync_legacy_agents(store)
|
|
user_id = _current_user_id(request)
|
|
if admin:
|
|
if not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
|
|
agents = await store.list_all()
|
|
else:
|
|
agents = await store.list_visible(user_id)
|
|
responses = [_record_to_response(a, include_soul=True) for a in agents]
|
|
|
|
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
|
favorite_ids = await store.list_favorite_ids(user_id)
|
|
for resp in responses:
|
|
resp.tags = assignments.get(resp.id, [])
|
|
resp.is_favorite = resp.id in favorite_ids
|
|
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 AgentsListResponse(agents=responses)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to list agents: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") from e
|
|
|
|
|
|
@router.get(
|
|
"/agents/check",
|
|
summary="Check Agent Name",
|
|
description="Validate an agent display name. Duplicate names are allowed.",
|
|
)
|
|
async def check_agent_name(name: str) -> dict[str, Any]:
|
|
_require_agents_api_enabled()
|
|
normalized = _validate_display_name(name)
|
|
return {"available": True, "name": normalized}
|
|
|
|
|
|
@router.get(
|
|
"/agents/selector",
|
|
response_model=AgentSelectorResponse,
|
|
summary="Agent Homepage Selector",
|
|
description=(
|
|
"Return the admin-curated agent list shown on the new-chat homepage. "
|
|
"Sorted by ``featured_order`` ascending. Visibility-filtered, so an "
|
|
"agent the caller can't see (author un-published) is silently omitted."
|
|
),
|
|
)
|
|
async def get_agent_selector(request: Request) -> AgentSelectorResponse:
|
|
_require_agents_api_enabled()
|
|
try:
|
|
store = get_agent_store(request)
|
|
await _sync_legacy_agents(store)
|
|
|
|
user_id = _current_user_id(request)
|
|
records = await store.list_featured(user_id)
|
|
return AgentSelectorResponse(agents=[_record_to_response(r) for r in records])
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to load agent selector: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to load agent selector: {str(e)}") from e
|
|
|
|
|
|
@router.get(
|
|
"/agents/{agent_id}",
|
|
response_model=AgentResponse,
|
|
summary="Get Agent",
|
|
description="Retrieve details and SOUL.md content for a visible agent.",
|
|
)
|
|
async def get_agent(agent_id: str, request: Request) -> AgentResponse:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
await _sync_legacy_agents(store)
|
|
user_id = _current_user_id(request)
|
|
record = await store.get_visible(agent_id, user_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
response = _record_to_response(record, include_soul=True)
|
|
favorite_ids = await store.list_favorite_ids(user_id)
|
|
response.is_favorite = agent_id in favorite_ids
|
|
return response
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to get agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}") from e
|
|
|
|
|
|
@router.post(
|
|
"/agents",
|
|
response_model=AgentResponse,
|
|
status_code=201,
|
|
summary="Create Agent",
|
|
description="Create a new user-owned agent. The display name can be Chinese and may duplicate existing names.",
|
|
)
|
|
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
|
|
_require_agents_api_enabled()
|
|
name = _validate_display_name(request.name)
|
|
store = get_agent_store(http_request)
|
|
|
|
if request.id:
|
|
agent_id = _validate_storage_id(request.id)
|
|
if await store.get_any(agent_id) is not None:
|
|
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
|
else:
|
|
agent_id = uuid4().hex
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
|
|
try:
|
|
if agent_dir.exists():
|
|
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
|
|
|
_write_agent_config(
|
|
agent_id,
|
|
name=name,
|
|
description=request.description or "",
|
|
skills=request.skills,
|
|
model=request.model,
|
|
tool_groups=request.tool_groups,
|
|
)
|
|
_write_agent_soul(agent_id, request.soul)
|
|
|
|
record = await store.create(
|
|
{
|
|
"id": agent_id,
|
|
"name": name,
|
|
"description": request.description or "",
|
|
"user_id": _current_user_id(http_request),
|
|
"published": request.published,
|
|
"is_private": request.is_private,
|
|
}
|
|
)
|
|
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
|
|
return _record_to_response(record, include_soul=True)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
if agent_dir.exists():
|
|
shutil.rmtree(agent_dir)
|
|
logger.error("Failed to create agent '%s': %s", request.name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}") from e
|
|
|
|
|
|
@router.put(
|
|
"/agents/{agent_id}",
|
|
response_model=AgentResponse,
|
|
summary="Update Agent",
|
|
description="Update an existing user-owned agent.",
|
|
)
|
|
async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request: Request) -> AgentResponse:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
try:
|
|
store = get_agent_store(http_request)
|
|
user_id = _current_user_id(http_request)
|
|
is_admin = _is_admin_user(http_request)
|
|
current, edit_mode = await _get_editable_or_raise(
|
|
store, agent_id, user_id, is_admin=is_admin
|
|
)
|
|
|
|
fields_set = request.model_fields_set
|
|
update_data: dict[str, Any] = {}
|
|
if "name" in fields_set:
|
|
update_data["name"] = _validate_display_name(request.name or "")
|
|
if "description" in fields_set:
|
|
update_data["description"] = request.description or ""
|
|
if "published" in fields_set:
|
|
update_data["published"] = bool(request.published)
|
|
if "is_private" in fields_set:
|
|
update_data["is_private"] = bool(request.is_private)
|
|
# Mutual exclusion: an agent moved into the private square cannot
|
|
# also be publicly published. Force ``published=false`` whenever the
|
|
# same request sets ``is_private=true``, even if the client forgot
|
|
# to clear it. (When the request only sets one of the two, the
|
|
# other side is left untouched.)
|
|
if update_data.get("is_private") is True:
|
|
update_data["published"] = False
|
|
|
|
next_name = update_data.get("name", current["name"])
|
|
next_description = update_data.get("description", current.get("description") or "")
|
|
|
|
existing_extras = _load_config_extras(agent_id)
|
|
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
|
|
next_model = request.model if "model" in fields_set else existing_extras["model"]
|
|
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
|
|
|
|
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups"))
|
|
if config_dirty:
|
|
_write_agent_config(
|
|
agent_id,
|
|
name=next_name,
|
|
description=next_description,
|
|
skills=next_skills,
|
|
model=next_model,
|
|
tool_groups=next_tool_groups,
|
|
)
|
|
if request.soul is not None:
|
|
_write_agent_soul(agent_id, request.soul)
|
|
|
|
if not update_data:
|
|
record = current
|
|
elif edit_mode == "admin":
|
|
# Admins may edit built-in (``user_id IS NULL``) and any user-owned
|
|
# agent — ``update_any`` skips the ownership check that ``update``
|
|
# and ``update_builtin`` enforce.
|
|
record = await store.update_any(agent_id, update_data)
|
|
else:
|
|
record = await store.update(agent_id, user_id, update_data)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
|
|
logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
|
|
return _record_to_response(record, include_soul=True)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to update agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") from e
|
|
|
|
|
|
class UserProfileResponse(BaseModel):
|
|
"""Response model for the global user profile (USER.md)."""
|
|
|
|
content: str | None = Field(default=None, description="USER.md content, or null if not yet created")
|
|
|
|
|
|
class UserProfileUpdateRequest(BaseModel):
|
|
"""Request body for setting the global user profile."""
|
|
|
|
content: str = Field(default="", description="USER.md content - describes the user's background and preferences")
|
|
|
|
|
|
@router.get(
|
|
"/user-profile",
|
|
response_model=UserProfileResponse,
|
|
summary="Get User Profile",
|
|
description="Read the global USER.md file that is injected into all custom agents.",
|
|
)
|
|
async def get_user_profile() -> UserProfileResponse:
|
|
_require_agents_api_enabled()
|
|
|
|
try:
|
|
user_md_path = get_paths().user_md_file
|
|
if not user_md_path.exists():
|
|
return UserProfileResponse(content=None)
|
|
raw = user_md_path.read_text(encoding="utf-8").strip()
|
|
return UserProfileResponse(content=raw or None)
|
|
except Exception as e:
|
|
logger.error("Failed to read user profile: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to read user profile: {str(e)}") from e
|
|
|
|
|
|
@router.put(
|
|
"/user-profile",
|
|
response_model=UserProfileResponse,
|
|
summary="Update User Profile",
|
|
description="Write the global USER.md file that is injected into all custom agents.",
|
|
)
|
|
async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse:
|
|
_require_agents_api_enabled()
|
|
|
|
try:
|
|
paths = get_paths()
|
|
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
|
paths.user_md_file.write_text(request.content, encoding="utf-8")
|
|
logger.info("Updated USER.md at %s", paths.user_md_file)
|
|
return UserProfileResponse(content=request.content or None)
|
|
except Exception as e:
|
|
logger.error("Failed to update user profile: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}") from e
|
|
|
|
|
|
@router.delete(
|
|
"/agents/{agent_id}",
|
|
status_code=204,
|
|
summary="Delete Agent",
|
|
description="Delete a user-owned agent and all its files.",
|
|
)
|
|
async def delete_agent(agent_id: str, request: Request) -> None:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
user_id = _current_user_id(request)
|
|
is_admin = _is_admin_user(request)
|
|
_, edit_mode = await _get_editable_or_raise(
|
|
store, agent_id, user_id, is_admin=is_admin
|
|
)
|
|
|
|
if edit_mode == "admin":
|
|
deleted = await store.delete_any(agent_id)
|
|
else:
|
|
deleted = await store.delete(agent_id, user_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
if agent_dir.exists():
|
|
shutil.rmtree(agent_dir)
|
|
try:
|
|
await get_tag_store(request).unassign_all("agent", agent_id)
|
|
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
|
|
pass
|
|
try:
|
|
await store.delete_favorites_for_agent(agent_id)
|
|
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
|
|
pass
|
|
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to delete agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e
|
|
|
|
|
|
@router.post(
|
|
"/agents/{agent_id}/favorite",
|
|
status_code=204,
|
|
summary="Favorite Agent",
|
|
description="Bookmark a visible agent into the current user's 我的 tab. Idempotent.",
|
|
)
|
|
async def favorite_agent(agent_id: str, request: Request) -> None:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
user_id = _current_user_id(request)
|
|
record = await store.get_visible(agent_id, user_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
await store.add_favorite(user_id, agent_id)
|
|
logger.info("User '%s' favorited agent '%s'", user_id, agent_id)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to favorite agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to favorite agent: {str(e)}") from e
|
|
|
|
|
|
@router.delete(
|
|
"/agents/{agent_id}/favorite",
|
|
status_code=204,
|
|
summary="Unfavorite Agent",
|
|
description="Remove an agent from the current user's favorites. No-op when not favorited.",
|
|
)
|
|
async def unfavorite_agent(agent_id: str, request: Request) -> None:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
user_id = _current_user_id(request)
|
|
await store.remove_favorite(user_id, agent_id)
|
|
logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
|
|
|
|
|
|
@router.put(
|
|
"/agents/{agent_id}/featured",
|
|
response_model=AgentResponse,
|
|
summary="Set Agent Featured Rank (admin)",
|
|
description=(
|
|
"Admin-only. Set or clear the homepage-selector featured rank for any agent. "
|
|
"Requires the agent to be ``published=true`` when a non-null rank is supplied; "
|
|
"passing ``order: null`` removes the agent from the selector and is always allowed."
|
|
),
|
|
)
|
|
async def set_agent_featured(
|
|
agent_id: str,
|
|
body: AgentFeaturedRequest,
|
|
request: Request,
|
|
) -> AgentResponse:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
if not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can set the homepage featured rank")
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
await _sync_legacy_agents(store)
|
|
record = await store.get_any(agent_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
|
|
if body.order is not None and not record.get("published"):
|
|
raise HTTPException(status_code=409, detail="Only published agents can be featured on the homepage")
|
|
|
|
updated = await store.set_featured(agent_id, body.order)
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
|
|
return _record_to_response(updated, include_soul=True)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to set featured for agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
|
|
|
|
|
|
class PrivateSquareListRequest(BaseModel):
|
|
"""Request body for listing all private-square agents."""
|
|
|
|
password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.")
|
|
|
|
|
|
class AgentPrivateUpdateRequest(BaseModel):
|
|
"""Admin-only request body for moving an agent into / out of the private square."""
|
|
|
|
is_private: bool = Field(..., description="Set true to move the agent into the private square, false to move it back into the public square.")
|
|
|
|
|
|
@router.post(
|
|
"/agents/private-square/list",
|
|
response_model=AgentsListResponse,
|
|
summary="List Private-Square Agents",
|
|
description=(
|
|
"Verify the admin-configured private-square password and return every "
|
|
"agent flagged ``is_private=true``. Returns 404 when no password is set, "
|
|
"403 when the supplied password does not match, and the full agent list "
|
|
"otherwise — regardless of ownership, since the password is the gate."
|
|
),
|
|
)
|
|
async def list_private_square(body: PrivateSquareListRequest, request: Request) -> AgentsListResponse:
|
|
_require_agents_api_enabled()
|
|
|
|
settings = load_system_settings().private_square
|
|
if not settings.password_hash:
|
|
raise HTTPException(status_code=404, detail="Private square password is not configured")
|
|
if not body.password or not await verify_password_async(body.password, settings.password_hash):
|
|
raise HTTPException(status_code=403, detail="Incorrect private square password")
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
tag_store: TagStore = get_tag_store(request)
|
|
await _sync_legacy_agents(store)
|
|
records = await store.list_private()
|
|
responses = [_record_to_response(r, include_soul=True) for r in records]
|
|
|
|
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
|
for resp in responses:
|
|
resp.tags = assignments.get(resp.id, [])
|
|
return AgentsListResponse(agents=responses)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to list private agents: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to list private agents: {str(e)}") from e
|
|
|
|
|
|
@router.put(
|
|
"/agents/{agent_id}/private",
|
|
response_model=AgentResponse,
|
|
summary="Move Agent Between Squares (admin)",
|
|
description=(
|
|
"Admin-only. Move any agent (built-in or user-owned) into or out of the "
|
|
"password-gated private square. Owners can already toggle the flag on "
|
|
"their own agents via the regular ``PUT /agents/{id}`` endpoint; this "
|
|
"endpoint exists so admins can re-categorize other users' agents too."
|
|
),
|
|
)
|
|
async def set_agent_private(
|
|
agent_id: str,
|
|
body: AgentPrivateUpdateRequest,
|
|
request: Request,
|
|
) -> AgentResponse:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
if not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can re-categorize other users' agents")
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
await _sync_legacy_agents(store)
|
|
record = await store.get_any(agent_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
|
|
update: dict[str, Any] = {"is_private": bool(body.is_private)}
|
|
# Mutual exclusion — see ``update_agent`` above. Moving an agent into
|
|
# the private square also drops the public ``published`` flag so we
|
|
# never end up with an agent that's "in both squares".
|
|
if update["is_private"]:
|
|
update["published"] = False
|
|
if record.get("user_id") is None:
|
|
updated = await store.update_builtin(agent_id, update)
|
|
else:
|
|
updated = await store.update(agent_id, str(record["user_id"]), update)
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
|
|
logger.info("Admin set agent '%s' is_private=%s", agent_id, body.is_private)
|
|
return _record_to_response(updated, include_soul=True)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to set is_private for agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") from e
|
|
|
|
|
|
class AgentFeaturedReorderRequest(BaseModel):
|
|
"""Request body for admin to bulk-reorder the homepage featured list."""
|
|
|
|
agent_ids: list[str] = Field(
|
|
default_factory=list,
|
|
description=(
|
|
"Featured agent ids in the desired top→bottom display order. Ids not "
|
|
"currently featured are silently skipped, so the client can send the "
|
|
"current visible order without first reconciling with the server."
|
|
),
|
|
)
|
|
|
|
|
|
@router.put(
|
|
"/agents/featured/order",
|
|
status_code=204,
|
|
summary="Reorder Featured Agents (admin)",
|
|
description=(
|
|
"Admin-only. Bulk-assign ``featured_order`` to each id in the order received "
|
|
"(``0`` is topmost). After this, the homepage selector renders agents in "
|
|
"exactly the supplied order. Ids not already featured are ignored."
|
|
),
|
|
)
|
|
async def reorder_featured_agents(
|
|
body: AgentFeaturedReorderRequest,
|
|
request: Request,
|
|
) -> None:
|
|
_require_agents_api_enabled()
|
|
if not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can reorder the homepage selector")
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
for index, agent_id in enumerate(body.agent_ids):
|
|
# ``set_featured`` is a no-op for unknown ids — it returns None and
|
|
# we move on without raising, matching the docstring.
|
|
await store.set_featured(agent_id, index)
|
|
logger.info("Admin reordered featured agents (%d ids)", len(body.agent_ids))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to reorder featured agents: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to reorder featured agents: {str(e)}") from e
|
|
|
|
|
|
class AgentSkillRemovalEntry(BaseModel):
|
|
agent_id: str = Field(..., description="Agent storage id whose config.yaml was rewritten")
|
|
agent_name: str | None = Field(default=None, description="Display name from the DB, if any")
|
|
user_id: str | None = Field(default=None, description="Owner user id (null = built-in)")
|
|
removed_skills: list[str] = Field(..., description="Skill names that no longer exist on disk and were stripped")
|
|
|
|
|
|
class AgentSkillsSyncResponse(BaseModel):
|
|
checked: int = Field(..., description="Number of agent config.yaml files scanned")
|
|
modified: int = Field(..., description="Number of agents whose skills list was rewritten")
|
|
removals: list[AgentSkillRemovalEntry] = Field(default_factory=list, description="Per-agent removal details")
|
|
|
|
|
|
@router.post(
|
|
"/agents/admin/sync-skills",
|
|
response_model=AgentSkillsSyncResponse,
|
|
summary="Sync Agent Skills (admin)",
|
|
description=(
|
|
"Admin-only. Scan every agent's ``config.yaml`` and strip skill names that "
|
|
"no longer exist on disk. Repairs historical drift where a skill was "
|
|
"removed without the delete/unpublish cascade firing (e.g. deleted from "
|
|
"the filesystem manually, or removed in an older version of the code)."
|
|
),
|
|
)
|
|
async def admin_sync_agent_skills(
|
|
request: Request,
|
|
config: AppConfig = Depends(get_config),
|
|
) -> AgentSkillsSyncResponse:
|
|
_require_agents_api_enabled()
|
|
if not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can run agent-skills sync")
|
|
|
|
try:
|
|
storage = get_or_new_skill_storage(app_config=config)
|
|
all_skills = storage.load_skills(enabled_only=False)
|
|
available = {skill.name for skill in all_skills}
|
|
|
|
result = await asyncio.to_thread(sync_agent_skills_to_available, available)
|
|
|
|
# Enrich removals with display name + owner from the DB so the admin
|
|
# UI can show "我的智能体 X 移除了技能 Y" instead of just the id.
|
|
store = get_agent_store(request)
|
|
enriched: list[AgentSkillRemovalEntry] = []
|
|
for entry in result.get("removals", []):
|
|
agent_id = entry["agent_id"]
|
|
record = await store.get_any(agent_id)
|
|
enriched.append(
|
|
AgentSkillRemovalEntry(
|
|
agent_id=agent_id,
|
|
agent_name=(record.get("name") if record else None),
|
|
user_id=(record.get("user_id") if record else None),
|
|
removed_skills=entry["removed_skills"],
|
|
)
|
|
)
|
|
|
|
if result.get("modified"):
|
|
await refresh_skills_system_prompt_cache_async()
|
|
|
|
logger.info(
|
|
"Admin agent-skills sync: checked=%d modified=%d",
|
|
result["checked"],
|
|
result["modified"],
|
|
)
|
|
return AgentSkillsSyncResponse(
|
|
checked=result["checked"],
|
|
modified=result["modified"],
|
|
removals=enriched,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to sync agent skills: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to sync agent skills: {str(e)}") from e
|