feat(skills): 新增技能详情字段并统一发布图标
- 技能新增 detail 字段(skills 表新增列 + Alembic 迁移),
由管理员或技能发布者编辑,内置/公开技能仅管理员可编辑
- 新增 PUT /api/skills/{name}/detail 接口,GET/列表接口返回 detail
- 新增可复用的技能详情弹窗组件,支持 Markdown 渲染与内联编辑
- 智能体创建/编辑选技能、智能体卡片技能徽章均可查看技能详情
- 将定时任务/智能体/技能的发布图标统一为 Globe/Lock
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3b951efd13
commit
f61d053010
17 files changed
+694
-77
No files matched your search
@@ -96,6 +96,7 @@ class SkillResponse(BaseModel):
|
||||
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
|
||||
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
||||
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
||||
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
|
||||
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
|
||||
|
||||
|
||||
@@ -180,10 +181,12 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
|
||||
owner_user_id: str | None = None
|
||||
published = False
|
||||
builtin = skill.category != SkillCategory.CUSTOM
|
||||
detail: str | None = None
|
||||
if ownership is not None:
|
||||
owner_user_id = ownership.get("owner_user_id")
|
||||
published = bool(ownership.get("published", False))
|
||||
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||
detail = ownership.get("detail")
|
||||
|
||||
return SkillResponse(
|
||||
name=skill.name,
|
||||
@@ -201,6 +204,7 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
|
||||
owner_user_id=owner_user_id,
|
||||
published=published,
|
||||
builtin=builtin,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
@@ -251,7 +255,9 @@ async def list_skills(
|
||||
# Public/built-in skills (no DB row) are visible to everyone.
|
||||
# Custom skills must have an ownership record in the visible set.
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
responses.append(_skill_to_response(skill))
|
||||
# Built-in skills usually have no DB row, but an admin-authored
|
||||
# ``detail`` annotation materializes one — surface it when present.
|
||||
responses.append(_skill_to_response(skill, ownership.get(skill.name)))
|
||||
continue
|
||||
record = ownership.get(skill.name)
|
||||
if record is None:
|
||||
@@ -828,7 +834,12 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
|
||||
summary="Get Skill Details",
|
||||
description="Retrieve detailed information about a specific skill by its name.",
|
||||
)
|
||||
async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
||||
async def get_skill(
|
||||
skill_name: str,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillResponse:
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
@@ -837,7 +848,9 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||
|
||||
return _skill_to_response(skill)
|
||||
# Attach ownership (owner/published/detail) when the caller may see it.
|
||||
ownership = await skill_store.get_visible(skill_name, _current_user_id(request))
|
||||
return _skill_to_response(skill, ownership)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -926,6 +939,71 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
|
||||
|
||||
|
||||
class SkillDetailRequest(BaseModel):
|
||||
"""Request body for setting a skill's human-authored detail text."""
|
||||
|
||||
detail: str = Field(default="", description="详情说明文本,支持 Markdown;传空字符串表示清空")
|
||||
|
||||
|
||||
class SkillDetailResponse(BaseModel):
|
||||
"""Response model for the skill-detail update endpoint."""
|
||||
|
||||
name: str = Field(..., description="Skill name")
|
||||
detail: str | None = Field(default=None, description="Persisted detail text (null when cleared)")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/skills/{skill_name}/detail",
|
||||
response_model=SkillDetailResponse,
|
||||
summary="Update Skill Detail",
|
||||
description="设置技能的详情说明。自定义技能由其所有者或管理员编辑;内置/公开技能仅管理员可编辑。",
|
||||
)
|
||||
async def update_skill_detail(
|
||||
skill_name: str,
|
||||
body: SkillDetailRequest,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillDetailResponse:
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
user_id = _current_user_id(request)
|
||||
is_admin = _is_admin_user(request)
|
||||
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
skill = next((s for s in skills if s.name == skill_name), None)
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||
|
||||
# Empty / whitespace-only input clears the annotation.
|
||||
detail_value: str | None = body.detail.strip() or None
|
||||
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
# Owner edits their own skill; admins may edit anyone's.
|
||||
_record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||
if mode == "admin":
|
||||
updated = await skill_store.update_admin(skill_name, {"detail": detail_value})
|
||||
else:
|
||||
updated = await skill_store.update(skill_name, user_id, {"detail": detail_value})
|
||||
else:
|
||||
# Built-in / public skill — only admins may annotate it. These carry
|
||||
# no DB row by default, so materialize one before writing detail.
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only admin users can edit built-in skill details")
|
||||
if await skill_store.get_any(skill_name) is None:
|
||||
await skill_store.ensure_legacy({"name": skill_name, "owner_user_id": None, "published": True})
|
||||
updated = await skill_store.update_admin(skill_name, {"detail": detail_value})
|
||||
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to persist skill detail")
|
||||
return SkillDetailResponse(name=skill_name, detail=updated.get("detail"))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to update detail for %s: %s", skill_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update skill detail: {str(e)}")
|
||||
|
||||
|
||||
class SkillPinRequest(BaseModel):
|
||||
pinned: bool = Field(..., description="Whether to pin or unpin the skill")
|
||||
|
||||
|
||||
Reference in new issue
Block a user