feat(skills/settings): 技能复制 + 设置持久化等会话前改动
- 技能复制:新增 POST 复制接口(_rewrite_skill_frontmatter_name 重写 frontmatter name + 校验 + 去重),前端 skills api/hooks 与技能设置页配套 - 设置持久化:lastAgentId、线程 RAG 模式锁(settings/index、local) - i18n(en/zh/types)、agent-card/input-box/message-list 等页面文案与交互微调 - scheduler/element.py 小修 (本批为会话前已存在的工作区改动,按要求一并提交;构建产物 backend.zip 未提交) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
d9afaca51f
commit
f590f672be
18 files changed
+524
-44
No files matched your search
@@ -30,7 +30,7 @@ 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.storage import SkillStorage, get_or_new_skill_storage
|
||||
from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -210,6 +210,12 @@ class CustomSkillUpdateRequest(BaseModel):
|
||||
content: str = Field(..., description="Replacement SKILL.md content")
|
||||
|
||||
|
||||
class SkillDuplicateRequest(BaseModel):
|
||||
"""Request model for duplicating an existing skill into a new custom skill."""
|
||||
|
||||
new_name: str = Field(..., description="Name for the new custom skill copy (lowercase letters/digits, '-'/'_' separators)")
|
||||
|
||||
|
||||
class CustomSkillHistoryResponse(BaseModel):
|
||||
history: list[dict]
|
||||
|
||||
@@ -950,6 +956,121 @@ async def install_skill(
|
||||
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}")
|
||||
|
||||
|
||||
def _rewrite_skill_frontmatter_name(content: str, new_name: str) -> str:
|
||||
"""Return *content* with the YAML-frontmatter ``name:`` field set to *new_name*.
|
||||
|
||||
A duplicated skill must declare the new name in its SKILL.md frontmatter or
|
||||
``validate_skill_markdown_content`` will reject it (frontmatter name must
|
||||
match the directory name). We rewrite only the first ``name:`` line inside
|
||||
the leading ``---`` block, leaving every other field byte-for-byte intact.
|
||||
"""
|
||||
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("SKILL.md is missing the leading YAML frontmatter block")
|
||||
fm = match.group(1)
|
||||
new_fm, replaced = re.subn(r"(?m)^(name:\s*).*$", rf"\g<1>{new_name}", fm, count=1)
|
||||
if replaced == 0:
|
||||
new_fm = f"name: {new_name}\n{fm}"
|
||||
return content[: match.start(1)] + new_fm + content[match.end(1) :]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/custom/{source_name}/duplicate",
|
||||
response_model=SkillInstallResponse,
|
||||
summary="Duplicate Skill",
|
||||
description=(
|
||||
"Copy an existing skill (built-in/public OR custom) into a brand-new custom "
|
||||
"skill named ``new_name``, owned by the caller and unpublished. All support "
|
||||
"files are copied verbatim; only the SKILL.md frontmatter name is rewritten. "
|
||||
"The source already passed the install-time security scan, so no re-scan is run."
|
||||
),
|
||||
)
|
||||
async def duplicate_skill(
|
||||
source_name: str,
|
||||
body: SkillDuplicateRequest,
|
||||
request: Request,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillInstallResponse:
|
||||
import shutil
|
||||
|
||||
try:
|
||||
user_id = _current_user_id(request)
|
||||
is_admin = _is_admin_user(request)
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
|
||||
source_name = SkillStorage.validate_skill_name(source_name.replace("\r\n", "").replace("\n", ""))
|
||||
new_name = SkillStorage.validate_skill_name(body.new_name.replace("\r\n", "").replace("\n", ""))
|
||||
|
||||
# Resolve the source directory (custom takes precedence over public, so a
|
||||
# user-shadowed copy is the one duplicated when both exist).
|
||||
if storage.custom_skill_exists(source_name):
|
||||
source_dir = storage.get_custom_skill_dir(source_name)
|
||||
elif storage.public_skill_exists(source_name):
|
||||
source_dir = storage.get_skills_root_path() / SkillCategory.PUBLIC.value / source_name
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{source_name}' not found")
|
||||
|
||||
if new_name == source_name or storage.custom_skill_exists(new_name) or storage.public_skill_exists(new_name):
|
||||
raise HTTPException(status_code=409, detail=f"A skill named '{new_name}' already exists")
|
||||
if not is_admin:
|
||||
existing = await skill_store.get_any(new_name)
|
||||
if existing and existing.get("owner_user_id") not in (None, user_id):
|
||||
raise HTTPException(status_code=409, detail=f"Skill '{new_name}' already exists and is owned by another user")
|
||||
|
||||
dest_dir = storage.get_custom_skill_dir(new_name)
|
||||
# copytree refuses to overwrite, which is the behaviour we want — the
|
||||
# existence checks above already guarantee a clean target.
|
||||
shutil.copytree(source_dir, dest_dir)
|
||||
try:
|
||||
source_content = (dest_dir / SKILL_MD_FILE).read_text(encoding="utf-8")
|
||||
new_content = _rewrite_skill_frontmatter_name(source_content, new_name)
|
||||
SkillStorage.validate_skill_markdown_content(new_name, new_content)
|
||||
storage.write_custom_skill(new_name, SKILL_MD_FILE, new_content)
|
||||
except Exception:
|
||||
# Roll back the half-created directory so a failed copy never leaves
|
||||
# an unparseable skill on disk.
|
||||
shutil.rmtree(dest_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
await skill_store.ensure_legacy({"name": new_name, "owner_user_id": user_id, "published": False})
|
||||
try:
|
||||
storage.append_history(
|
||||
new_name,
|
||||
{
|
||||
"action": "duplicate",
|
||||
"author": "human",
|
||||
"thread_id": None,
|
||||
"file_path": SKILL_MD_FILE,
|
||||
"prev_content": None,
|
||||
"new_content": new_content,
|
||||
"source_skill": source_name,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from deerflow.skills.usage import mark_uploaded
|
||||
mark_uploaded(new_name)
|
||||
except Exception:
|
||||
pass
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return SkillInstallResponse(
|
||||
success=True,
|
||||
skill_name=new_name,
|
||||
message=f"Skill '{source_name}' duplicated as '{new_name}'",
|
||||
overwritten=False,
|
||||
category=SkillCategory.CUSTOM.value,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to duplicate skill %s: %s", source_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to duplicate skill: {str(e)}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/reconcile-custom",
|
||||
response_model=SkillReconcileResponse,
|
||||
|
||||
Reference in new issue
Block a user