From f0991f543f8b5698eaffafb4ee01d4a9a1a8c612 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 1 May 2026 09:49:14 +0800 Subject: [PATCH] feat(skill): skill template gallery + author wizard MVP --- .../mate/skill/template/SkillTemplate.java | 82 +++++ .../template/SkillTemplateController.java | 55 +++ .../skill/template/SkillTemplateRegistry.java | 78 +++++ .../skill/template/SkillTemplateService.java | 147 ++++++++ .../meeting-summarizer/template.json | 45 +++ .../skill-templates/tcm-qa/template.json | 49 +++ mateclaw-ui/src/api/index.ts | 8 + mateclaw-ui/src/i18n/locales/en-US.ts | 25 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 25 ++ mateclaw-ui/src/router/index.ts | 7 + mateclaw-ui/src/views/SkillMarket.vue | 7 + mateclaw-ui/src/views/SkillTemplates.vue | 327 ++++++++++++++++++ 12 files changed, 855 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplate.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateRegistry.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java create mode 100644 mateclaw-server/src/main/resources/skill-templates/meeting-summarizer/template.json create mode 100644 mateclaw-server/src/main/resources/skill-templates/tcm-qa/template.json create mode 100644 mateclaw-ui/src/views/SkillTemplates.vue diff --git a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplate.java b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplate.java new file mode 100644 index 00000000..65188a4e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplate.java @@ -0,0 +1,82 @@ +package vip.mate.skill.template; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * RFC-091 — definition of a skill creation template. + * + *

Templates are loaded from + * {@code resources/skill-templates/{id}/template.json} at startup. The + * frontend wizard renders the {@link #fields} as a form, the user fills + * them in, and the resulting key/value map is substituted into the + * {@link #skillMd} jinja-style placeholders to produce a manifest skill. + * + *

This is the data model only — see {@link SkillTemplateRegistry} for + * loading and {@link SkillTemplateService#instantiate} for the actual + * substitution + install path. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class SkillTemplate { + + /** Unique slug, matches the resource directory name. */ + private String id; + + private String name; + private String nameZh; + private String nameEn; + private String description; + private String descriptionZh; + + /** prompt | knowledge | code | mcp | acp — drives wizard branching. */ + private String type; + + private String category; + private String icon; + + /** Form fields the wizard renders. */ + @Builder.Default + private List fields = List.of(); + + /** + * Skill body with {@code {{placeholders}}} ready for substitution. + * Stored verbatim from {@code template.json}; line endings preserved. + */ + private String skillMd; + + /** Forward-compat catch-all for fields the wizard hasn't typed yet. */ + @Builder.Default + private Map extras = Map.of(); + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public static class TemplateField { + private String key; + private String label; + /** text | textarea | select | toggle | kb-picker */ + private String type; + @Builder.Default + private boolean required = false; + private String placeholder; + private String hint; + /** JSON {@code "default"} maps here — {@code default} is a Java keyword. */ + @JsonProperty("default") + private Object defaultValue; + @Builder.Default + private List> options = List.of(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java new file mode 100644 index 00000000..5ed11ac0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java @@ -0,0 +1,55 @@ +package vip.mate.skill.template; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import vip.mate.common.result.R; +import vip.mate.skill.model.SkillEntity; + +import java.util.List; +import java.util.Map; + +/** + * RFC-091 — REST entry point for the wizard UI. + * + *

Three operations: + *

+ */ +@Tag(name = "Skill Template Wizard") +@RestController +@RequestMapping("/api/v1/skill-templates") +@RequiredArgsConstructor +public class SkillTemplateController { + + private final SkillTemplateRegistry registry; + private final SkillTemplateService templateService; + + @Operation(summary = "List skill templates (RFC-091)") + @GetMapping + public R> list() { + return R.ok(registry.all()); + } + + @Operation(summary = "Get a single skill template") + @GetMapping("/{id}") + public R get(@PathVariable String id) { + SkillTemplate t = registry.find(id); + if (t == null) return R.fail("Template not found: " + id); + return R.ok(t); + } + + @Operation(summary = "Instantiate a template into a skill") + @PostMapping("/{id}/instantiate") + public R instantiate( + @PathVariable String id, + @RequestBody Map values) { + return R.ok(templateService.instantiate(id, values)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateRegistry.java b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateRegistry.java new file mode 100644 index 00000000..379e2945 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateRegistry.java @@ -0,0 +1,78 @@ +package vip.mate.skill.template; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Component; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * RFC-091 — registry for built-in skill creation templates. + * + *

Loads {@code skill-templates/*/template.json} from the + * classpath at startup and exposes them via {@link #all()} / + * {@link #find(String)}. We intentionally don't watch the filesystem — + * additions land via redeploys, mirroring how SKILL.md skills work. + * + *

Templates with malformed JSON are logged and skipped rather than + * failing startup, so a single bad template can't block the rest. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillTemplateRegistry { + + private static final String TEMPLATE_PATTERN = "classpath:/skill-templates/*/template.json"; + + private final ObjectMapper objectMapper; + + /** + * Insertion order matches the alphabetical scan, which keeps the + * gallery deterministic across deployments. Frontend can re-sort. + */ + private final Map templates = new LinkedHashMap<>(); + + @PostConstruct + public void load() { + templates.clear(); + try { + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + Resource[] resources = resolver.getResources(TEMPLATE_PATTERN); + for (Resource r : resources) { + try (InputStream in = r.getInputStream()) { + SkillTemplate tpl = objectMapper.readValue(in, SkillTemplate.class); + if (tpl.getId() == null || tpl.getId().isBlank()) { + log.warn("Skipping skill template at {} — missing id", r.getDescription()); + continue; + } + templates.put(tpl.getId(), tpl); + log.info("Loaded skill template: {} ({})", tpl.getId(), tpl.getType()); + } catch (Exception e) { + log.warn("Failed to parse skill template at {}: {}", + r.getDescription(), e.getMessage()); + } + } + log.info("SkillTemplateRegistry loaded {} template(s)", templates.size()); + } catch (Exception e) { + log.warn("Skill template scan failed; gallery will be empty: {}", e.getMessage()); + } + } + + /** All registered templates, deterministic order. */ + public List all() { + return new ArrayList<>(templates.values()); + } + + /** Lookup by id; null when missing. */ + public SkillTemplate find(String id) { + return templates.get(id); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java new file mode 100644 index 00000000..3e2239fc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java @@ -0,0 +1,147 @@ +package vip.mate.skill.template; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.exception.MateClawException; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillService; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * RFC-091 — instantiate a {@link SkillTemplate} into a real + * {@code mate_skill} row. + * + *

Algorithm: + *

    + *
  1. Validate every required field has a non-blank value.
  2. + *
  3. Compute auxiliary placeholders (e.g. {@code citation_string} + * from a boolean toggle) so the SKILL.md doesn't need conditional + * logic.
  4. + *
  5. Substitute {@code {{key}}} occurrences in the template body.
  6. + *
  7. Build a {@link SkillEntity} and hand it to + * {@link SkillService#createSkill}, which persists the row, + * initializes the workspace directory, and refreshes the runtime + * cache. The resolver will then pick up the manifest.
  8. + *
+ * + *

This intentionally goes through {@code SkillService.createSkill} + * rather than the install task pipeline — the wizard produces a local + * skill from in-process content, no bundle download involved. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillTemplateService { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{([a-zA-Z0-9_]+)}}"); + + private final SkillTemplateRegistry registry; + private final SkillService skillService; + + /** + * Instantiate the template by id, substituting fields, and create + * the skill. Returns the created {@link SkillEntity}. + * + * @param templateId id from the registry (e.g. {@code tcm-qa}) + * @param values user-supplied field values; missing required + * fields throw a translatable exception + */ + public SkillEntity instantiate(String templateId, Map values) { + SkillTemplate template = registry.find(templateId); + if (template == null) { + throw new MateClawException("err.skill_template.not_found", + "Skill template not found: " + templateId); + } + if (values == null) values = Map.of(); + + // 1. validate + collect into a single substitution map + Map substitutions = collectSubstitutions(template, values); + + // 2. render SKILL.md + String skillMd = render(template.getSkillMd(), substitutions); + + // 3. build entity and create via SkillService (which already + // handles uniqueness, defaults, workspace init, runtime + // cache refresh). + SkillEntity entity = new SkillEntity(); + entity.setName(substitutions.get("skill_name")); + String displayZh = substitutions.getOrDefault("display_name_zh", ""); + String displayEn = substitutions.getOrDefault("display_name_en", + substitutions.getOrDefault("display_name", "")); + entity.setNameZh(displayZh.isBlank() ? null : displayZh); + entity.setNameEn(displayEn.isBlank() ? null : displayEn); + entity.setDescription(template.getDescription()); + entity.setSkillType(mapType(template.getType())); + entity.setIcon(template.getIcon()); + entity.setVersion("1.0.0"); + entity.setAuthor("skill-template-wizard"); + entity.setSkillContent(skillMd); + entity.setEnabled(true); + + return skillService.createSkill(entity); + } + + private String mapType(String type) { + if (type == null) return "dynamic"; + // RFC-090 §5.1 introduced more types, but mate_skill.skill_type + // historically only knows builtin / mcp / dynamic. Map knowledge / + // prompt back to dynamic for legacy callers; the manifest_json + // column carries the real v3 type. + return switch (type) { + case "mcp" -> "mcp"; + case "builtin" -> "builtin"; + default -> "dynamic"; + }; + } + + private Map collectSubstitutions(SkillTemplate template, + Map values) { + Map out = new LinkedHashMap<>(); + for (SkillTemplate.TemplateField field : template.getFields()) { + Object raw = values.get(field.getKey()); + String resolved = raw == null ? null : raw.toString().trim(); + if ((resolved == null || resolved.isBlank())) { + if (field.getDefaultValue() != null) { + resolved = field.getDefaultValue().toString(); + } else if (field.isRequired()) { + throw new MateClawException("err.skill_template.missing_field", + "Required field missing: " + field.getKey()); + } else { + resolved = ""; + } + } + out.put(field.getKey(), resolved); + } + // Auxiliary derived placeholders so SKILL.md templates stay simple. + if (out.containsKey("citation_required")) { + boolean req = Boolean.parseBoolean(out.get("citation_required")); + out.put("citation_string", req ? "required" : "optional"); + out.put("citation_instruction", req + ? "**每条建议必须标明引用的 KB 出处** ({{citation}} 自动注入)。" + : "如有 KB 引用,按 {{citation}} 标注;否则可省略。"); + } + if (out.containsKey("output_language")) { + String lang = out.get("output_language"); + out.put("output_language_label", "zh".equalsIgnoreCase(lang) ? "中文" : "English"); + } + return out; + } + + private String render(String template, Map values) { + if (template == null) return ""; + Matcher m = PLACEHOLDER.matcher(template); + StringBuilder sb = new StringBuilder(); + while (m.find()) { + String key = m.group(1); + String replacement = values.getOrDefault(key, ""); + m.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + m.appendTail(sb); + return sb.toString(); + } +} diff --git a/mateclaw-server/src/main/resources/skill-templates/meeting-summarizer/template.json b/mateclaw-server/src/main/resources/skill-templates/meeting-summarizer/template.json new file mode 100644 index 00000000..f67ef4e5 --- /dev/null +++ b/mateclaw-server/src/main/resources/skill-templates/meeting-summarizer/template.json @@ -0,0 +1,45 @@ +{ + "id": "meeting-summarizer", + "name": "Meeting Summarizer", + "nameZh": "会议纪要助手", + "category": "content", + "type": "prompt", + "icon": "📝", + "description": "Pure-prompt skill that turns a meeting transcript into a structured summary (decisions, action items, owners).", + "descriptionZh": "把会议转录稿整理成结构化纪要(决策、行动项、负责人)。", + "fields": [ + { + "key": "skill_name", + "label": "Skill 标识 (slug)", + "type": "text", + "required": true, + "placeholder": "team-meeting-notes" + }, + { + "key": "display_name", + "label": "显示名", + "type": "text", + "required": false, + "placeholder": "Team Meeting Notes" + }, + { + "key": "team_context", + "label": "团队上下文", + "type": "textarea", + "required": false, + "default": "产研团队,敏捷开发,迭代节奏 2 周。", + "hint": "帮助模型理解参会人角色与术语" + }, + { + "key": "output_language", + "label": "输出语言", + "type": "select", + "default": "zh", + "options": [ + { "value": "zh", "label": "中文" }, + { "value": "en", "label": "English" } + ] + } + ], + "skillMd": "---\nname: {{skill_name}}\ndescription: {{display_name}} - 把会议转录整理为结构化纪要\ntype: prompt\nicon: \"📝\"\ncategory: content\nversion: 1.0.0\nauthor: skill-template-wizard\nself-evolution:\n lessons_enabled: true\n---\n\n# {{display_name}}\n\n你是会议纪要助手。**团队背景**:{{team_context}}\n\n输出格式({{output_language_label}}):\n\n1. **核心决策** — 列点,每条 1 句话\n2. **行动项 (Action Items)** — 表格:负责人 / 任务 / Due\n3. **风险与待办** — 列点\n4. **下次同步** — 时间 + 议题草案\n\n约束:\n- 不要把闲聊误判为决策\n- 没有明确负责人的任务标 `Owner: TBD`\n- 提到具体日期时统一为 `YYYY-MM-DD`\n" +} diff --git a/mateclaw-server/src/main/resources/skill-templates/tcm-qa/template.json b/mateclaw-server/src/main/resources/skill-templates/tcm-qa/template.json new file mode 100644 index 00000000..9da9dc0f --- /dev/null +++ b/mateclaw-server/src/main/resources/skill-templates/tcm-qa/template.json @@ -0,0 +1,49 @@ +{ + "id": "tcm-qa", + "name": "中医药知识问答", + "nameEn": "TCM Knowledge Q&A", + "category": "data", + "type": "knowledge", + "icon": "🌿", + "description": "Domain Q&A skill backed by a Wiki KB (e.g. 古籍 / 临床指南). Bind to a KB and start asking — citations are required by default.", + "descriptionZh": "中医师 / 药师可绑定古籍 / 临床指南 KB,自动 RAG + 引用追踪。", + "fields": [ + { + "key": "skill_name", + "label": "Skill 标识 (slug)", + "type": "text", + "required": true, + "placeholder": "tcm-classics-qa", + "hint": "小写英文 + 连字符;用作 SKILL.md 的 name 字段" + }, + { + "key": "display_name_zh", + "label": "显示名 (中文)", + "type": "text", + "required": false, + "placeholder": "中医药问答助手" + }, + { + "key": "kb_slug", + "label": "Wiki KB slug", + "type": "kb-picker", + "required": true, + "hint": "选择已存在的 Wiki 知识库;不存在请先到 Wiki 页面创建" + }, + { + "key": "expert_persona", + "label": "专家人设", + "type": "textarea", + "required": true, + "default": "你是有 20 年临床经验的中医师,擅长辨证论治。", + "hint": "skill 的角色定位,会注入到 system prompt" + }, + { + "key": "citation_required", + "label": "强制引用古籍出处", + "type": "toggle", + "default": true + } + ], + "skillMd": "---\nname: {{skill_name}}\ndescription: {{display_name_zh}} - 基于 Wiki KB 的领域问答\ntype: knowledge\nicon: \"🌿\"\ncategory: data\nversion: 1.0.0\nauthor: skill-template-wizard\nknowledge:\n bind_kb: {{kb_slug}}\n retrieval: hybrid\n top_k: 6\n citation: {{citation_string}}\n rerank: true\nself-evolution:\n lessons_enabled: true\n lessons_max_entries: 30\n---\n\n# {{display_name_zh}}\n\n{{expert_persona}}\n\n基于检索到的 KB 内容,结合用户描述给出专业建议。\n{{citation_instruction}}\n\n如果检索结果不足以支撑判断,告知用户「需要更多信息」,不要编造。\n" +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index f5e3ba8a..94db8b4a 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -176,6 +176,14 @@ export const skillApi = { clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`), } +// ==================== Skill Templates (RFC-091) ==================== +export const skillTemplateApi = { + list: () => http.get('/skill-templates'), + get: (id: string) => http.get(`/skill-templates/${id}`), + instantiate: (id: string, values: Record) => + http.post(`/skill-templates/${id}/instantiate`, values), +} + // ==================== Skill Install ==================== export const skillInstallApi = { searchHub: (q: string, limit = 20) => diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index be46f1d7..cea8c1e6 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1195,6 +1195,30 @@ export default { toggleFailed: 'Failed to toggle tool status', }, }, + skillTemplates: { + kicker: 'Wizard', + title: 'Skill Templates', + desc: 'Pick a starter template, fill in a few fields, and a manifest skill is created in your workspace.', + backToSkills: '← Skills', + backToGallery: 'Back to gallery', + fields: 'fields', + useTemplate: 'Use template', + empty: 'No skill templates available yet.', + step1: 'Fill parameters', + step2: 'Preview', + step3: 'Done', + next: 'Next', + back: 'Back', + selectKb: '— Select Knowledge Base —', + previewHint: 'Preview of the SKILL.md that will be created. Click Install to commit.', + installSkill: 'Install Skill', + installing: 'Installing...', + installFailed: 'Installation failed', + installed: 'Skill installed', + installedDesc: '{name} is ready in your workspace.', + installAnother: 'Install another', + viewInSkills: 'View in Skills', + }, plugins: { title: 'Plugins', desc: 'Manage external plugins loaded from JAR files', @@ -1858,6 +1882,7 @@ export default { desc: 'Manage tools and skills available to your agents', newSkill: 'New Skill', importSkill: 'Import Skill', + browseTemplates: 'Browse Templates', refreshRuntime: 'Refresh Runtime', refreshing: 'Refreshing...', refreshSuccess: 'Active skills refreshed', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 698dccc7..beb135c3 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1195,6 +1195,30 @@ export default { toggleFailed: '切换工具状态失败', }, }, + skillTemplates: { + kicker: '创作向导', + title: 'Skill 模板库', + desc: '选一个起步模板,填几个字段,模板向导会在你的工作区里生成一个标准 manifest skill。', + backToSkills: '← 返回 Skills', + backToGallery: '返回模板列表', + fields: '个字段', + useTemplate: '使用模板', + empty: '暂无可用模板。', + step1: '填写参数', + step2: '预览', + step3: '完成', + next: '下一步', + back: '上一步', + selectKb: '— 选择知识库 —', + previewHint: '即将生成的 SKILL.md 预览。确认无误后点 Install Skill 创建。', + installSkill: '安装 Skill', + installing: '安装中...', + installFailed: '安装失败', + installed: 'Skill 已安装', + installedDesc: '{name} 已添加到你的工作区。', + installAnother: '再装一个', + viewInSkills: '到 Skills 查看', + }, plugins: { title: '插件', desc: '管理从 JAR 文件加载的外部插件', @@ -1860,6 +1884,7 @@ export default { desc: '管理 Agent 可用的工具和技能', newSkill: '新建技能', importSkill: '导入技能', + browseTemplates: '浏览模板', refreshRuntime: '刷新运行时', refreshing: '刷新中...', refreshSuccess: '运行时技能已刷新', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index 963011da..472e07a3 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -63,6 +63,13 @@ const router = createRouter({ component: () => import('@/views/Security/Activity/index.vue'), meta: { title: 'Activity' }, }, + // RFC-091: Skill 模板库 + 创作向导 + { + path: 'skills/templates', + name: 'SkillTemplates', + component: () => import('@/views/SkillTemplates.vue'), + meta: { title: 'Skill Templates' }, + }, { path: 'plugins', name: 'Plugins', diff --git a/mateclaw-ui/src/views/SkillMarket.vue b/mateclaw-ui/src/views/SkillMarket.vue index 422a6f61..c502328c 100644 --- a/mateclaw-ui/src/views/SkillMarket.vue +++ b/mateclaw-ui/src/views/SkillMarket.vue @@ -16,6 +16,13 @@ {{ refreshing ? t('skills.refreshing') : t('skills.refreshRuntime') }} + + + + + +

+
+
+ {{ tpl.icon || '🧩' }} +
+

{{ tpl.nameZh || tpl.name }}

+

{{ tpl.nameEn || tpl.name }}

+
+ {{ tpl.type }} +
+

{{ tpl.descriptionZh || tpl.description }}

+ +
+

+ {{ t('skillTemplates.empty') }} +

+
+ + +
+
+ +

{{ selectedTemplate.nameZh || selectedTemplate.name }}

+

{{ selectedTemplate.descriptionZh || selectedTemplate.description }}

+
+ +
+
+ 1 + {{ t('skillTemplates.step1') }} +
+
+ 2 + {{ t('skillTemplates.step2') }} +
+
+ 3 + {{ t('skillTemplates.step3') }} +
+
+ + +
+
+ + +