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:
吉鑫升andClaude Opus 4.8 committed 2026-06-16 16:37:43 +08:00
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,
)