feat(skill): 支持直接上传单个 SKILL.md 文件,更新导入功能描述
This commit is contained in:
parent
1f792fa914
commit
8ba4f7050d
@ -492,31 +492,39 @@ async def import_skill_zip(
|
||||
file_bytes: bytes,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
if not filename.lower().endswith(".zip"):
|
||||
raise ValueError("仅支持上传 .zip 文件")
|
||||
normalized_filename = filename.lower()
|
||||
is_zip_upload = normalized_filename.endswith(".zip")
|
||||
is_skill_md_upload = normalized_filename.endswith("skill.md")
|
||||
if not is_zip_upload and not is_skill_md_upload:
|
||||
raise ValueError("仅支持上传 .zip 或 SKILL.md 文件")
|
||||
|
||||
repo = SkillRepository(db)
|
||||
skills_root = get_skills_root_dir()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root:
|
||||
temp_root_path = Path(temp_root)
|
||||
zip_path = temp_root_path / "upload.zip"
|
||||
extract_dir = temp_root_path / "extract"
|
||||
stage_dir = temp_root_path / "stage"
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
if is_zip_upload:
|
||||
zip_path = temp_root_path / "upload.zip"
|
||||
zip_path.write_bytes(file_bytes)
|
||||
|
||||
zip_path.write_bytes(file_bytes)
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
_validate_zip_paths(zf)
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
_validate_zip_paths(zf)
|
||||
zf.extractall(extract_dir)
|
||||
skill_md_files = list(extract_dir.rglob("SKILL.md"))
|
||||
if len(skill_md_files) != 1:
|
||||
raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)")
|
||||
|
||||
skill_md_files = list(extract_dir.rglob("SKILL.md"))
|
||||
if len(skill_md_files) != 1:
|
||||
raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)")
|
||||
skill_md_path = skill_md_files[0]
|
||||
source_skill_dir = skill_md_path.parent
|
||||
else:
|
||||
source_skill_dir = extract_dir
|
||||
skill_md_path = source_skill_dir / "SKILL.md"
|
||||
skill_md_path.write_bytes(file_bytes)
|
||||
|
||||
skill_md_path = skill_md_files[0]
|
||||
source_skill_dir = skill_md_path.parent
|
||||
content = skill_md_path.read_text(encoding="utf-8")
|
||||
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
|
||||
|
||||
|
||||
@ -182,7 +182,7 @@ async def import_skill_route(
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导入技能压缩包(仅超级管理员)。"""
|
||||
"""导入技能包(支持 ZIP 或单个 SKILL.md,仅超级管理员)。"""
|
||||
try:
|
||||
file_bytes = await file.read()
|
||||
item = await import_skill_zip(
|
||||
@ -197,7 +197,7 @@ async def import_skill_route(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import skill zip: {e}")
|
||||
logger.error(f"Failed to import skill package: {e}")
|
||||
raise HTTPException(status_code=500, detail="导入技能失败")
|
||||
|
||||
|
||||
|
||||
@ -72,6 +72,37 @@ def test_import_skill_requires_superadmin():
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_import_skill_route_accepts_skill_md(monkeypatch):
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
async def fake_import_skill_zip(_db, *, filename, file_bytes, created_by):
|
||||
captured["filename"] = filename
|
||||
captured["file_bytes"] = file_bytes.decode("utf-8")
|
||||
captured["created_by"] = created_by
|
||||
return Skill(
|
||||
slug="demo",
|
||||
name="demo",
|
||||
description="demo skill",
|
||||
dir_path="skills/demo",
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.import_skill_zip", fake_import_skill_zip)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/api/system/skills/import",
|
||||
files={"file": ("SKILL.md", b"---\nname: demo\ndescription: demo skill\n---\n", "text/markdown")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert captured["filename"] == "SKILL.md"
|
||||
assert "name: demo" in captured["file_bytes"]
|
||||
assert captured["created_by"] == "root"
|
||||
|
||||
|
||||
def test_update_skill_file_passes_operator(monkeypatch):
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
|
||||
@ -165,6 +165,62 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc
|
||||
assert "name: demo-v2" in skill_md
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_skill_md_creates_single_file_skill(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
|
||||
|
||||
class FakeRepo:
|
||||
created_item: Skill | None = None
|
||||
|
||||
def __init__(self, _db):
|
||||
pass
|
||||
|
||||
async def exists_slug(self, slug: str) -> bool:
|
||||
return False
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str,
|
||||
tool_dependencies: list[str] | None,
|
||||
mcp_dependencies: list[str] | None,
|
||||
skill_dependencies: list[str] | None,
|
||||
dir_path: str,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
item = Skill(
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
tool_dependencies=tool_dependencies or [],
|
||||
mcp_dependencies=mcp_dependencies or [],
|
||||
skill_dependencies=skill_dependencies or [],
|
||||
dir_path=dir_path,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
)
|
||||
self.__class__.created_item = item
|
||||
return item
|
||||
|
||||
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
||||
|
||||
skill_md = "---\nname: demo\ndescription: this is demo\n---\n# Demo\n"
|
||||
item = await svc.import_skill_zip(
|
||||
None,
|
||||
filename="SKILL.md",
|
||||
file_bytes=skill_md.encode("utf-8"),
|
||||
created_by="root",
|
||||
)
|
||||
|
||||
assert item.slug == "demo"
|
||||
assert item.name == "demo"
|
||||
assert (tmp_path / "skills" / "demo" / "SKILL.md").read_text(encoding="utf-8") == skill_md
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
|
||||
|
||||
@ -9,17 +9,11 @@
|
||||
|
||||
### 看板
|
||||
|
||||
- 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
|
||||
- Langfuse 增加 self-host 模式支持,补齐私有化部署与配置说明
|
||||
- 部分场景应该使用默认模型作为默认值而不是空值
|
||||
- 检索测试中,添加问答
|
||||
- 集成 Memory,基于 deepagents 的文件后端实现
|
||||
- 添加自定义向量模型和 rerank 模型的配置,在网页上面
|
||||
- 调研轻便的文件展示与编辑器
|
||||
- 移除 TODO 的模块与设计,移除这个中间件。
|
||||
- Yuxi-cli 相关的功能,放在后续版本中实现(不是类似于编程助手,而是工具)
|
||||
- 工作区实现长期记忆保存,要求模型只能将结果写入到 outputs 文件夹
|
||||
- 新增 present file 工具,提供预览以及保存到工作区的选项
|
||||
|
||||
### Bugs
|
||||
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi/issues/279)
|
||||
@ -38,7 +32,9 @@
|
||||
- 修复沙盒 `workspace` 隔离粒度:宿主机目录从共享 `saves/threads/shared/workspace` 收敛为用户级 `saves/threads/shared/<user_id>/workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。
|
||||
- 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。
|
||||
- 收紧文件系统安全边界:viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2,并移除 skill frontmatter 解析中的正则回溯风险。
|
||||
- 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能
|
||||
|
||||
---
|
||||
|
||||
历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。
|
||||
|
||||
|
||||
@ -11,14 +11,14 @@
|
||||
<!-- Skills Tab 的按钮 -->
|
||||
<template v-if="activeTab === 'skills'">
|
||||
<a-upload
|
||||
accept=".zip"
|
||||
accept=".zip,.md"
|
||||
:show-upload-list="false"
|
||||
:custom-request="handleImportUpload"
|
||||
:disabled="skillsLoading || skillsImporting"
|
||||
>
|
||||
<a-button type="primary" :loading="skillsImporting" class="lucide-icon-btn">
|
||||
<Upload :size="14" />
|
||||
<span>上传 ZIP</span>
|
||||
<span>上传 Skill</span>
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<a-button @click="handleSkillsRefresh" :disabled="skillsLoading" class="lucide-icon-btn">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user