From a4718379d94b76b12543e0c776cd5eaa744a50a9 Mon Sep 17 00:00:00 2001 From: Yige Date: Tue, 7 Jul 2026 21:14:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=BA=E5=8C=96=E5=B8=82=E5=9C=BA=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E7=B4=A2=E5=BC=95=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并市场索引、分类、i18n 校验、skill-creator 工具和已修改 skill 版本号更新。 --- .github/workflows/i18n-translate.yml | 16 + .github/workflows/i18n-validate.yml | 1 - README.md | 235 ++++---- builtin-skills.json | 1 + categories.json | 30 + docs/I18N.md | 10 +- manifest.json | 6 +- .../i18n/schema/skill-frontmatter.schema.json | 16 +- scripts/i18n/translate.py | 10 +- scripts/i18n/validate-i18n.py | 223 +++++++- skills/configuring-compute/SKILL.md | 3 +- skills/dashscope-image-gen/SKILL.md | 4 +- skills/guizang-ppt/SKILL.md | 8 +- skills/guizang-ppt/SKILL.zh-CN.md | 538 ++++++++++++++++++ skills/image-to-image/SKILL.md | 4 +- skills/mail-operations/SKILL.md | 4 +- skills/manage-skills/SKILL.md | 4 +- skills/minimax-music-gen/SKILL.md | 4 +- skills/minimax-video-gen/SKILL.md | 4 +- skills/nodejs-runtime/SKILL.md | 4 +- skills/pptx/SKILL.md | 4 +- skills/python-runtime/SKILL.md | 4 +- skills/registering-services/SKILL.md | 3 +- skills/s3-storage-operations/SKILL.md | 4 +- skills/skill-creator/SKILL.md | 4 +- .../references/desirecore-format.md | 2 +- skills/skill-creator/scripts/init_skill.py | 55 +- .../skill-creator/scripts/quick_validate.py | 123 +++- skills/update-agent/SKILL.md | 4 +- skills/using-services/SKILL.md | 3 +- skills/web-access/SKILL.md | 4 +- skills/workflow/SKILL.md | 4 +- skills/xiaomi-tts/SKILL.md | 4 +- skills/xlsx/SKILL.md | 4 +- 34 files changed, 1129 insertions(+), 218 deletions(-) create mode 100644 skills/guizang-ppt/SKILL.zh-CN.md diff --git a/.github/workflows/i18n-translate.yml b/.github/workflows/i18n-translate.yml index 3faed1b..a42c17f 100644 --- a/.github/workflows/i18n-translate.yml +++ b/.github/workflows/i18n-translate.yml @@ -336,6 +336,22 @@ jobs: issue_number: context.issue.number, labels: ['i18n-translation-failed'], }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: [ + '🌐 **i18n translation check failed**', + '', + 'Please inspect the workflow logs for the exact failing skill/locale.', + '', + 'Common causes:', + '- A machine-translated locale could not be regenerated.', + '- A `translated_by: human` locale has source hash drift and must be manually synchronized.', + '', + 'After manually reviewing a human-locked translation, update its `source_hash` to match the current source content.', + ].join('\n'), + }); - name: Skip notice if: steps.changes.outputs.relevant != 'true' diff --git a/.github/workflows/i18n-validate.yml b/.github/workflows/i18n-validate.yml index 5b44701..6a3ff05 100644 --- a/.github/workflows/i18n-validate.yml +++ b/.github/workflows/i18n-validate.yml @@ -65,7 +65,6 @@ jobs: - name: Check for stale translations if: steps.changes.outputs.relevant == 'true' run: uv run --quiet scripts/i18n/translate.py --check - continue-on-error: true - name: Skip notice if: steps.changes.outputs.relevant != 'true' diff --git a/README.md b/README.md index c7fe4d7..fad853b 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,152 @@ # DesireCore Market -DesireCore 官方市场仓库,存放经过官方验证的 Agent 和 Skill 定义。 +DesireCore 官方市场仓库,存放官方维护的 Agent/Skill 定义,以及经过整理的第三方 Skill 入口。 -## 目录结构 +## Repository Shape ``` . -├── manifest.json # 仓库元数据 -├── categories.json # 分类配置 -├── README.md # 本文件 -├── agents/ # Agent 定义目录 -│ ├── task-master/ -│ │ └── agent.json -│ ├── code-reviewer/ -│ │ └── agent.json -│ ├── business-analyst/ -│ │ └── agent.json -│ ├── writing-coach/ -│ │ └── agent.json -│ ├── translator/ -│ │ └── agent.json -│ └── data-analyst/ +├── manifest.json # Market metadata, supported locales, aggregate stats +├── categories.json # Category registry and localized labels +├── builtin-skills.json # Built-in local SKILL.md skills +├── agents/ +│ └── desirecore/ │ └── agent.json -└── skills/ # Skill 定义目录 - ├── web-search/ - │ └── skill.json - ├── file-manager/ - │ └── skill.json - ├── calendar-sync/ - │ └── skill.json - ├── doc-parser/ - │ └── skill.json - └── email-client/ - └── skill.json +└── skills/ + ├── / + │ ├── SKILL.md + │ └── SKILL..md + └── / + └── entry.json ``` -## Agent 清单 +The market currently contains: -| ID | 名称 | 分类 | 定位 | -|----|------|------|------| -| task-master | 任务管家 | 效率 | 智能任务规划与追踪专家 | -| code-reviewer | 代码审查官 | 开发 | 资深代码审查与质量把控 | -| business-analyst | 商业洞察师 | 商业 | 数据驱动的商业分析顾问 | -| writing-coach | 写作教练 | 创意 | 从构思到润色的写作伙伴 | -| translator | 译界通 | 沟通 | 跨文化沟通与专业翻译 | -| data-analyst | 数据洞察师 | 数据 | 数据科学与业务洞察专家 | +- `1` Agent: `desirecore` +- `30` local built-in skills with `SKILL.md` +- `15` external skill entries with `entry.json` +- `45` publishable skills in total (`SKILL.md` + `entry.json`) -## Skill 清单 +## Skill Sources -| ID | 名称 | 分类 | 风险等级 | 功能概述 | -|----|------|------|----------|----------| -| web-search | 网络搜索 | 效率 | 中 | 智能搜索与信息聚合 | -| file-manager | 文件管家 | 开发 | 高 | 本地文件系统管理 | -| calendar-sync | 日历同步 | 商业 | 中 | 日历服务连接与调度 | -| doc-parser | 文档解析器 | 数据 | 低 | 50+ 格式文档解析 | -| email-client | 邮件助理 | 沟通 | 高 | 智能邮件管理与起草 | +Local built-in skills are installable from this repository and must be listed in `builtin-skills.json`: -## 数据格式 +```text +configuring-compute, create-agent, dashscope-image-gen, delete-agent, +dev-environment-setup, discover-agent, docx, frontend-design, guizang-ppt, +image-to-image, mail-operations, manage-skills, manage-teams, markdown, +minimax-music-gen, minimax-video-gen, nodejs-runtime, pdf, pptx, +python-runtime, registering-services, s3-storage-operations, skill-creator, +tech-diagram, update-agent, using-services, web-access, workflow, +xiaomi-tts, xlsx +``` -### Agent 定义 (agents/{id}/agent.json) +External entries are marketplace pointers to Git/Web/ZIP sources: + +```text +ai-news-radar, amap-jsapi-skill, dingtalk-api, flyai-skill, +follow-builders, larksuite-cli, luckin-my-coffee, mattpocock-skills, +minimax-image-gen, minimax-tts, mt-paotui-for-client, netease-skills, +taste-skill, wechatpay-skills, wecom-cli +``` + +## Data Formats + +### Local Skill (`skills//SKILL.md`) + +Local skills use YAML frontmatter plus Markdown body. The top-level `name` must equal the directory slug. Display strings live in `metadata.i18n`. + +```yaml +--- +name: web-access +description: >- + Use this skill when ... +version: 2.0.1 +type: procedural +risk_level: low +status: enabled +metadata: + author: desirecore + updated_at: '2026-05-05' + i18n: + default_locale: en-US + source_locale: zh-CN + locales: [zh-CN, en-US] + zh-CN: + name: 联网访问 + short_desc: 联网搜索、网页抓取、登录态浏览器访问 + body: ./SKILL.zh-CN.md + translated_by: human + en-US: + name: Web Access + short_desc: Web search, page fetching, logged-in browser access + body: ./SKILL.md + source_hash: sha256:... + translated_by: human +market: + category: research + channel: latest + maintainer: + name: DesireCore Official + verified: true +--- +``` + +### External Entry (`skills//entry.json`) + +External entries point to upstream packages or repositories. They are counted in `manifest.stats.totalSkills` but are not included in `builtin-skills.json`. ```json { - "id": "string", - "name": "string", - "avatar": { "t": "string", "bg": "string" }, - "shortDesc": "string", - "fullDesc": "string", - "category": "productivity|development|business|creative|media|communication|data|management", - "tags": ["string"], - "version": "semver", - "latestVersion": "semver", - "updatedAt": "YYYY-MM-DD", - "maintainer": { "name": "string", "verified": boolean }, - "downloads": number, - "rating": number, - "ratingCount": number, - "installStatus": "not_installed", - "persona": { - "role": "string", - "traits": ["string"], - "tools": ["string"] + "id": "example-skill", + "name": "Example Skill", + "category": "development", + "tags": ["example"], + "maintainer": { + "name": "Example", + "verified": false, + "account": "example", + "url": "https://github.com/example/example-skill" + }, + "stewardship": "community", + "license": "MIT", + "redistribution": "allowed", + "source": { + "kind": "git", + "repoUrl": "https://github.com/example/example-skill.git", + "repoBranch": "main" } } ``` -### Skill 定义 (skills/{id}/skill.json) +## Categories -```json -{ - "id": "string", - "name": "string", - "icon": "lucide-icon-name", - "shortDesc": "string", - "fullDesc": "string", - "category": "productivity|development|business|creative|media|communication|data|management", - "tags": ["string"], - "version": "semver", - "latestVersion": "semver", - "updatedAt": "YYYY-MM-DD", - "maintainer": { "name": "string", "verified": boolean }, - "downloads": number, - "rating": number, - "ratingCount": number, - "installStatus": "not_installed", - "riskLevel": "low|medium|high", - "requires": { - "tools": ["string"], - "connections": ["string"] - }, - "compatibleAgents": ["string"] -} +Valid category slugs are declared in `categories.json`: + +```text +productivity, development, business, creative, design, media, +communication, research, data, management ``` -## 设计令牌 +## Validation -头像背景色使用 DesireCore 设计系统: - -- **Green (#34C759)**: 效率、商业类 -- **Blue (#007AFF)**: 开发、沟通类 -- **Purple (#AF52DE)**: 创意、数据类 - -## 多语言(i18n) - -仓库支持多语言展示与多语言 SKILL.md 正文。当前覆盖 `zh-CN`(简体中文,源语言)与 `en-US`(英文,默认 fallback),由 `manifest.json#supportedLocales` 声明。 - -- **作者只交一种语言**,CI 自动翻译为其余语言。CI 默认走 [GitHub Models](https://docs.github.com/en/github-models)(`openai/gpt-5-mini`,使用 repo 内置 `GITHUB_TOKEN` + `permissions: models: read`);可切到 Anthropic Claude(设置 `vars.TRANSLATE_BACKEND=anthropic` 与 `secrets.ANTHROPIC_API_KEY`)。 -- **顶层 `name` 字段为 ASCII slug**(== 目录名),中文显示名安放在 `metadata.i18n.zh-CN.name`,符合 [agentskills.io 规范](https://agentskills.io/specification)。 -- **正文文件**:`SKILL.md`(默认语言)+ `SKILL..md`(其他语言)兄弟文件。 - -工具: +Run these checks before submitting changes: ```bash -# 校验 i18n 完整性 +# Full market + i18n validation uv run scripts/i18n/validate-i18n.py -# 检查哪些 locale 缺翻译(不调 API) +# Translation freshness check uv run scripts/i18n/translate.py --check -# 本地翻译:默认 GitHub Models(需要 fine-grained PAT 含 Models: Read) -GITHUB_TOKEN=ghp_... uv run scripts/i18n/translate.py - -# 切到 Anthropic Claude -TRANSLATE_BACKEND=anthropic ANTHROPIC_API_KEY=sk-ant-... \ - uv run scripts/i18n/translate.py +# Optional network check for entry.json source URLs +uv run scripts/i18n/validate-i18n.py --online ``` -详细规范见 [docs/I18N.md](docs/I18N.md)。 +The validator checks market stats, category references, `builtin-skills.json`, `entry.json` structure, i18n completeness, and translation freshness. Human-locked translations (`translated_by: human`) must keep `source_hash` aligned after manual review. -## 许可证 +Detailed i18n guidance is in [docs/I18N.md](docs/I18N.md). -MIT License - 详见 [LICENSE](LICENSE) +## License + +MIT License. See [LICENSE](LICENSE). diff --git a/builtin-skills.json b/builtin-skills.json index f70c406..e16f94d 100644 --- a/builtin-skills.json +++ b/builtin-skills.json @@ -8,6 +8,7 @@ "discover-agent", "docx", "frontend-design", + "guizang-ppt", "image-to-image", "mail-operations", "manage-skills", diff --git a/categories.json b/categories.json index d61e61e..105ed6f 100644 --- a/categories.json +++ b/categories.json @@ -59,6 +59,21 @@ } } }, + "design": { + "color": "#FF2D55", + "icon": "pen-tool", + "i18n": { + "default_locale": "en-US", + "en-US": { + "label": "Design", + "description": "Interface, visual, and product design skills for creating polished user experiences, layouts, and design systems." + }, + "zh-CN": { + "label": "设计", + "description": "界面、视觉与产品设计技能,用于创建高质量用户体验、布局与设计系统" + } + } + }, "media": { "color": "#AF52DE", "icon": "image", @@ -89,6 +104,21 @@ } } }, + "research": { + "color": "#5AC8FA", + "icon": "search", + "i18n": { + "default_locale": "en-US", + "en-US": { + "label": "Research", + "description": "Information access, web research, source gathering, and synthesis skills for current or external knowledge." + }, + "zh-CN": { + "label": "研究", + "description": "信息访问、联网调研、资料收集与综合分析技能,用于获取当前或外部知识" + } + } + }, "data": { "color": "#AF52DE", "icon": "database", diff --git a/docs/I18N.md b/docs/I18N.md index 20dac99..3a27def 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -76,11 +76,12 @@ market: ### 新建 skill(首次贡献) 1. 在 `skills//` 下新建目录与 `SKILL.md`。 -2. 编写中文 frontmatter + 中文 body: +2. 编写中文 frontmatter + 中文 body,并给默认英文 locale 留可替换占位: - `name: `(ASCII) - - `metadata.i18n.{default_locale: en-US, source_locale: zh-CN, locales: [zh-CN]}` + - `metadata.i18n.{default_locale: en-US, source_locale: zh-CN, locales: [zh-CN, en-US]}` - `metadata.i18n.zh-CN.{name, short_desc, description}` - `metadata.i18n.zh-CN.body: ./SKILL.zh-CN.md` + - `metadata.i18n.en-US.{name, short_desc, description, body: ./SKILL.md, translated_by: ai:pending}` - 中文正文写在 `SKILL.zh-CN.md`,首行 `` - 根 `SKILL.md` body 留作翻译占位符(CI 会替换) 3. 本地校验:`uv run scripts/i18n/validate-i18n.py skills/` @@ -97,14 +98,14 @@ CI 用 [GitHub Models inference API](https://docs.github.com/en/github-models) - **切换模型**:repository → Settings → Variables → `TRANSLATE_MODEL`,取值参考 [GitHub Models marketplace](https://github.com/marketplace?type=models) 的 `publisher/model-name` 格式。 - **切到 Anthropic Claude**:在 repo 加 secret `ANTHROPIC_API_KEY`,再加 variable `TRANSLATE_BACKEND=anthropic`、`TRANSLATE_MODEL=claude-sonnet-4-6`。 -提交回仓库的方式:`desirecore-bot` 在同 PR 追加 commit `chore(i18n): auto-translate skills [skip ci]`,并在 PR 留 review comment 提示人工核对。 +提交回仓库的方式:`desirecore-bot` 在同 PR 追加 commit `chore(i18n): auto-translate skills`,并在 PR 留 comment 提示人工核对。该 commit 不带 `[skip ci]`,因为必需检查需要在翻译结果上重新运行。 ### 修改已有 skill 1. 修改源语言 body(`SKILL.zh-CN.md`)或 `metadata.i18n.zh-CN.{name,short_desc,description}`。 2. 不要手动改 `source_hash`。 3. 开 PR。CI 检测 hash 变化,重新翻译目标 locale 并追加 commit。 -4. 若某个 locale 的 `translated_by: human`,CI 会跳过该 locale 并在 PR 留 review 提示「目标语已脱钩,请确认」,由你手工同步。 +4. 若某个 locale 的 `translated_by: human` 且 `source_hash` 与源语言不一致,CI 会失败并提示该人工翻译已过期。请手工同步目标语言正文,确认无误后更新 `source_hash`。 --- @@ -114,6 +115,7 @@ CI 用 [GitHub Models inference API](https://docs.github.com/en/github-models) # 校验 i18n 状态 uv run scripts/i18n/validate-i18n.py uv run scripts/i18n/validate-i18n.py skills/web-access +uv run scripts/i18n/validate-i18n.py --online # 可选:检查 entry.json 外部 URL # 一次性把旧格式 SKILL.md 迁移到 i18n 格式(旧 skill 已迁完,新 skill 一般不需要) uv run scripts/i18n/migrate.py --apply skills/ diff --git a/manifest.json b/manifest.json index e0d6574..ebaf1ff 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "DesireCore Official Market", - "version": "1.2.7", + "version": "1.2.8", "schemaVersion": "1.1.0", "supportedLocales": ["zh-CN", "en-US"], "defaultLocale": "en-US", @@ -27,11 +27,11 @@ }, "stats": { "totalAgents": 1, - "totalSkills": 26, + "totalSkills": 45, "lastUpdated": "2026-07-07" }, "features": [ - "verified-only", + "curated-index", "auto-sync", "multi-language", "ai-translation" diff --git a/scripts/i18n/schema/skill-frontmatter.schema.json b/scripts/i18n/schema/skill-frontmatter.schema.json index 9c82d34..71abdd5 100644 --- a/scripts/i18n/schema/skill-frontmatter.schema.json +++ b/scripts/i18n/schema/skill-frontmatter.schema.json @@ -79,7 +79,21 @@ "additionalProperties": true, "properties": { "icon": { "type": "string" }, - "category": { "type": "string" }, + "category": { + "type": "string", + "enum": [ + "productivity", + "development", + "business", + "creative", + "design", + "media", + "communication", + "research", + "data", + "management" + ] + }, "channel": { "type": "string", "enum": ["latest", "stable", "canary", "beta"] diff --git a/scripts/i18n/translate.py b/scripts/i18n/translate.py index 18d98ac..2155d7f 100755 --- a/scripts/i18n/translate.py +++ b/scripts/i18n/translate.py @@ -429,9 +429,10 @@ def translate_skill( if target_block.get("translated_by") == "human": if target_block.get("source_hash") != current_hash: plan["actions"].append( - f"WARN: human-translated locale {target_locale} is stale " - f"(source_hash drift). Skipping; please update manually." + f"ERROR: human-translated locale {target_locale} is stale " + f"(source_hash drift). Skipping automatic translation; update it manually." ) + plan["human_stale"] = True else: plan["actions"].append(f"locale {target_locale} is human-locked, skipping") return plan @@ -604,6 +605,7 @@ def main(argv: list[str]) -> int: }) needs = [p for p in plans if p.get("needs_translation")] + human_stale = [p for p in plans if p.get("human_stale")] errs = [p for p in plans if p.get("errors")] if args.check: for p in plans: @@ -612,7 +614,7 @@ def main(argv: list[str]) -> int: for p in errs: for e in p["errors"]: print(f" ERROR [{p['skill']}/{p['target']}]: {e}") - return 1 if (needs or errs) else 0 + return 1 if (needs or human_stale or errs) else 0 print(f"Backend: {backend} Model: {model} Endpoint: {endpoint}\n") for p in plans: @@ -621,7 +623,7 @@ def main(argv: list[str]) -> int: print(f" - {a}") for e in p.get("errors", []): print(f" ✗ ERROR: {e}") - return 1 if errs else 0 + return 1 if (human_stale or errs) else 0 if __name__ == "__main__": diff --git a/scripts/i18n/validate-i18n.py b/scripts/i18n/validate-i18n.py index d8054a2..d245707 100755 --- a/scripts/i18n/validate-i18n.py +++ b/scripts/i18n/validate-i18n.py @@ -15,6 +15,9 @@ Checks: 6. Frontmatter parses cleanly; heading count of locale body matches source body (+/- 0). 7. categories.json's per-category i18n covers all locales declared in manifest.json. 8. Top-level description is 1-1024 chars (spec); top-level name is 1-64 chars (spec). + 9. Skill/Agent counts and builtin skill index match the repository contents. + 10. Skill, Agent, and entry.json category references exist in categories.json. + 11. entry.json pointers have the required marketplace fields and safe source URLs. Exit codes: 0 = pass @@ -24,6 +27,7 @@ Exit codes: Usage: python3 scripts/i18n/validate-i18n.py # validate everything under repo root python3 scripts/i18n/validate-i18n.py skills/web-access # validate single skill + python3 scripts/i18n/validate-i18n.py --online # also check entry.json source URLs python3 scripts/i18n/validate-i18n.py --json # machine-readable output """ from __future__ import annotations @@ -31,10 +35,13 @@ from __future__ import annotations import argparse import json import re +import ssl import sys from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen try: import yaml @@ -50,6 +57,7 @@ LOCALE_PATTERN = re.compile(r"^[a-z]{2,3}(?:-[A-Z]{2})?$") LOCALE_HEADER_PATTERN = re.compile(r"^") HEADING_PATTERN = re.compile(r"^(#{1,6})\s+\S", re.MULTILINE) FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL) +SAFE_URL_PATTERN = re.compile(r"^https://") @dataclass @@ -100,7 +108,12 @@ def heading_count(text: str) -> int: return len(HEADING_PATTERN.findall(text or "")) -def validate_skill(skill_dir: Path, report: Report, declared_locales: set[str] | None = None) -> None: +def validate_skill( + skill_dir: Path, + report: Report, + declared_locales: set[str] | None = None, + category_ids: set[str] | None = None, +) -> None: """Validate one skill directory (must contain SKILL.md).""" rel_dir = skill_dir.relative_to(REPO_ROOT).as_posix() skill_md = skill_dir / "SKILL.md" @@ -117,6 +130,10 @@ def validate_skill(skill_dir: Path, report: Report, declared_locales: set[str] | name = fm.get("name", "") description = fm.get("description", "") + market = fm.get("market") or {} + category = market.get("category") if isinstance(market, dict) else None + if category is None: + category = fm.get("category") # Rule 1: name spec-compliance + matches dir if not isinstance(name, str) or not NAME_PATTERN.match(name) or len(name) > 64 or name in RESERVED_NAMES: @@ -137,6 +154,15 @@ def validate_skill(skill_dir: Path, report: Report, declared_locales: set[str] | f"description must be 1-1024 chars (got {len(description) if isinstance(description, str) else 'non-string'})" )) + if category_ids is not None: + if not isinstance(category, str) or not category.strip(): + report.add(Issue(f"{rel_dir}/SKILL.md", "market-category", "market.category is missing")) + elif category not in category_ids: + report.add(Issue( + f"{rel_dir}/SKILL.md", "market-category", + f"category '{category}' is not declared in categories.json" + )) + # Rule 2/3/4: i18n block metadata = fm.get("metadata") or {} i18n = metadata.get("i18n") if isinstance(metadata, dict) else None @@ -245,18 +271,31 @@ def validate_skill(skill_dir: Path, report: Report, declared_locales: set[str] | )) -def validate_market_root(report: Report) -> set[str]: - """Validate manifest.json + categories.json. Returns the declared locale set or empty.""" +def load_json(path: Path, report: Report, rule: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + report.add(Issue(path.relative_to(REPO_ROOT).as_posix(), rule, "file not found")) + return {} + except json.JSONDecodeError as e: + report.add(Issue(path.relative_to(REPO_ROOT).as_posix(), rule, f"JSON parse error: {e}")) + return {} + if not isinstance(value, dict): + report.add(Issue(path.relative_to(REPO_ROOT).as_posix(), rule, "JSON root must be an object")) + return {} + return value + + +def validate_market_root(report: Report) -> tuple[set[str], set[str], dict[str, Any]]: + """Validate manifest.json + categories.json. Returns locales, categories, manifest.""" manifest_path = REPO_ROOT / "manifest.json" categories_path = REPO_ROOT / "categories.json" declared: set[str] = set() + category_ids: set[str] = set() + manifest: dict[str, Any] = {} if manifest_path.is_file(): - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - report.add(Issue("manifest.json", "rule-7", f"JSON parse error: {e}")) - manifest = {} + manifest = load_json(manifest_path, report, "rule-7") supported = manifest.get("supportedLocales") or [] if not isinstance(supported, list) or not all(isinstance(x, str) and LOCALE_PATTERN.match(x) for x in supported): report.add(Issue("manifest.json", "rule-7", "supportedLocales must be a list of BCP-47 tags")) @@ -267,11 +306,8 @@ def validate_market_root(report: Report) -> set[str]: report.add(Issue("manifest.json", "rule-7", f"defaultLocale '{default}' not in supportedLocales")) if categories_path.is_file() and declared: - try: - categories = json.loads(categories_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - report.add(Issue("categories.json", "rule-7", f"JSON parse error: {e}")) - return declared + categories = load_json(categories_path, report, "rule-7") + category_ids = set(categories) for cat_id, cat in categories.items(): i18n = cat.get("i18n") if isinstance(cat, dict) else None if not isinstance(i18n, dict): @@ -284,7 +320,155 @@ def validate_market_root(report: Report) -> set[str]: "categories.json", "rule-7", f"category '{cat_id}' missing i18n.{locale}.label" )) - return declared + return declared, category_ids, manifest + + +def count_publishable_skills() -> tuple[list[str], list[str]]: + skill_md_names = sorted(p.parent.name for p in (REPO_ROOT / "skills").glob("*/SKILL.md")) + entry_names = sorted(p.parent.name for p in (REPO_ROOT / "skills").glob("*/entry.json")) + return skill_md_names, entry_names + + +def validate_builtin_skills(report: Report, skill_md_names: list[str]) -> None: + builtin_path = REPO_ROOT / "builtin-skills.json" + builtin = load_json(builtin_path, report, "builtin-skills") + skills = builtin.get("skills") + if not isinstance(skills, list) or not all(isinstance(x, str) for x in skills): + report.add(Issue("builtin-skills.json", "builtin-skills", "skills must be a list of strings")) + return + + expected = sorted(skill_md_names) + actual = list(skills) + if actual != expected: + missing = sorted(set(expected) - set(actual)) + extra = sorted(set(actual) - set(expected)) + order_issue = not missing and not extra + parts = [] + if missing: + parts.append(f"missing local SKILL.md skills {missing}") + if extra: + parts.append(f"contains non-local skills {extra}") + if order_issue: + parts.append("skills list is not sorted") + report.add(Issue("builtin-skills.json", "builtin-skills", "; ".join(parts))) + + +def validate_agent_json(report: Report, agent_file: Path, category_ids: set[str]) -> None: + rel = agent_file.relative_to(REPO_ROOT).as_posix() + agent = load_json(agent_file, report, "agent-json") + if not agent: + return + if agent.get("id") != agent_file.parent.name: + report.add(Issue(rel, "agent-json", f"id must equal parent directory '{agent_file.parent.name}'")) + category = agent.get("category") + if not isinstance(category, str) or category not in category_ids: + report.add(Issue(rel, "agent-json", f"category '{category}' is not declared in categories.json")) + + +def validate_entry_json(report: Report, entry_file: Path, category_ids: set[str], online: bool) -> None: + rel = entry_file.relative_to(REPO_ROOT).as_posix() + entry = load_json(entry_file, report, "entry-json") + if not entry: + return + + required = ("id", "name", "category", "maintainer", "stewardship", "license", "redistribution", "source") + for key in required: + if key not in entry: + report.add(Issue(rel, "entry-json", f"missing required field '{key}'")) + + if entry.get("id") != entry_file.parent.name: + report.add(Issue(rel, "entry-json", f"id must equal parent directory '{entry_file.parent.name}'")) + + category = entry.get("category") + if not isinstance(category, str) or category not in category_ids: + report.add(Issue(rel, "entry-json", f"category '{category}' is not declared in categories.json")) + + maintainer = entry.get("maintainer") + if not isinstance(maintainer, dict) or not isinstance(maintainer.get("name"), str): + report.add(Issue(rel, "entry-json", "maintainer.name is required")) + + tags = entry.get("tags") + if tags is not None: + if not isinstance(tags, list) or not all(isinstance(x, str) for x in tags): + report.add(Issue(rel, "entry-json", "tags must be a list of strings")) + elif len(tags) != len(set(tags)): + report.add(Issue(rel, "entry-json", "tags must be unique")) + + source = entry.get("source") + if not isinstance(source, dict): + report.add(Issue(rel, "entry-json", "source must be an object")) + return + kind = source.get("kind") + if kind not in {"git", "web", "zip"}: + report.add(Issue(rel, "entry-json", f"source.kind '{kind}' must be one of git/web/zip")) + repo_url = source.get("repoUrl") + if not isinstance(repo_url, str) or not repo_url.strip(): + report.add(Issue(rel, "entry-json", "source.repoUrl is required")) + elif not SAFE_URL_PATTERN.match(repo_url): + report.add(Issue(rel, "entry-json", "source.repoUrl must use https://")) + elif online: + validate_url(report, rel, repo_url) + + +def validate_url(report: Report, path: str, url: str) -> None: + context = ssl.create_default_context() + for method in ("HEAD", "GET"): + try: + req = Request(url, method=method, headers={"User-Agent": "desirecore-market-validator/1.0"}) + with urlopen(req, timeout=12, context=context) as resp: + if 200 <= resp.status < 400: + return + report.add(Issue(path, "entry-online", f"{url} returned HTTP {resp.status}")) + return + except HTTPError as e: + if method == "HEAD" and e.code in {403, 405}: + continue + report.add(Issue(path, "entry-online", f"{url} returned HTTP {e.code}")) + return + except (URLError, TimeoutError, OSError) as e: + report.add(Issue(path, "entry-online", f"{url} is not reachable: {e}")) + return + + +def validate_market_catalog(report: Report, manifest: dict[str, Any], category_ids: set[str], online: bool) -> None: + agent_files = sorted((REPO_ROOT / "agents").glob("*/agent.json")) + skill_md_names, entry_names = count_publishable_skills() + + stats = manifest.get("stats") + if not isinstance(stats, dict): + report.add(Issue("manifest.json", "market-stats", "stats must be an object")) + else: + expected_agents = len(agent_files) + expected_skills = len(skill_md_names) + len(entry_names) + if stats.get("totalAgents") != expected_agents: + report.add(Issue( + "manifest.json", "market-stats", + f"stats.totalAgents is {stats.get('totalAgents')}, expected {expected_agents}" + )) + if stats.get("totalSkills") != expected_skills: + report.add(Issue( + "manifest.json", "market-stats", + f"stats.totalSkills is {stats.get('totalSkills')}, expected {expected_skills}" + )) + + features = manifest.get("features") or [] + if isinstance(features, list) and "verified-only" in features: + for entry_file in sorted((REPO_ROOT / "skills").glob("*/entry.json")): + entry = load_json(entry_file, report, "entry-json") + maintainer = entry.get("maintainer") if isinstance(entry, dict) else None + verified = maintainer.get("verified") if isinstance(maintainer, dict) else None + if entry.get("stewardship") != "official" or verified is not True: + report.add(Issue( + "manifest.json", "market-features", + "features includes 'verified-only' but the market contains non-official or unverified entry.json pointers" + )) + break + + validate_builtin_skills(report, skill_md_names) + for agent_file in agent_files: + validate_agent_json(report, agent_file, category_ids) + for entry_file in sorted((REPO_ROOT / "skills").glob("*/entry.json")): + validate_entry_json(report, entry_file, category_ids, online) def iter_skill_dirs(targets: Iterable[Path]) -> Iterable[Path]: @@ -302,11 +486,13 @@ def iter_skill_dirs(targets: Iterable[Path]) -> Iterable[Path]: def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("paths", nargs="*", help="Skills or directories to validate (default: repo root)") + parser.add_argument("--online", action="store_true", help="Check entry.json source URLs with HEAD/GET requests") parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON") args = parser.parse_args(argv) report = Report() - declared_locales = validate_market_root(report) + declared_locales, category_ids, manifest = validate_market_root(report) + validate_market_catalog(report, manifest, category_ids, online=args.online) if args.paths: targets = [Path(p).resolve() for p in args.paths] @@ -314,7 +500,12 @@ def main(argv: list[str]) -> int: targets = [REPO_ROOT / "skills"] for skill_dir in iter_skill_dirs(targets): - validate_skill(skill_dir, report, declared_locales=declared_locales or None) + validate_skill( + skill_dir, + report, + declared_locales=declared_locales or None, + category_ids=category_ids or None, + ) if args.json: json.dump([i.to_dict() for i in report.issues], sys.stdout, indent=2, ensure_ascii=False) diff --git a/skills/configuring-compute/SKILL.md b/skills/configuring-compute/SKILL.md index ff18aa3..fe50389 100644 --- a/skills/configuring-compute/SKILL.md +++ b/skills/configuring-compute/SKILL.md @@ -6,7 +6,7 @@ description: >- (write-only), verify them, and reload the configuration. Use when the user asks to add or configure a model provider, set an API key, or fix compute configuration. 用户要求配置算力、添加模型供应商或设置 API Key 时使用。 -version: 1.0.0 +version: 1.0.1 type: meta risk_level: medium status: enabled @@ -44,6 +44,7 @@ metadata: asks to add or configure a model provider, set an API key, or fix compute configuration. body: ./SKILL.md translated_by: human + source_hash: sha256:15e9247a2c27edf7 market: icon: >- diff --git a/skills/dashscope-image-gen/SKILL.md b/skills/dashscope-image-gen/SKILL.md index 213d6bf..61a3de3 100644 --- a/skills/dashscope-image-gen/SKILL.md +++ b/skills/dashscope-image-gen/SKILL.md @@ -8,7 +8,7 @@ description: >- Use when the user mentions: generate image, draw, text-to-image, create image, AI painting, illustration, design picture. license: Complete terms in LICENSE.txt -version: 1.4.0 +version: 1.4.1 type: procedural risk_level: low status: enabled @@ -48,7 +48,7 @@ metadata: short_desc: AI text-to-image generation (GPT Image / Wan models) description: "Use this skill when the user wants to generate images. Supports multiple models: gpt-image-2 (default via DesireCore Cloud), wan2.7-image-pro / wan2.7-image (when user has DashScope configured). Uses the OpenAI-compatible /images/generations API. Trigger keywords: generate image, draw, text-to-image, create image, AI painting, illustration, design picture." body: ./SKILL.md - source_hash: sha256:b3d5f7a91c2e4068 + source_hash: sha256:8398726504b70586 translated_by: human market: icon: >- diff --git a/skills/guizang-ppt/SKILL.md b/skills/guizang-ppt/SKILL.md index 67bcbbb..e3094e8 100644 --- a/skills/guizang-ppt/SKILL.md +++ b/skills/guizang-ppt/SKILL.md @@ -7,7 +7,7 @@ description: >- decks (web PPT) in two visual systems — editorial "magazine × e-ink" or "Swiss International". 当用户需要制作分享 / 演讲 / 发布会风格的网页 PPT,或提到 杂志风 PPT、瑞士风 PPT、Swiss Style、horizontal swipe deck、网页 PPT 时使用。 -version: 1.0.0 +version: 1.0.1 type: procedural risk_level: low status: enabled @@ -21,7 +21,7 @@ metadata: author: 歸藏 (op7418) updated_at: '2026-05-31' i18n: - default_locale: zh-CN + default_locale: en-US source_locale: zh-CN locales: - zh-CN @@ -32,7 +32,7 @@ metadata: description: >- 生成横向翻页的单文件 HTML 网页 PPT,提供“电子杂志 × 电子墨水”与“瑞士国际主义”两套视觉系统,含 WebGL 背景、章节封面、数据大字报、图片网格等模板。适合线下分享、演讲、产品发布、年度总结等场景。 - body: ./SKILL.md + body: ./SKILL.zh-CN.md translated_by: human en-US: name: Guizang Web PPT @@ -46,6 +46,8 @@ metadata: locale intentionally falls back to the shared body, so it is locked as human-maintained. translated_by: human + source_hash: sha256:24ddc30c45afde10 + body: ./SKILL.md market: icon: >- + +## 何时使用 + +**合适的场景**: +- 线下分享 / 行业内部讲话 / 私享会 +- AI 新产品发布 / demo day +- 带有强烈个人风格的演讲 +- 需要"一次做完,不用翻页工具"的网页版 slides + +**不合适的场景**: +- 大段表格数据、图表叠加(用常规 PPT) +- 培训课件(信息密度不够) +- 需要多人协作编辑(这是静态 HTML) + +## 工作流 + +### Step 1 · 需求澄清(**动手前必做**) + +**如果用户已经给了完整的大纲 + 图片/截图处理要求**,可以跳过直接进 Step 2。 + +**如果用户只给了主题或一个模糊想法**,用这 7 个问题逐个对齐后再动手。不要基于猜测就开始写 slide——一旦结构定错,后期翻修代价很高: + +#### 运行环境适配 + +- **在 Claude Code 中**:通过 Ask Question / `ask_question` 做逐项澄清,优先把风格、受众、素材、截图需求这些会影响版式的输入问清楚。 +- **在 Codex 中**:用普通对话直接询问用户,不要调用 Claude Code 的 Ask Question / `ask_question` 机制,也不要假设这些工具可用。一次最多问 1-3 个最关键问题;如果信息缺口不影响开工,先做合理假设并在回复里说明。 + +#### 7 问澄清清单 + +| # | 问题 | 为什么要问 | +|---|------|-----------| +| 1 | **风格 A 还是 B?**(电子杂志风 / 瑞士国际主义风) | **必须先问**,决定用哪个 template + layouts + themes 文件 | +| 2 | **受众是谁?分享场景?**(行业内部 / 商业发布 / demo day / 私享会) | 决定语言风格和深度 | +| 3 | **分享时长?** | 15 分钟 ≈ 10 页,30 分钟 ≈ 20 页,45 分钟 ≈ 25-30 页 | +| 4 | **有没有原始素材?**(文档 / 数据 / 旧 PPT / 文章链接) | 有素材就基于素材,没有就帮他搭 | +| 5 | **有没有图片或截图?希望怎么处理?** | 决定图文版式、图片槽位、截图是否需要 CleanShot X 式适配或 GPT-M 2.0 重构 | +| 6 | **想要哪套主题色?** | 杂志风 5 套(`themes.md`) / 瑞士风 4 套(`themes-swiss.md`),挑一 | +| 7 | **有没有硬约束?**(必须包含 XX 数据 / 不能出现 YY) | 避免返工 | + +#### 风格选择参考(问题 1) + +| 如果用户说... | 推荐风格 | +|---|---| +| "杂志感" / "人文" / "Monocle 风" / 不指定 | **A · 电子杂志风** | +| "瑞士风" / "Swiss Style" / "Helvetica" / "极简" / "网格" / "信息图" / "数据驱动" | **B · 瑞士国际主义风** | +| 内容是 AI 产品 / 技术 / 工程 / 数据汇报 | B 更合适 | +| 内容是行业观察 / 人文 / 故事 / 文化 | A 更合适 | +| 用户给了大量 KPI 数字 / 路线图 / 流程 | B 更合适(`Data Hero` 布局是瑞士风专长) | +| 用户给了大量纪实照片 / 人文图片 | A 更合适(图片网格、左文右图是杂志风专长) | +| 用户需要 GPT-M 2.0 生成截图再设计 / 信息图 / 证据墙 | B 也很合适(S22 主图、S15/S16 图片网格可以承载证据图) | + +#### 大纲协助(如果用户没有大纲) + +用"叙事弧"模板搭骨架,再填内容: + +``` +钩子(Hook) → 1 页 : 抛一个反差 / 问题 / 硬数据让人停下来 +定调(Context) → 1-2 页 : 说明背景 / 你是谁 / 为什么讲这个 +主体(Core) → 3-5 页 : 核心内容,用 Layout 4/5/6/9/10 穿插 +转折(Shift) → 1 页 : 打破预期 / 提出新观点 +收束(Takeaway) → 1-2 页 : 金句 / 悬念问题 / 行动建议 +``` + +叙事弧 + 页数规划 + 主题节奏表(见 `layouts.md`),**三张表对齐后**再进 Step 2。 + +大纲建议保存为 `项目记录.md` 或 `大纲-v1.md`,便于后续迭代。 + +#### 图片约定(告知用户) + +在动手前向用户说清: + +- **文件夹位置**:`项目/XXX/ppt/images/` 下(和 `index.html` 同级) +- **命名规范**:`{页号}-{语义}.{ext}`,例如 `01-cover.jpg` / `03-figma.jpg` / `05-dashboard.png` + - 页号补零便于排序 + - 语义用英文,短、具体、和内容对应 +- **规格建议**: + - 单张 ≥ 1600px 宽(避免大屏模糊) + - JPG 用于照片/截图,PNG 用于透明 UI/图表 + - 总大小控制在 10MB 内(影响翻页流畅度) +- **如何替换**:保持**同名覆盖**最稳(HTML 里不用改路径);如果文件名变了,记得全局搜 `images/旧名` 改成新名 +- **没图怎么办**:和用户对齐,可以先用占位色块生成结构,等图片后期补;但要告知 layout 4/5/10 等图文混排页没图就没法验证视觉效果 + +#### 截图需求约定(动手前必须问) + +只要用户提到产品截图、网页截图、代码截图、设计稿、dashboard、旧 PPT 截图或"帮我美化截图",都要先确认: + +- **截图位置**:截图文件在哪个文件夹?是否已经命名好? +- **使用目的**:保真展示 / 截图美化 / 截图再设计 / UI 情景图? +- **落位比例**:最终放进哪个版式槽位?常用 `21:9` / `16:10` / `16:9` / `4:3` / `1:1` +- **内容要求**:是否必须保留全部文字、品牌、数据?是否有敏感信息要遮挡? +- **视觉处理**:是否需要主题背景、留边、居中/角落对齐、拆成长截图面板? + +默认策略:先让内容适配模板,再处理图片比例。截图需要保真时,先读 `references/screenshot-framing.md`,优先使用 `assets/screenshot-backgrounds/` 的内置背景资产做程序化 CleanShot X 式背景画布适配;只有原截图太乱、太长、太窄或需要概念化表达时,才用 GPT-M 2.0 做截图再设计。 + +#### Codex 配图生成(可选) + +如果当前运行环境是 **Codex**,完成 deck 初稿后,主动问用户是否需要用 GPT-M 2.0 生成配图并插入 PPT。不要默认生成。 + +推荐询问方式: + +> 要不要为这份 PPT 生成几张配图?可以做成人文纪实照片、杂志风信息图、流程/对比/系统关系图,或把截图再设计成统一的杂志风视觉。 + +如果用户确认生成,再问他想要哪种图片类型或风格;如果用户没有偏好,根据页面内容自行推荐 1-3 张最值得生成的配图。 + +如果用户提供的是截图,先判断是**截图美化**还是**截图再设计**: + +- 截图美化:读 `references/screenshot-framing.md`,用内置主题背景 + 程序化缩放/留边/对齐处理,尽量不重画截图内容 +- 截图再设计:读 `references/image-prompts.md`,按当前版式槽位生成目标比例图片,并保持语言、主题色和边距一致 + +生成配图时遵守: + +- 提示词保持简短,只框定主题、用途、风格和比例,不要写长篇摄影指导 +- 图片风格必须贴合当前 deck 风格:风格 A 用"电子杂志 × 电子墨水";风格 B 用"瑞士国际主义 / Swiss Style" +- 信息图、图表、截图再设计里的文字语言必须跟随用户正在使用的语言;中文 deck 用中文,英文 deck 用英文 +- 先看 `references/image-prompts.md` 选择图片类型和基础提示词 +- 如果处理用户原始截图,先看 `references/screenshot-framing.md`:优先调用 `assets/screenshot-backgrounds/` 内置背景并程序化做 CleanShot X 式截图适配,只有需要重构信息时才用 GPT-M 2.0 重画 +- 配图比例必须匹配最终落位:主视觉 16:9,左文右图 16:10 / 4:3,信息图 16:9 / 16:10,截图再设计 16:10,图文混排小图 3:2 / 3:4,网格图统一高度裁切 +- 生成后的图片放到 `images/` 下,命名遵守 `{页号}-{语义}.{ext}` + +### Step 2 · 拷贝模板 + +**根据 Step 1 选定的风格,拷贝对应的模板**到目标位置(通常是 `项目/XXX/ppt/index.html`),同时在同级建一个 `images/` 文件夹准备接图片。 + +```bash +mkdir -p "项目/XXX/ppt/images" + +# 风格 A · 电子杂志风 +cp "/assets/template.html" "项目/XXX/ppt/index.html" + +# 或 风格 B · 瑞士国际主义风 +cp "/assets/template-swiss.html" "项目/XXX/ppt/index.html" +``` + +两个 `template*.html` 都是**完整可运行**的文件——CSS、WebGL shader、翻页 JS、字体/图标 CDN 全已预设好,只有 `` 占位符等待你填充 slide 内容。 + +**注意**:风格 A 和 B **不能混用**。layouts.md 里的类(如 `.h-hero` 衬线大标题、`.display-zh` 等)只在 template.html 有定义;layouts-swiss.md 里的类(如 `.kpi-hero`、`.accent-block`、`.span-N`、`.dots` 等)只在 template-swiss.html 有定义。一份 deck 只能选一套。 + +#### 2.1 · 必改占位符(**容易漏**) + +拷贝后立刻改掉以下占位符,否则浏览器 Tab 会显示"[必填] 替换为 PPT 标题"这种尴尬文字: + +| 位置 | 原始 | 需改为 | +|------|------|--------| +| `` | `[必填] 替换为 PPT 标题 · Deck Title` | 实际 deck 标题(如 `一种新的工作方式 · Luke Wroblewski`) | + +每次拷贝完 template.html 第一件事:grep 一下"[必填]" 确认全部替换完。 + +#### 2.2 · 选定主题色(5 套预设 · 不允许自定义) + +本 skill **只允许从 5 套精心调配的预设里选一套**,不接受用户自定义 hex 值——颜色搭配错了画面瞬间变丑,保护美学比给自由更重要。 + +| # | 主题 | 适合 | +|---|------|------| +| 1 | 🖋 墨水经典 | 通用 / 商业发布 / 不知道选啥的默认 | +| 2 | 🌊 靛蓝瓷 | 科技 / 研究 / 数据 / 技术发布会 | +| 3 | 🌿 森林墨 | 自然 / 可持续 / 文化 / 非虚构 | +| 4 | 🍂 牛皮纸 | 怀旧 / 人文 / 文学 / 独立杂志 | +| 5 | 🌙 沙丘 | 艺术 / 设计 / 创意 / 画廊 | + +**操作**: +1. 基于内容主题推荐一套,或直接问用户选哪一套 +2. 打开 `references/themes.md`,找到对应主题的 `:root` 块 +3. **整体替换** `assets/template.html`(已拷贝版本)开头 `:root{` 块里标有"主题色"注释的那几行(`--ink` / `--ink-rgb` / `--paper` / `--paper-rgb` / `--paper-tint` / `--ink-tint`) +4. 其他 CSS 都走 `var(--...)`,无需任何其他改动 + +**硬规则**: +- 一份 deck 只用一套主题,不要中途换色 +- 不要接受用户给的任意 hex 值——委婉拒绝并展示 5 套让选 +- 不要混搭(例如 ink 取墨水经典、paper 取沙丘)——会彻底违和 + +### Step 3 · 填充内容 + +#### 3.0 · 预检:类名必须在模板的 `