feat: 技能收藏与常驻(always-on)支持
- 新增 skill_favorites / skill_always_on 迁移与持久化模型字段 - skills 路由、存储、前端设置页支持收藏与常驻 - 调整摘要中间件与 lead agent 提示词 - 同步岗位 position_sync 及相关配置清理 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5ebbb82d66
commit
5182ea5d11
19 files changed
+472
-107
No files matched your search
@@ -169,6 +169,7 @@ class SkillResponse(BaseModel):
|
||||
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
||||
square_id: str = Field(default="", description="Publish square (发布广场) the skill is filed under once published. Empty string = the default square.")
|
||||
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
||||
favorited: bool = Field(default=False, description="True when this skill is in the caller's 我的技能 collection (e.g. granted by their 岗位/position). Owned skills are detected separately via owner_user_id.")
|
||||
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")
|
||||
@@ -280,6 +281,7 @@ def _skill_to_response(
|
||||
*,
|
||||
display_override: dict[str, Any] | None | object = _UNSET,
|
||||
display_sample: dict[str, Any] | None = None,
|
||||
favorited: bool = False,
|
||||
) -> SkillResponse:
|
||||
"""Convert a Skill object to a SkillResponse.
|
||||
|
||||
@@ -317,6 +319,9 @@ def _skill_to_response(
|
||||
detail: str | None = None
|
||||
name_zh: str | None = None
|
||||
square_id = ""
|
||||
# 常驻(免压缩) is stored in the DB (SkillRow.always_on), surfaced via the
|
||||
# ownership record — never read from the on-disk extensions_config.json.
|
||||
always_on = False
|
||||
if ownership is not None:
|
||||
owner_user_id = ownership.get("owner_user_id")
|
||||
published = bool(ownership.get("published", False))
|
||||
@@ -324,6 +329,7 @@ def _skill_to_response(
|
||||
detail = ownership.get("detail")
|
||||
name_zh = ownership.get("name_zh")
|
||||
square_id = str(ownership.get("square_id") or "")
|
||||
always_on = bool(ownership.get("always_on", False))
|
||||
|
||||
display = None if display_override is _UNSET else display_override
|
||||
if display_override is _UNSET:
|
||||
@@ -341,7 +347,7 @@ def _skill_to_response(
|
||||
license=skill.license,
|
||||
category=skill.category,
|
||||
enabled=skill.enabled,
|
||||
always_on=skill.always_on,
|
||||
always_on=always_on,
|
||||
use_count=entry.get("use_count", 0),
|
||||
view_count=entry.get("view_count", 0),
|
||||
patch_count=entry.get("patch_count", 0),
|
||||
@@ -355,6 +361,7 @@ def _skill_to_response(
|
||||
published=published,
|
||||
square_id=square_id,
|
||||
builtin=builtin,
|
||||
favorited=favorited,
|
||||
detail=detail,
|
||||
display=display,
|
||||
displaySample=display_sample,
|
||||
@@ -810,6 +817,15 @@ async def list_skills(
|
||||
ownership = {record["name"]: record for record in ownership_records}
|
||||
else:
|
||||
ownership = await _build_ownership_index(skill_store, user_id)
|
||||
# Skills the caller's 岗位 (position) granted into their "我的技能"
|
||||
# collection. Admins browse everything, so the badge is moot for them.
|
||||
favorite_names: set[str] = set()
|
||||
if not admin:
|
||||
try:
|
||||
favorite_names = await skill_store.list_favorite_skill_names(user_id)
|
||||
except Exception:
|
||||
logger.debug("skill favorites lookup failed", exc_info=True)
|
||||
favorite_names = set()
|
||||
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
|
||||
responses: list[SkillResponse] = []
|
||||
for skill in all_skills:
|
||||
@@ -825,22 +841,30 @@ async def list_skills(
|
||||
ownership.get(skill.name),
|
||||
display_override=_display_override_from_adapter(adapter),
|
||||
display_sample=adapter.get("sample") if adapter else None,
|
||||
favorited=skill.name in favorite_names,
|
||||
)
|
||||
)
|
||||
continue
|
||||
record = ownership.get(skill.name)
|
||||
if record is None:
|
||||
if not admin:
|
||||
if admin:
|
||||
# Admin sees every custom skill on disk even without an
|
||||
# ownership row (legacy / orphaned), so they can clean it up.
|
||||
record = None
|
||||
elif skill.name in favorite_names:
|
||||
# A position granted this skill even though it isn't
|
||||
# otherwise visible to the user (e.g. an unpublished custom
|
||||
# skill). Surface it so it lands in their 我的技能.
|
||||
record = await skill_store.get_any(skill.name)
|
||||
else:
|
||||
continue
|
||||
# Admin sees every custom skill on disk even without an
|
||||
# ownership row (legacy / orphaned), so they can clean it up.
|
||||
record = None
|
||||
responses.append(
|
||||
_skill_to_response(
|
||||
skill,
|
||||
record,
|
||||
display_override=_display_override_from_adapter(adapter),
|
||||
display_sample=adapter.get("sample") if adapter else None,
|
||||
favorited=skill.name in favorite_names,
|
||||
)
|
||||
)
|
||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||
@@ -855,19 +879,12 @@ async def list_skills(
|
||||
for r in responses
|
||||
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
||||
]
|
||||
# 岗位 (position) visibility: when the caller asks for "我的技能",
|
||||
# restrict to the skills their position grants. ``None`` from the sync
|
||||
# service = unrestricted (no skill tag binding → all skills visible).
|
||||
if scope == "mine" and not admin:
|
||||
sync = getattr(request.app.state, "position_sync", None)
|
||||
if sync is not None:
|
||||
try:
|
||||
visible = await sync.resolve_visible(user_id, "skill")
|
||||
except Exception:
|
||||
logger.debug("skill visibility resolve failed", exc_info=True)
|
||||
visible = None
|
||||
if visible is not None:
|
||||
responses = [r for r in responses if r.name in visible]
|
||||
# 岗位 (position) no longer *restricts* the skill list: a position grants
|
||||
# skills by materializing them into the member's 我的技能 (the ``favorited``
|
||||
# flag above + the ``skill_favorites`` table, synced by PositionSyncService),
|
||||
# mirroring how agents are added to 我的智能体. The browse-all 广场 therefore
|
||||
# always shows every visible skill — the ``scope`` param is kept for API
|
||||
# compatibility but intentionally does not filter here.
|
||||
responses = _sort_skill_responses(responses, sort)
|
||||
return SkillsListResponse(skills=responses)
|
||||
except HTTPException:
|
||||
@@ -2194,7 +2211,6 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
||||
current_skill_config = extensions_config.skills.get(skill_name)
|
||||
extensions_config.skills[skill_name] = SkillStateConfig(
|
||||
enabled=request.enabled,
|
||||
always_on=current_skill_config.always_on if current_skill_config else skill.always_on,
|
||||
display=current_skill_config.display if current_skill_config else None,
|
||||
)
|
||||
|
||||
@@ -2229,41 +2245,50 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
||||
summary="Update Skill 常驻(免压缩) Flag",
|
||||
description="Mark a skill as 常驻(免压缩) so it is injected full-text into the system prompt even when skill compression is enabled.",
|
||||
)
|
||||
async def update_skill_always_on(skill_name: str, request: SkillAlwaysOnUpdateRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
||||
async def update_skill_always_on(
|
||||
skill_name: str,
|
||||
request: SkillAlwaysOnUpdateRequest,
|
||||
http_request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillResponse:
|
||||
"""Persist the 常驻(免压缩) flag in the **DB** (skills table), not in
|
||||
extensions_config.json — the source-tree config file is read-only / unsafe to
|
||||
rewrite in some deployments, and built-in skills carry no file entry anyway.
|
||||
Mirrors the detail/name_zh endpoints: custom skill → owner or admin; built-in
|
||||
/ public skill → admin only (materializes a row on demand)."""
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
user_id = _current_user_id(http_request)
|
||||
is_admin = _is_admin_user(http_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")
|
||||
|
||||
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}")
|
||||
if skill.category == SkillCategory.CUSTOM:
|
||||
_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, {"always_on": request.always_on})
|
||||
else:
|
||||
updated = await skill_store.update(skill_name, user_id, {"always_on": request.always_on})
|
||||
else:
|
||||
# Built-in / public skill — admin only; no DB row by default.
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only admin users can change a built-in skill's 常驻 flag")
|
||||
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, {"always_on": request.always_on})
|
||||
|
||||
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,
|
||||
always_on=request.always_on,
|
||||
display=current_skill_config.display if current_skill_config else None,
|
||||
)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to persist skill 常驻 flag")
|
||||
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(_dump_extensions_config(extensions_config), f, indent=2)
|
||||
|
||||
reload_extensions_config()
|
||||
# always_on lives in the system prompt's skill injection — refresh the cache.
|
||||
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}' always_on updated to {request.always_on}")
|
||||
return _skill_to_response(updated_skill)
|
||||
logger.info("Skill '%s' always_on updated to %s", skill_name, request.always_on)
|
||||
return _skill_to_response(skill, updated)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -2300,7 +2325,6 @@ async def update_skill_display(
|
||||
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,
|
||||
always_on=current_skill_config.always_on if current_skill_config else skill.always_on,
|
||||
display=request.display,
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user