mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): skill template gallery + author wizard MVP
This commit is contained in:
parent
359600c77f
commit
f0991f543f
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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/*/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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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) =>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '运行时技能已刷新',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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"/>
|
||||
|
||||
327
mateclaw-ui/src/views/SkillTemplates.vue
Normal file
327
mateclaw-ui/src/views/SkillTemplates.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user