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:
AdministratorandClaude Opus 4.8 committed 2026-06-02 23:48:19 +08:00
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,