Skip to content

Commit 6b286e0

Browse files
committed
✨ Skill 系统:version 字段、URL 分发机制、更新检查
- Skill 支持 version 字段(SkillSummary/SkillMetadata/SkillRecord) - 新增 URL 安装流程:以 SKILL.cat.md 为入口,按相对路径 fetch scripts/references - 新增更新检查机制:checkForUpdates/updateSkill,基于 semver 比较 - SKILL.cat.md 优先于 SKILL.md(parseSkillZip 兼容) - DNR 拦截 .cat.md URL,安装页自动识别并走 URL 安装流程 - SkillInstallView 展示 version 和 installUrl - AgentSkills 页面支持 URL 安装输入和批量检查更新 - 8 个语言文件新增 i18n keys - 新增单元测试覆盖 version/URL 安装/更新检查
1 parent 7ecebb3 commit 6b286e0

21 files changed

Lines changed: 894 additions & 47 deletions

File tree

src/app/repo/skill_repo.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,14 @@ export class SkillRepo extends OPFSRepo {
8686
const summary: SkillSummary = {
8787
name: record.name,
8888
description: record.description,
89+
...(record.version ? { version: record.version } : {}),
8990
toolNames: record.toolNames,
9091
referenceNames: record.referenceNames,
9192
...(record.config && Object.keys(record.config).length > 0 ? { hasConfig: true } : {}),
9293
// 保留已有的 enabled 状态
9394
...(idx >= 0 && registry[idx].enabled !== undefined ? { enabled: registry[idx].enabled } : {}),
95+
// 保留或更新 installUrl
96+
...(record.installUrl ? { installUrl: record.installUrl } : idx >= 0 && registry[idx].installUrl ? { installUrl: registry[idx].installUrl } : {}),
9497
installtime: record.installtime,
9598
updatetime: record.updatetime,
9699
};

src/app/service/agent/core/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,18 +284,23 @@ export type SkillConfigField = {
284284
export type SkillSummary = {
285285
name: string;
286286
description: string;
287+
version?: string; // 语义化版本号
287288
toolNames: string[]; // 随 Skill 打包的脚本名称(scripts/ 目录下)
288289
referenceNames: string[]; // 参考资料名称(references/ 目录下)
289290
hasConfig?: boolean; // 是否有 config 字段声明
290291
enabled?: boolean; // 是否启用,默认 true(undefined 视为 true)
292+
installUrl?: string; // 安装来源 URL(用于检查更新)
291293
installtime: number;
292294
updatetime: number;
293295
};
294296

295-
// SKILL.md frontmatter 解析结果
297+
// SKILL.cat.md frontmatter 解析结果
296298
export type SkillMetadata = {
297299
name: string;
298300
description: string;
301+
version?: string; // 语义化版本号
302+
scripts?: string[]; // 脚本文件名列表(URL 安装时按相对路径获取)
303+
references?: string[]; // 参考资料文件名列表
299304
config?: Record<string, SkillConfigField>;
300305
};
301306

src/app/service/agent/service_worker/agent.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,15 @@ export class AgentService {
165165
this.group.on("saveSkillConfig", (params: { name: string; values: Record<string, unknown> }) =>
166166
this.skillService.skillRepo.saveConfigValues(params.name, params.values)
167167
);
168-
// Skill ZIP 安装页面相关消息
168+
// Skill 安装页面相关消息
169169
this.group.on("prepareSkillInstall", (zipBase64: string) => this.prepareSkillInstall(zipBase64));
170+
this.group.on("prepareSkillFromUrl", (url: string) => this.skillService.prepareSkillFromUrl(url));
170171
this.group.on("getSkillInstallData", (uuid: string) => this.getSkillInstallData(uuid));
171172
this.group.on("completeSkillInstall", (uuid: string) => this.completeSkillInstall(uuid));
172173
this.group.on("cancelSkillInstall", (uuid: string) => this.cancelSkillInstall(uuid));
174+
// Skill 更新检查
175+
this.group.on("checkForUpdates", () => this.skillService.checkForUpdates());
176+
this.group.on("updateSkill", (name: string) => this.skillService.updateSkill(name));
173177
// Model CRUD 及摘要模型 API(委托给 AgentModelService)
174178
this.modelService.init();
175179
// MCP API(供 Options UI 调用,复用已有的 handleMCPApi)
@@ -268,14 +272,15 @@ export class AgentService {
268272
return this.skillService.prepareSkillInstall(zipBase64);
269273
}
270274

271-
// 获取缓存的 Skill ZIP 数据并解析
275+
// 获取缓存的 Skill 安装数据并解析
272276
async getSkillInstallData(uuid: string): Promise<{
273277
skillMd: string;
274278
metadata: SkillMetadata;
275279
prompt: string;
276280
scripts: Array<{ name: string; code: string }>;
277281
references: Array<{ name: string; content: string }>;
278282
isUpdate: boolean;
283+
installUrl?: string;
279284
}> {
280285
return this.skillService.getSkillInstallData(uuid);
281286
}

src/app/service/agent/service_worker/skill.test.ts

Lines changed: 243 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ Updated prompt.`;
387387
it("无效 SKILL.md 应抛出异常", async () => {
388388
const { service } = createTestService();
389389

390-
await expect(service.installSkill("not valid skill md")).rejects.toThrow("Invalid SKILL.md");
390+
await expect(service.installSkill("not valid skill md")).rejects.toThrow("Invalid SKILL.cat.md");
391391
});
392392

393393
it("含无效 Skill Script 时应抛出异常", async () => {
@@ -511,4 +511,246 @@ return query;`;
511511
expect(record.referenceNames).toEqual([]);
512512
});
513513
});
514+
515+
describe("installSkill version 和 installUrl", () => {
516+
it("应正确保存 version 字段", async () => {
517+
const { service, mockSkillRepo } = createTestService();
518+
519+
const skillMd = `---
520+
name: versioned
521+
description: test
522+
version: 1.2.3
523+
---
524+
Prompt.`;
525+
526+
const record = await (service as any).skillService.installSkill(skillMd);
527+
expect(record.version).toBe("1.2.3");
528+
expect(mockSkillRepo.saveSkill.mock.calls[0][0].version).toBe("1.2.3");
529+
});
530+
531+
it("应正确保存 installUrl", async () => {
532+
const { service, mockSkillRepo } = createTestService();
533+
534+
const skillMd = `---
535+
name: from-url
536+
description: test
537+
version: 1.0.0
538+
---
539+
Prompt.`;
540+
const url = "https://example.com/skills/test/SKILL.cat.md";
541+
542+
const record = await (service as any).skillService.installSkill(skillMd, undefined, undefined, url);
543+
expect(record.installUrl).toBe(url);
544+
expect(mockSkillRepo.saveSkill.mock.calls[0][0].installUrl).toBe(url);
545+
});
546+
547+
it("无 version 时 record.version 应为 undefined", async () => {
548+
const { service } = createTestService();
549+
550+
const skillMd = `---
551+
name: no-ver
552+
description: test
553+
---
554+
Prompt.`;
555+
556+
const record = await (service as any).skillService.installSkill(skillMd);
557+
expect(record.version).toBeUndefined();
558+
});
559+
});
560+
561+
describe("installFromUrl", () => {
562+
it("应从 URL 获取 SKILL.cat.md 并安装", async () => {
563+
const { service, mockSkillRepo } = createTestService();
564+
565+
const skillMd = `---
566+
name: remote-skill
567+
description: Remote skill
568+
version: 2.0.0
569+
scripts:
570+
- helper.js
571+
references:
572+
- docs.md
573+
---
574+
Remote prompt.`;
575+
576+
const scriptCode = VALID_SKILLSCRIPT_CODE;
577+
const refContent = "# Docs\nSome docs.";
578+
579+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => {
580+
const urlStr = typeof url === "string" ? url : url.toString();
581+
if (urlStr.endsWith("SKILL.cat.md")) {
582+
return { ok: true, text: async () => skillMd } as Response;
583+
}
584+
if (urlStr.includes("scripts/helper.js")) {
585+
return { ok: true, text: async () => scriptCode } as Response;
586+
}
587+
if (urlStr.includes("references/docs.md")) {
588+
return { ok: true, text: async () => refContent } as Response;
589+
}
590+
return { ok: false, status: 404, statusText: "Not Found" } as Response;
591+
});
592+
593+
try {
594+
const record = await (service as any).skillService.installFromUrl(
595+
"https://example.com/skills/test/SKILL.cat.md"
596+
);
597+
598+
expect(record.name).toBe("remote-skill");
599+
expect(record.version).toBe("2.0.0");
600+
expect(record.installUrl).toBe("https://example.com/skills/test/SKILL.cat.md");
601+
expect(record.toolNames).toEqual(["test-tool"]);
602+
expect(record.referenceNames).toEqual(["docs.md"]);
603+
604+
// 验证 fetch 调用了正确的相对路径
605+
expect(fetchSpy).toHaveBeenCalledTimes(3);
606+
const urls = fetchSpy.mock.calls.map((c) => (typeof c[0] === "string" ? c[0] : c[0].toString()));
607+
expect(urls).toContain("https://example.com/skills/test/SKILL.cat.md");
608+
expect(urls).toContain("https://example.com/skills/test/scripts/helper.js");
609+
expect(urls).toContain("https://example.com/skills/test/references/docs.md");
610+
} finally {
611+
fetchSpy.mockRestore();
612+
}
613+
});
614+
615+
it("SKILL.cat.md 获取失败时应抛错", async () => {
616+
const { service } = createTestService();
617+
618+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
619+
ok: false,
620+
status: 404,
621+
statusText: "Not Found",
622+
} as Response);
623+
624+
try {
625+
await expect(
626+
(service as any).skillService.installFromUrl("https://example.com/not-found.cat.md")
627+
).rejects.toThrow("Failed to fetch");
628+
} finally {
629+
fetchSpy.mockRestore();
630+
}
631+
});
632+
});
633+
634+
describe("checkForUpdates / updateSkill", () => {
635+
it("远程版本更高时应返回更新信息", async () => {
636+
const { service, mockSkillRepo } = createTestService();
637+
638+
const skillList = [{ name: "updatable", version: "1.0.0", installUrl: "https://example.com/SKILL.cat.md" }];
639+
mockSkillRepo.listSkills.mockResolvedValue(skillList);
640+
641+
const remoteMd = `---
642+
name: updatable
643+
description: test
644+
version: 2.0.0
645+
---
646+
Updated prompt.`;
647+
648+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
649+
ok: true,
650+
text: async () => remoteMd,
651+
} as Response);
652+
653+
try {
654+
const updates = await (service as any).skillService.checkForUpdates();
655+
expect(updates).toHaveLength(1);
656+
expect(updates[0].name).toBe("updatable");
657+
expect(updates[0].currentVersion).toBe("1.0.0");
658+
expect(updates[0].remoteVersion).toBe("2.0.0");
659+
} finally {
660+
fetchSpy.mockRestore();
661+
}
662+
});
663+
664+
it("远程版本相同或更低时不返回更新", async () => {
665+
const { service, mockSkillRepo } = createTestService();
666+
667+
mockSkillRepo.listSkills.mockResolvedValue([
668+
{ name: "up-to-date", version: "2.0.0", installUrl: "https://example.com/SKILL.cat.md" },
669+
]);
670+
671+
const remoteMd = `---
672+
name: up-to-date
673+
description: test
674+
version: 2.0.0
675+
---
676+
Same prompt.`;
677+
678+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
679+
ok: true,
680+
text: async () => remoteMd,
681+
} as Response);
682+
683+
try {
684+
const updates = await (service as any).skillService.checkForUpdates();
685+
expect(updates).toHaveLength(0);
686+
} finally {
687+
fetchSpy.mockRestore();
688+
}
689+
});
690+
691+
it("无 installUrl 的 Skill 不参与更新检查", async () => {
692+
const { service, mockSkillRepo } = createTestService();
693+
694+
mockSkillRepo.listSkills.mockResolvedValue([
695+
{ name: "local-only", version: "1.0.0" },
696+
{ name: "no-version", installUrl: "https://example.com/SKILL.cat.md" },
697+
]);
698+
699+
const updates = await (service as any).skillService.checkForUpdates();
700+
expect(updates).toHaveLength(0);
701+
});
702+
703+
it("网络错误时静默忽略", async () => {
704+
const { service, mockSkillRepo } = createTestService();
705+
706+
mockSkillRepo.listSkills.mockResolvedValue([
707+
{ name: "net-err", version: "1.0.0", installUrl: "https://example.com/SKILL.cat.md" },
708+
]);
709+
710+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
711+
712+
try {
713+
const updates = await (service as any).skillService.checkForUpdates();
714+
expect(updates).toHaveLength(0);
715+
} finally {
716+
fetchSpy.mockRestore();
717+
}
718+
});
719+
720+
it("updateSkill 应从 installUrl 重新安装", async () => {
721+
const { service, mockSkillRepo } = createTestService();
722+
723+
const url = "https://example.com/skills/test/SKILL.cat.md";
724+
mockSkillRepo.listSkills.mockResolvedValue([{ name: "to-update", version: "1.0.0", installUrl: url }]);
725+
726+
const remoteMd = `---
727+
name: to-update
728+
description: updated
729+
version: 2.0.0
730+
---
731+
Updated.`;
732+
733+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
734+
ok: true,
735+
text: async () => remoteMd,
736+
} as Response);
737+
738+
try {
739+
const record = await (service as any).skillService.updateSkill("to-update");
740+
expect(record.name).toBe("to-update");
741+
expect(record.version).toBe("2.0.0");
742+
expect(record.installUrl).toBe(url);
743+
} finally {
744+
fetchSpy.mockRestore();
745+
}
746+
});
747+
748+
it("无 installUrl 的 Skill 调用 updateSkill 应抛错", async () => {
749+
const { service, mockSkillRepo } = createTestService();
750+
751+
mockSkillRepo.listSkills.mockResolvedValue([{ name: "local-only", version: "1.0.0" }]);
752+
753+
await expect((service as any).skillService.updateSkill("local-only")).rejects.toThrow("no install URL");
754+
});
755+
});
514756
});

0 commit comments

Comments
 (0)