feat(skill): skill template gallery + author wizard MVP

This commit is contained in:
matevip 2026-05-01 09:49:14 +08:00
parent 359600c77f
commit f0991f543f
12 changed files with 855 additions and 0 deletions

View File

@ -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.
*
* <p>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.
*
* <p>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<TemplateField> 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<String, Object> 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<Map<String, Object>> options = List.of();
}
}

View File

@ -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.
*
* <p>Three operations:
* <ul>
* <li>{@code GET /api/v1/skill-templates} list gallery</li>
* <li>{@code GET /api/v1/skill-templates/{id}} single template
* definition (for the wizard form)</li>
* <li>{@code POST /api/v1/skill-templates/{id}/instantiate}
* create a skill from filled-in values</li>
* </ul>
*/
@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<SkillTemplate>> list() {
return R.ok(registry.all());
}
@Operation(summary = "Get a single skill template")
@GetMapping("/{id}")
public R<SkillTemplate> 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<SkillEntity> instantiate(
@PathVariable String id,
@RequestBody Map<String, Object> values) {
return R.ok(templateService.instantiate(id, values));
}
}

View File

@ -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.
*
* <p>Loads {@code skill-templates/&#42;&#47;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.
*
* <p>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<String, SkillTemplate> 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<SkillTemplate> all() {
return new ArrayList<>(templates.values());
}
/** Lookup by id; null when missing. */
public SkillTemplate find(String id) {
return templates.get(id);
}
}

View File

@ -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.
*
* <p>Algorithm:
* <ol>
* <li>Validate every required field has a non-blank value.</li>
* <li>Compute auxiliary placeholders (e.g. {@code citation_string}
* from a boolean toggle) so the SKILL.md doesn't need conditional
* logic.</li>
* <li>Substitute {@code {{key}}} occurrences in the template body.</li>
* <li>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.</li>
* </ol>
*
* <p>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<String, Object> 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<String, String> 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<String, String> collectSubstitutions(SkillTemplate template,
Map<String, Object> values) {
Map<String, String> 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<String, String> 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();
}
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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<string, unknown>) =>
http.post(`/skill-templates/${id}/instantiate`, values),
}
// ==================== Skill Install ====================
export const skillInstallApi = {
searchHub: (q: string, limit = 20) =>

View File

@ -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',

View File

@ -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: '运行时技能已刷新',

View File

@ -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',

View File

@ -16,6 +16,13 @@
</svg>
{{ refreshing ? t('skills.refreshing') : t('skills.refreshRuntime') }}
</button>
<button class="btn-secondary" @click="$router.push('/skills/templates')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
{{ t('skills.browseTemplates') }}
</button>
<button class="btn-secondary" @click="showImportDialog = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>

View File

@ -0,0 +1,327 @@
<template>
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner templates-page">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">{{ t('skillTemplates.kicker') }}</div>
<h1 class="mc-page-title">{{ t('skillTemplates.title') }}</h1>
<p class="mc-page-desc">{{ t('skillTemplates.desc') }}</p>
</div>
<div class="header-actions">
<button class="btn-secondary" @click="$router.push('/skills')">
{{ t('skillTemplates.backToSkills') }}
</button>
</div>
</div>
<!-- Gallery -->
<div v-if="!selectedTemplate" class="template-grid">
<div
v-for="tpl in templates"
:key="tpl.id"
class="template-card mc-surface-card"
@click="onPickTemplate(tpl)"
>
<div class="template-card-head">
<span class="template-icon">{{ tpl.icon || '🧩' }}</span>
<div class="template-meta">
<h3 class="template-name">{{ tpl.nameZh || tpl.name }}</h3>
<p class="template-name-en">{{ tpl.nameEn || tpl.name }}</p>
</div>
<span class="template-type-badge" :class="`type-${tpl.type}`">{{ tpl.type }}</span>
</div>
<p class="template-desc">{{ tpl.descriptionZh || tpl.description }}</p>
<div class="template-footer">
<span class="template-fields-count">{{ tpl.fields?.length || 0 }} {{ t('skillTemplates.fields') }}</span>
<button class="btn-primary btn-sm">{{ t('skillTemplates.useTemplate') }} </button>
</div>
</div>
<p v-if="templates.length === 0" class="empty-state">
{{ t('skillTemplates.empty') }}
</p>
</div>
<!-- Wizard -->
<div v-else class="wizard mc-surface-card">
<div class="wizard-head">
<button class="btn-link" @click="selectedTemplate = null"> {{ t('skillTemplates.backToGallery') }}</button>
<h2>{{ selectedTemplate.nameZh || selectedTemplate.name }}</h2>
<p>{{ selectedTemplate.descriptionZh || selectedTemplate.description }}</p>
</div>
<div class="wizard-steps">
<div class="wizard-step" :class="{ active: step === 1, done: step > 1 }">
<span class="step-num">1</span>
<span>{{ t('skillTemplates.step1') }}</span>
</div>
<div class="wizard-step" :class="{ active: step === 2, done: step > 2 }">
<span class="step-num">2</span>
<span>{{ t('skillTemplates.step2') }}</span>
</div>
<div class="wizard-step" :class="{ active: step === 3 }">
<span class="step-num">3</span>
<span>{{ t('skillTemplates.step3') }}</span>
</div>
</div>
<!-- Step 1: form -->
<div v-if="step === 1" class="wizard-step-body">
<div v-for="field in selectedTemplate.fields" :key="field.key" class="form-group">
<label class="form-label">
{{ field.label || field.key }}
<span v-if="field.required" class="required-mark">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="form[field.key]"
class="form-input"
:placeholder="field.placeholder || ''"
/>
<textarea
v-else-if="field.type === 'textarea'"
v-model="form[field.key]"
class="form-input form-textarea"
rows="3"
:placeholder="field.placeholder || ''"
/>
<select
v-else-if="field.type === 'select'"
v-model="form[field.key]"
class="form-input"
>
<option v-for="opt in field.options || []" :key="opt.value" :value="opt.value">
{{ opt.label || opt.value }}
</option>
</select>
<label v-else-if="field.type === 'toggle'" class="toggle-row">
<input type="checkbox" v-model="form[field.key]" />
<span>{{ field.placeholder || '' }}</span>
</label>
<select
v-else-if="field.type === 'kb-picker'"
v-model="form[field.key]"
class="form-input"
>
<option value="">{{ t('skillTemplates.selectKb') }}</option>
<option v-for="kb in availableKbs" :key="kb.slug" :value="kb.slug">
{{ kb.name }} ({{ kb.slug }})
</option>
</select>
<input v-else v-model="form[field.key]" class="form-input" :placeholder="field.placeholder || ''" />
<p v-if="field.hint" class="form-hint">{{ field.hint }}</p>
</div>
<div class="wizard-actions">
<button class="btn-secondary" @click="selectedTemplate = null">{{ t('common.cancel') }}</button>
<button class="btn-primary" :disabled="!step1Valid" @click="step = 2">
{{ t('skillTemplates.next') }}
</button>
</div>
</div>
<!-- Step 2: preview -->
<div v-if="step === 2" class="wizard-step-body">
<p class="form-hint">{{ t('skillTemplates.previewHint') }}</p>
<pre class="preview-pre">{{ renderedSkillMd }}</pre>
<div class="wizard-actions">
<button class="btn-secondary" @click="step = 1"> {{ t('skillTemplates.back') }}</button>
<button class="btn-primary" :disabled="installing" @click="installSkill">
{{ installing ? t('skillTemplates.installing') : t('skillTemplates.installSkill') }}
</button>
</div>
</div>
<!-- Step 3: done -->
<div v-if="step === 3" class="wizard-step-body">
<div class="success-state">
<span class="success-icon"></span>
<h3>{{ t('skillTemplates.installed') }}</h3>
<p>{{ t('skillTemplates.installedDesc', { name: form.skill_name }) }}</p>
<div class="wizard-actions">
<button class="btn-secondary" @click="resetWizard">{{ t('skillTemplates.installAnother') }}</button>
<button class="btn-primary" @click="$router.push('/skills')">{{ t('skillTemplates.viewInSkills') }} </button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { skillTemplateApi, wikiApi } from '@/api/index'
interface TemplateField {
key: string
label?: string
type: string
required?: boolean
placeholder?: string
hint?: string
default?: any
options?: Array<{ value: string; label?: string }>
}
interface SkillTemplate {
id: string
name: string
nameZh?: string
nameEn?: string
description?: string
descriptionZh?: string
type: string
icon?: string
category?: string
fields?: TemplateField[]
skillMd?: string
}
const { t } = useI18n()
const templates = ref<SkillTemplate[]>([])
const selectedTemplate = ref<SkillTemplate | null>(null)
const step = ref<1 | 2 | 3>(1)
const form = reactive<Record<string, any>>({})
const installing = ref(false)
const availableKbs = ref<Array<{ slug: string; name: string }>>([])
onMounted(async () => {
try {
const res: any = await skillTemplateApi.list()
templates.value = res?.data || []
} catch {
templates.value = []
}
// Load KB list lazily for kb-picker fields. wikiApi.listKnowledgeBases is
// the existing endpoint used elsewhere. Failure is non-fatal.
try {
const res: any = await wikiApi.listKBs()
availableKbs.value = (res?.data || []).map((kb: any) => ({
// KBs don't have slugs in MateClaw fall back to id-as-slug for the
// wizard. The backend can still bind via id; manifest just stores
// whichever value the user picked here.
slug: kb.slug || (kb.id != null ? String(kb.id) : ''),
name: kb.name || kb.title || '',
})).filter((kb: any) => kb.slug)
} catch {
availableKbs.value = []
}
})
function onPickTemplate(template: SkillTemplate) {
selectedTemplate.value = template
step.value = 1
// Pre-populate defaults so the form starts in a usable state.
Object.keys(form).forEach(k => delete form[k])
for (const field of template.fields || []) {
form[field.key] = field.default !== undefined ? field.default : ''
}
}
const step1Valid = computed(() => {
if (!selectedTemplate.value) return false
for (const field of selectedTemplate.value.fields || []) {
if (field.required) {
const v = form[field.key]
if (v === undefined || v === null || (typeof v === 'string' && v.trim() === '')) return false
}
}
return true
})
const renderedSkillMd = computed(() => {
if (!selectedTemplate.value || !selectedTemplate.value.skillMd) return ''
let out = selectedTemplate.value.skillMd
// Mirror the backend's auxiliary placeholders so the preview matches reality.
const ctx: Record<string, string> = {}
for (const k of Object.keys(form)) ctx[k] = form[k]?.toString() ?? ''
if ('citation_required' in form) {
const req = !!form.citation_required
ctx.citation_string = req ? 'required' : 'optional'
ctx.citation_instruction = req
? '**每条建议必须标明引用的 KB 出处** ({{citation}} 自动注入)。'
: '如有 KB 引用,按 {{citation}} 标注;否则可省略。'
}
if ('output_language' in form) {
ctx.output_language_label = form.output_language === 'zh' ? '中文' : 'English'
}
out = out.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, (_match, k) => ctx[k] ?? '')
return out
})
async function installSkill() {
if (!selectedTemplate.value) return
installing.value = true
try {
await skillTemplateApi.instantiate(selectedTemplate.value.id, form)
step.value = 3
} catch (e: any) {
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skillTemplates.installFailed'))
} finally {
installing.value = false
}
}
function resetWizard() {
selectedTemplate.value = null
step.value = 1
Object.keys(form).forEach(k => delete form[k])
}
</script>
<style scoped>
.templates-page { gap: 18px; }
.template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
.template-card { padding: 18px; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; display: flex; flex-direction: column; gap: 12px; }
.template-card:hover { transform: translateY(-2px); box-shadow: var(--mc-shadow-medium); border-color: var(--mc-primary-light); }
.template-card-head { display: flex; align-items: flex-start; gap: 12px; }
.template-icon { font-size: 28px; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; background: var(--mc-bg-muted); border-radius: 12px; flex-shrink: 0; }
.template-meta { flex: 1; min-width: 0; }
.template-name { font-size: 16px; font-weight: 700; color: var(--mc-text-primary); margin: 0; }
.template-name-en { font-size: 12px; color: var(--mc-text-tertiary); margin: 2px 0 0; }
.template-type-badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; text-transform: uppercase; flex-shrink: 0; }
.type-knowledge { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
.type-prompt { background: var(--mc-primary-bg); color: var(--mc-primary); }
.template-desc { font-size: 13px; color: var(--mc-text-secondary); line-height: 1.5; margin: 0; flex: 1; }
.template-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-top: 8px; border-top: 1px solid var(--mc-border-light); }
.template-fields-count { font-size: 12px; color: var(--mc-text-tertiary); }
.empty-state { color: var(--mc-text-tertiary); font-size: 14px; padding: 40px; text-align: center; }
.wizard { padding: 24px; }
.wizard-head { padding-bottom: 12px; border-bottom: 1px solid var(--mc-border-light); margin-bottom: 16px; }
.wizard-head h2 { font-size: 18px; font-weight: 700; margin: 8px 0 4px; }
.wizard-head p { font-size: 13px; color: var(--mc-text-secondary); margin: 0; }
.btn-link { background: none; border: none; color: var(--mc-primary); cursor: pointer; padding: 0; font-size: 13px; }
.wizard-steps { display: flex; gap: 12px; margin-bottom: 20px; }
.wizard-step { display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 999px; background: var(--mc-bg-muted); color: var(--mc-text-tertiary); font-size: 13px; font-weight: 500; }
.wizard-step.active { background: var(--mc-primary-bg); color: var(--mc-primary); font-weight: 600; }
.wizard-step.done { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
.step-num { width: 22px; height: 22px; border-radius: 50%; background: var(--mc-bg-elevated); color: inherit; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; }
.wizard-step-body { display: flex; flex-direction: column; gap: 14px; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-label { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
.required-mark { color: var(--mc-danger); margin-left: 2px; }
.form-input { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); width: 100%; }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1); }
.form-textarea { resize: vertical; font-family: inherit; }
.form-hint { font-size: 11px; color: var(--mc-text-tertiary); line-height: 1.5; margin: 2px 0 0; }
.toggle-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; }
.preview-pre { background: var(--mc-bg-sunken); padding: 14px; border-radius: 10px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 12px; line-height: 1.5; color: var(--mc-text-primary); max-height: 480px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
.wizard-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--mc-border-light); }
.btn-primary { padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-primary.btn-sm { padding: 6px 12px; font-size: 12px; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.success-state { text-align: center; padding: 32px 20px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.success-icon { font-size: 48px; }
.success-state h3 { font-size: 20px; font-weight: 700; margin: 0; }
.success-state p { color: var(--mc-text-secondary); margin: 0; }
.success-state .wizard-actions { width: 100%; justify-content: center; border-top: none; padding-top: 0; }
.header-actions { display: flex; gap: 8px; }
</style>