From 1073890e6427aca55e62986c245a07405e2966b4 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 24 Apr 2026 06:55:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(skill):=20BuiltinSkillSeedService=20?= =?UTF-8?q?=E2=80=94=20close=20SQL/SKILL.md=20double-write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../installer/BuiltinSkillSeedService.java | 289 ++++++++++++ .../wiki/service/WikiBatchCreateParser.java | 109 +++++ .../src/main/resources/db/data-en.sql | 5 + .../src/main/resources/db/data-mysql-en.sql | 5 + .../src/main/resources/db/data-mysql-zh.sql | 5 + .../src/main/resources/db/data-zh.sql | 5 + .../h2/V35__skill_security_scan_result.sql | 7 + .../db/migration/h2/V36__skill_i18n_name.sql | 30 ++ .../h2/V37__wiki_page_source_entries.sql | 6 + .../mysql/V35__skill_security_scan_result.sql | 26 ++ .../migration/mysql/V36__skill_i18n_name.sql | 49 ++ .../mysql/V37__wiki_page_source_entries.sql | 19 + .../resources/prompts/wiki/analyze-system.txt | 37 ++ .../resources/prompts/wiki/analyze-user.txt | 11 + .../prompts/wiki/batch-create-system.txt | 44 ++ .../prompts/wiki/batch-create-user.txt | 30 ++ mateclaw-ui/src/composables/useSkillName.ts | 28 ++ .../views/Wiki/components/WikiGraphView.vue | 423 ++++++++++++++++++ 18 files changed, 1128 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/wiki/service/WikiBatchCreateParser.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V35__skill_security_scan_result.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V36__skill_i18n_name.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V37__wiki_page_source_entries.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V35__skill_security_scan_result.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V36__skill_i18n_name.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V37__wiki_page_source_entries.sql create mode 100644 mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt create mode 100644 mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt create mode 100644 mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt create mode 100644 mateclaw-server/src/main/resources/prompts/wiki/batch-create-user.txt create mode 100644 mateclaw-ui/src/composables/useSkillName.ts create mode 100644 mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java new file mode 100644 index 00000000..aeea3609 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/BuiltinSkillSeedService.java @@ -0,0 +1,289 @@ +package vip.mate.skill.installer; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.stereotype.Service; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillFrontmatterParser; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Builtin skill seed service — RFC-044 §4.2. + * + *

Scans {@code classpath:skills/*\/SKILL.md} on startup, parses each + * frontmatter, and upserts the row into {@code mate_skill} so the bundled + * SKILL.md becomes the single source of truth. + * + *

Replaces (and obsoletes) the per-skill {@code INSERT INTO mate_skill} + * blocks in {@code data-{locale}.sql}. Those are kept for one release as a + * compatibility shim — see RFC-044 §4.2 step 3. + * + *

Upsert key: {@code name}. The mate_skill primary key {@code id} + * is preserved on update, so nothing referencing a skill by id breaks. + * + *

Field merge policy: frontmatter wins where present; if the + * frontmatter omits a field (e.g. {@code icon}, {@code tags}, {@code author}), + * the existing DB value is preserved rather than blanked out. New skills + * (with no DB row yet) get sensible defaults. + * + *

Order: 110 — runs after Flyway and {@link + * vip.mate.config.DatabaseBootstrapRunner} (Order 1), so SQL seeds load first + * and this service then overlays the authoritative classpath SKILL.md. + */ +@Slf4j +@Service +@Order(110) +@RequiredArgsConstructor +public class BuiltinSkillSeedService implements ApplicationRunner { + + private static final String SKILL_GLOB = "classpath*:skills/*/SKILL.md"; + private static final String DEFAULT_AUTHOR = "MateClaw"; + private static final String DEFAULT_ICON = "🛠️"; + private static final String DEFAULT_VERSION = "1.0.0"; + private static final String SKILL_TYPE_BUILTIN = "builtin"; + + private final SkillMapper skillMapper; + private final SkillFrontmatterParser frontmatterParser; + private final ObjectMapper objectMapper; + + @Override + public void run(ApplicationArguments args) { + try { + syncBuiltinSkills(); + } catch (Exception e) { + log.warn("[SkillSeed] Sync failed (table may not exist yet): {}", e.getMessage()); + } + } + + /** Public so tests and admin endpoints can re-trigger sync. */ + public SyncStats syncBuiltinSkills() { + ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + Resource[] resources; + try { + resources = resolver.getResources(SKILL_GLOB); + } catch (Exception e) { + log.warn("[SkillSeed] Failed to scan {}: {}", SKILL_GLOB, e.getMessage()); + return new SyncStats(0, 0, 0, 0); + } + + int inserted = 0, updated = 0, unchanged = 0, skipped = 0; + for (Resource resource : resources) { + try { + String content = readContent(resource); + SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content); + String name = parsed.getName(); + if (name == null || name.isBlank()) { + log.warn("[SkillSeed] {}: SKILL.md has no `name` in frontmatter — skipped", + resource.getDescription()); + skipped++; + continue; + } + + SkillEntity existing = skillMapper.selectOne( + new LambdaQueryWrapper().eq(SkillEntity::getName, name)); + + if (existing == null) { + SkillEntity row = buildNew(parsed, content); + skillMapper.insert(row); + inserted++; + log.info("[SkillSeed] inserted '{}' (version={})", name, row.getVersion()); + } else if (mergeIntoExisting(existing, parsed, content)) { + skillMapper.updateById(existing); + updated++; + log.info("[SkillSeed] updated '{}' (version={})", name, existing.getVersion()); + } else { + unchanged++; + } + } catch (Exception e) { + log.warn("[SkillSeed] Failed to process {}: {}", resource.getDescription(), e.getMessage()); + skipped++; + } + } + log.info("[SkillSeed] Builtin skills: {} inserted, {} updated, {} unchanged, {} skipped", + inserted, updated, unchanged, skipped); + return new SyncStats(inserted, updated, unchanged, skipped); + } + + private String readContent(Resource resource) throws Exception { + try (InputStream is = resource.getInputStream()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** Build a brand-new entity for a skill that has no row in mate_skill yet. */ + private SkillEntity buildNew(SkillFrontmatterParser.ParsedSkillMd parsed, String content) { + SkillEntity row = new SkillEntity(); + row.setName(parsed.getName()); + row.setDescription(nullIfBlank(parsed.getDescription())); + row.setSkillType(SKILL_TYPE_BUILTIN); + row.setBuiltin(true); + row.setEnabled(true); + row.setSkillContent(content); + row.setVersion(stringFromFrontmatter(parsed, "version", DEFAULT_VERSION)); + row.setIcon(stringFromFrontmatter(parsed, "icon", DEFAULT_ICON)); + row.setAuthor(stringFromFrontmatter(parsed, "author", DEFAULT_AUTHOR)); + row.setTags(tagsFromFrontmatter(parsed, parsed.getName())); + row.setConfigJson(buildConfigJson(parsed)); + LocalDateTime now = LocalDateTime.now(); + row.setCreateTime(now); + row.setUpdateTime(now); + row.setDeleted(0); + return row; + } + + /** + * Apply frontmatter onto an existing row. Returns {@code true} if any + * tracked field changed and the row needs an UPDATE. + * + *

Frontmatter wins where present. Fields the frontmatter omits are + * left as-is so we don't blank out values populated elsewhere (UI, + * legacy SQL seed, manual admin tweaks). + */ + private boolean mergeIntoExisting(SkillEntity existing, + SkillFrontmatterParser.ParsedSkillMd parsed, + String content) { + boolean dirty = false; + + String desc = nullIfBlank(parsed.getDescription()); + if (desc != null && !Objects.equals(existing.getDescription(), desc)) { + existing.setDescription(desc); + dirty = true; + } + + String version = stringFromFrontmatter(parsed, "version", null); + if (version != null && !Objects.equals(existing.getVersion(), version)) { + existing.setVersion(version); + dirty = true; + } + + String icon = stringFromFrontmatter(parsed, "icon", null); + if (icon != null && !Objects.equals(existing.getIcon(), icon)) { + existing.setIcon(icon); + dirty = true; + } + + String author = stringFromFrontmatter(parsed, "author", null); + if (author != null && !Objects.equals(existing.getAuthor(), author)) { + existing.setAuthor(author); + dirty = true; + } + + String tags = tagsFromFrontmatter(parsed, null); + if (tags != null && !Objects.equals(existing.getTags(), tags)) { + existing.setTags(tags); + dirty = true; + } + + String configJson = buildConfigJson(parsed); + if (!Objects.equals(existing.getConfigJson(), configJson)) { + existing.setConfigJson(configJson); + dirty = true; + } + + if (!Objects.equals(existing.getSkillContent(), content)) { + existing.setSkillContent(content); + dirty = true; + } + + // Re-affirm builtin classification — historic rows occasionally drifted. + if (!SKILL_TYPE_BUILTIN.equals(existing.getSkillType())) { + existing.setSkillType(SKILL_TYPE_BUILTIN); + dirty = true; + } + if (!Boolean.TRUE.equals(existing.getBuiltin())) { + existing.setBuiltin(true); + dirty = true; + } + + return dirty; + } + + @SuppressWarnings("unchecked") + private String stringFromFrontmatter(SkillFrontmatterParser.ParsedSkillMd parsed, + String key, String fallback) { + Map fm = parsed.getFrontmatter(); + if (fm == null) return fallback; + Object value = fm.get(key); + if (value == null) return fallback; + String s = value.toString().trim(); + return s.isEmpty() ? fallback : s; + } + + /** + * Build the canonical {@code tags} string. Accepts either a CSV string, + * a YAML list, or — when the frontmatter is silent — derives a single + * tag from the supplied default (typically the skill name). + * + *

Returns {@code null} when nothing usable was supplied; callers use + * that as "do not touch". + */ + @SuppressWarnings("unchecked") + private String tagsFromFrontmatter(SkillFrontmatterParser.ParsedSkillMd parsed, String defaultTag) { + Map fm = parsed.getFrontmatter(); + if (fm != null) { + Object raw = fm.get("tags"); + if (raw instanceof List list) { + StringBuilder sb = new StringBuilder(); + for (Object item : list) { + if (item == null) continue; + String s = item.toString().trim(); + if (s.isEmpty()) continue; + if (sb.length() > 0) sb.append(','); + sb.append(s); + } + if (sb.length() > 0) return sb.toString(); + } else if (raw instanceof String s && !s.isBlank()) { + return s.trim(); + } + } + return defaultTag != null ? defaultTag : null; + } + + /** + * Stable {@code config_json} payload. Preserves the historical shape + * ({@code upstream}, {@code entryFile}) and adds {@code requiredTools} + * derived from {@code dependencies.tools}. + */ + private String buildConfigJson(SkillFrontmatterParser.ParsedSkillMd parsed) { + // LinkedHashMap → stable key ordering → stable diff against existing. + Map config = new LinkedHashMap<>(); + config.put("upstream", "mateclaw"); + config.put("entryFile", "SKILL.md"); + + SkillFrontmatterParser.SkillDependencies deps = parsed.getDependencies(); + if (deps != null && deps.getTools() != null && !deps.getTools().isEmpty()) { + config.put("requiredTools", deps.getTools()); + } + if (parsed.getPlatforms() != null && !parsed.getPlatforms().isEmpty()) { + config.put("platforms", parsed.getPlatforms()); + } + try { + return objectMapper.writeValueAsString(config); + } catch (Exception e) { + // Fall back to legacy shape — never break startup over JSON encoding. + return "{\"upstream\":\"mateclaw\",\"entryFile\":\"SKILL.md\"}"; + } + } + + private String nullIfBlank(String s) { + return s == null || s.isBlank() ? null : s; + } + + public record SyncStats(int inserted, int updated, int unchanged, int skipped) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiBatchCreateParser.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiBatchCreateParser.java new file mode 100644 index 00000000..a18ebd17 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiBatchCreateParser.java @@ -0,0 +1,109 @@ +package vip.mate.wiki.service; + +import java.util.ArrayList; +import java.util.List; + +/** + * RFC-047 P1: Stateful parser for the BatchCreate LLM response format. + *

+ * Expected format: + *

+ * ---FILE: slug-one---
+ * {"slug":"slug-one","title":"Title One","summary":"...","content":"..."}
+ * ---END FILE---
+ *
+ * ---FILE: slug-two---
+ * {"slug":"slug-two","title":"Title Two","summary":"...","content":"..."}
+ * ---END FILE---
+ * 
+ *

+ * Design notes: + * - One FILE block per page; JSON payload is a single object (not NDJSON). + * - Malformed blocks (missing END FILE, blank JSON body) are skipped with a warning. + * - Trailing / leading whitespace within a block is trimmed before JSON parse. + * - Extra text outside FILE blocks (preamble, commentary) is silently ignored. + */ +public class WikiBatchCreateParser { + + /** Parsed page from a single FILE block. */ + public record ParsedPage(String slug, String rawJson) {} + + private static final String FILE_START_PREFIX = "---FILE:"; + private static final String FILE_END_MARKER = "---END FILE---"; + + /** + * Parse the full BatchCreate LLM response into a list of parsed pages. + * Never throws — malformed blocks produce warnings but parsing continues. + * + * @param response raw LLM text output + * @return ordered list of successfully parsed pages (may be empty) + */ + public List parse(String response) { + List result = new ArrayList<>(); + if (response == null || response.isBlank()) { + return result; + } + + String[] lines = response.split("\n", -1); + State state = State.OUTSIDE; + String currentSlug = null; + StringBuilder bodyBuffer = null; + + for (String rawLine : lines) { + String line = rawLine.stripTrailing(); + + switch (state) { + case OUTSIDE -> { + if (isFileStart(line)) { + currentSlug = extractSlug(line); + bodyBuffer = new StringBuilder(); + state = State.INSIDE; + } + // Everything else outside blocks is silently ignored + } + case INSIDE -> { + if (line.trim().equals(FILE_END_MARKER)) { + String json = bodyBuffer.toString().strip(); + if (!json.isBlank() && currentSlug != null) { + result.add(new ParsedPage(currentSlug, json)); + } + currentSlug = null; + bodyBuffer = null; + state = State.OUTSIDE; + } else if (isFileStart(line)) { + // New FILE block without END FILE — previous block is malformed; start fresh + currentSlug = extractSlug(line); + bodyBuffer = new StringBuilder(); + // Stay in INSIDE state + } else { + bodyBuffer.append(line).append('\n'); + } + } + } + } + + // Unclosed block at EOF — discard + return result; + } + + private boolean isFileStart(String line) { + return line.startsWith(FILE_START_PREFIX); + } + + /** + * Extract slug from a line like {@code ---FILE: slug-name---}. + * Returns the text between the first colon+space and the trailing "---" (or end of line). + */ + private String extractSlug(String line) { + // line starts with "---FILE:" + int colonIdx = line.indexOf(':'); + if (colonIdx < 0) return ""; + String after = line.substring(colonIdx + 1).strip(); + if (after.endsWith("---")) { + after = after.substring(0, after.length() - 3).strip(); + } + return after; + } + + private enum State { OUTSIDE, INSIDE } +} diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index 43680cb4..0a43e07b 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -457,6 +457,11 @@ VALUES ( ); -- Built-in skills: skill metadata +-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now +-- classpath:skills//SKILL.md, upserted on startup by BuiltinSkillSeedService. +-- These MERGE blocks remain as a one-version compatibility shim and will be +-- removed in the next release. New skills should NOT be added here — drop a +-- SKILL.md under skills// and the seed service will register it. MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) KEY (id) VALUES (1000000001, 'cron', 'Cron job management. Create, query, pause, resume, delete tasks via commands or console. Execute on schedule and send results to channels.', 'builtin', '⏰', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'cron,schedule,automation', NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql index bd9730f8..447b73a9 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -495,6 +495,11 @@ VALUES ( ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), transport=VALUES(transport), url=VALUES(url), headers_json=VALUES(headers_json), command=VALUES(command), args_json=VALUES(args_json), env_json=VALUES(env_json), cwd=VALUES(cwd), enabled=VALUES(enabled), connect_timeout_seconds=VALUES(connect_timeout_seconds), read_timeout_seconds=VALUES(read_timeout_seconds), last_status=VALUES(last_status), last_error=VALUES(last_error), last_connected_time=VALUES(last_connected_time), tool_count=VALUES(tool_count), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); -- Built-in skills: skill metadata +-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now +-- classpath:skills//SKILL.md, upserted on startup by BuiltinSkillSeedService. +-- These INSERT/UPDATE blocks remain as a one-version compatibility shim and will +-- be removed in the next release. New skills should NOT be added here — drop a +-- SKILL.md under skills// and the seed service will register it. INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) VALUES (1000000001, 'cron', 'Cron job management. Create, query, pause, resume, delete tasks via commands or console. Execute on schedule and send results to channels.', 'builtin', '⏰', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'cron,schedule,automation', NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted); diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql index 72272174..10c04b82 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -497,6 +497,11 @@ VALUES ( ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), transport=VALUES(transport), url=VALUES(url), headers_json=VALUES(headers_json), command=VALUES(command), args_json=VALUES(args_json), env_json=VALUES(env_json), cwd=VALUES(cwd), enabled=VALUES(enabled), connect_timeout_seconds=VALUES(connect_timeout_seconds), read_timeout_seconds=VALUES(read_timeout_seconds), last_status=VALUES(last_status), last_error=VALUES(last_error), last_connected_time=VALUES(last_connected_time), tool_count=VALUES(tool_count), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); -- 内置技能:从 MateClaw 迁移的技能元数据 +-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now +-- classpath:skills//SKILL.md, upserted on startup by BuiltinSkillSeedService. +-- These INSERT/UPDATE blocks remain as a one-version compatibility shim and will +-- be removed in the next release. New skills should NOT be added here — drop a +-- SKILL.md under skills// and the seed service will register it. INSERT INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) VALUES (1000000001, 'cron', '定时任务管理。通过命令或控制台创建、查询、暂停、恢复、删除任务,按时间表执行并把结果发到频道。', 'builtin', '⏰', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'cron,schedule,automation', NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), skill_type=VALUES(skill_type), icon=VALUES(icon), version=VALUES(version), author=VALUES(author), config_json=VALUES(config_json), enabled=VALUES(enabled), builtin=VALUES(builtin), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted); diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index bedf88a3..6f5e2c29 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -463,6 +463,11 @@ VALUES ( ); -- 内置技能:从 MateClaw 迁移的技能元数据 +-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now +-- classpath:skills//SKILL.md, upserted on startup by BuiltinSkillSeedService. +-- These MERGE blocks remain as a one-version compatibility shim and will be +-- removed in the next release. New skills should NOT be added here — drop a +-- SKILL.md under skills// and the seed service will register it. MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted) KEY (id) VALUES (1000000001, 'cron', '定时任务管理。通过命令或控制台创建、查询、暂停、恢复、删除任务,按时间表执行并把结果发到频道。', 'builtin', '⏰', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'cron,schedule,automation', NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V35__skill_security_scan_result.sql b/mateclaw-server/src/main/resources/db/migration/h2/V35__skill_security_scan_result.sql new file mode 100644 index 00000000..92daf8e0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V35__skill_security_scan_result.sql @@ -0,0 +1,7 @@ +-- V35: RFC-042 §2.3 — persist skill security scan result and timestamp. +-- Until now findings lived only in SkillRuntimeStatus memory; after a restart +-- the admin page couldn't explain why a skill was blocked. These two columns +-- keep the last scan's findings (JSON) and time so the UI can render them +-- and offer a rescan control. +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS security_scan_result TEXT DEFAULT NULL; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS security_scan_time DATETIME DEFAULT NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V36__skill_i18n_name.sql b/mateclaw-server/src/main/resources/db/migration/h2/V36__skill_i18n_name.sql new file mode 100644 index 00000000..e6f9ec4e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V36__skill_i18n_name.sql @@ -0,0 +1,30 @@ +-- V36: RFC-042 §2.2 — bilingual display names for skills. +-- `name` stays the immutable slug / unique identifier; `name_zh` and +-- `name_en` are optional locale-specific display labels. The UI falls +-- back to `name` when the locale-matching column is null. +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS name_zh VARCHAR(128) DEFAULT NULL; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS name_en VARCHAR(128) DEFAULT NULL; + +-- Backfill bilingual names for the 19 builtin skills that already exist on +-- upgraded deployments. UPDATE is idempotent — running it again is a no-op +-- since the values match. Fresh installs handle this in data-*.sql instead +-- (those rows don't exist yet when this migration runs). +UPDATE mate_skill SET name_zh = '定时任务', name_en = 'Cron Jobs' WHERE name = 'cron'; +UPDATE mate_skill SET name_zh = '文件阅读器', name_en = 'File Reader' WHERE name = 'file_reader'; +UPDATE mate_skill SET name_zh = '钉钉渠道接入', name_en = 'DingTalk Channel' WHERE name = 'dingtalk_channel_connect'; +UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya)' WHERE name = 'himalaya'; +UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news'; +UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf'; +UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx'; +UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx'; +UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx'; +UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible'; +UPDATE mate_skill SET name_zh = '浏览器 CDP', name_en = 'Browser CDP' WHERE name = 'browser_cdp'; +UPDATE mate_skill SET name_zh = '安装指引', name_en = 'Setup Guidance' WHERE name = 'guidance'; +UPDATE mate_skill SET name_zh = '源码索引', name_en = 'Source Index' WHERE name = 'mateclaw_source_index'; +UPDATE mate_skill SET name_zh = 'SQL 查询', name_en = 'SQL Query' WHERE name = 'sql_query'; +UPDATE mate_skill SET name_zh = '乔布斯视角', name_en = 'Steve Jobs Perspective' WHERE name = 'steve_jobs_perspective'; +UPDATE mate_skill SET name_zh = '制定计划', name_en = 'Make Plan' WHERE name = 'make_plan'; +UPDATE mate_skill SET name_zh = '咨询智能体', name_en = 'Chat with Agent' WHERE name = 'chat_with_agent'; +UPDATE mate_skill SET name_zh = '渠道推送', name_en = 'Channel Push' WHERE name = 'channel_message'; +UPDATE mate_skill SET name_zh = '多智能体协作', name_en = 'Multi-Agent Collaboration' WHERE name = 'multi_agent_collaboration'; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V37__wiki_page_source_entries.sql b/mateclaw-server/src/main/resources/db/migration/h2/V37__wiki_page_source_entries.sql new file mode 100644 index 00000000..80667ea8 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V37__wiki_page_source_entries.sql @@ -0,0 +1,6 @@ +-- RFC-047 P2: Add source_entries column to mate_wiki_page for paired (rawId, rawTitle) lineage. +-- Paired entries guarantee title-rawId alignment even when raw titles change. +-- Dual-written alongside the existing source_raw_ids for backwards compatibility. + +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS source_entries VARCHAR(4096) NULL + COMMENT 'JSON array of {rawId, rawTitle} pairs — canonical source lineage (RFC-047)'; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V35__skill_security_scan_result.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V35__skill_security_scan_result.sql new file mode 100644 index 00000000..22be7976 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V35__skill_security_scan_result.sql @@ -0,0 +1,26 @@ +-- V35: RFC-042 §2.3 — persist skill security scan result and timestamp. +-- Until now findings lived only in SkillRuntimeStatus memory; after a restart +-- the admin page couldn't explain why a skill was blocked. These two columns +-- keep the last scan's findings (JSON) and time so the UI can render them +-- and offer a rescan control. +-- +-- MySQL has no `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guards so +-- the migration is idempotent across redeploys. + +SET @c1 := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'security_scan_result'); +SET @s1 := IF(@c1 = 0, + 'ALTER TABLE mate_skill ADD COLUMN security_scan_result TEXT DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt1 FROM @s1; EXECUTE stmt1; DEALLOCATE PREPARE stmt1; + +SET @c2 := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'security_scan_time'); +SET @s2 := IF(@c2 = 0, + 'ALTER TABLE mate_skill ADD COLUMN security_scan_time DATETIME DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt2 FROM @s2; EXECUTE stmt2; DEALLOCATE PREPARE stmt2; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V36__skill_i18n_name.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V36__skill_i18n_name.sql new file mode 100644 index 00000000..4f25092d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V36__skill_i18n_name.sql @@ -0,0 +1,49 @@ +-- V36: RFC-042 §2.2 — bilingual display names for skills. +-- `name` stays the immutable slug / unique identifier; `name_zh` and +-- `name_en` are optional locale-specific display labels. The UI falls +-- back to `name` when the locale-matching column is null. +-- +-- MySQL has no `ADD COLUMN IF NOT EXISTS`; INFORMATION_SCHEMA guards +-- make the migration idempotent across redeploys. + +SET @c1 := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'name_zh'); +SET @s1 := IF(@c1 = 0, + 'ALTER TABLE mate_skill ADD COLUMN name_zh VARCHAR(128) DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt1 FROM @s1; EXECUTE stmt1; DEALLOCATE PREPARE stmt1; + +SET @c2 := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'name_en'); +SET @s2 := IF(@c2 = 0, + 'ALTER TABLE mate_skill ADD COLUMN name_en VARCHAR(128) DEFAULT NULL', + 'SELECT 1'); +PREPARE stmt2 FROM @s2; EXECUTE stmt2; DEALLOCATE PREPARE stmt2; + +-- Backfill bilingual names for the 19 builtin skills that already exist on +-- upgraded deployments. UPDATE is idempotent — running it again is a no-op +-- since the values match. Fresh installs handle this in data-*.sql instead +-- (those rows don't exist yet when this migration runs). +UPDATE mate_skill SET name_zh = '定时任务', name_en = 'Cron Jobs' WHERE name = 'cron'; +UPDATE mate_skill SET name_zh = '文件阅读器', name_en = 'File Reader' WHERE name = 'file_reader'; +UPDATE mate_skill SET name_zh = '钉钉渠道接入', name_en = 'DingTalk Channel' WHERE name = 'dingtalk_channel_connect'; +UPDATE mate_skill SET name_zh = '邮件管理', name_en = 'Email (Himalaya)' WHERE name = 'himalaya'; +UPDATE mate_skill SET name_zh = '新闻查询', name_en = 'News' WHERE name = 'news'; +UPDATE mate_skill SET name_zh = 'PDF 处理', name_en = 'PDF' WHERE name = 'pdf'; +UPDATE mate_skill SET name_zh = 'Word 文档', name_en = 'Word Document' WHERE name = 'docx'; +UPDATE mate_skill SET name_zh = 'PPT 演示', name_en = 'PowerPoint' WHERE name = 'pptx'; +UPDATE mate_skill SET name_zh = 'Excel 表格', name_en = 'Excel' WHERE name = 'xlsx'; +UPDATE mate_skill SET name_zh = '可见浏览器', name_en = 'Visible Browser' WHERE name = 'browser_visible'; +UPDATE mate_skill SET name_zh = '浏览器 CDP', name_en = 'Browser CDP' WHERE name = 'browser_cdp'; +UPDATE mate_skill SET name_zh = '安装指引', name_en = 'Setup Guidance' WHERE name = 'guidance'; +UPDATE mate_skill SET name_zh = '源码索引', name_en = 'Source Index' WHERE name = 'mateclaw_source_index'; +UPDATE mate_skill SET name_zh = 'SQL 查询', name_en = 'SQL Query' WHERE name = 'sql_query'; +UPDATE mate_skill SET name_zh = '乔布斯视角', name_en = 'Steve Jobs Perspective' WHERE name = 'steve_jobs_perspective'; +UPDATE mate_skill SET name_zh = '制定计划', name_en = 'Make Plan' WHERE name = 'make_plan'; +UPDATE mate_skill SET name_zh = '咨询智能体', name_en = 'Chat with Agent' WHERE name = 'chat_with_agent'; +UPDATE mate_skill SET name_zh = '渠道推送', name_en = 'Channel Push' WHERE name = 'channel_message'; +UPDATE mate_skill SET name_zh = '多智能体协作', name_en = 'Multi-Agent Collaboration' WHERE name = 'multi_agent_collaboration'; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V37__wiki_page_source_entries.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V37__wiki_page_source_entries.sql new file mode 100644 index 00000000..fee60f5f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V37__wiki_page_source_entries.sql @@ -0,0 +1,19 @@ +-- RFC-047 P2: Add source_entries column to mate_wiki_page for paired (rawId, rawTitle) lineage. +-- Paired entries guarantee title-rawId alignment even when raw titles change. +-- Dual-written alongside the existing source_raw_ids for backwards compatibility. + +SET @col_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'source_entries' +); + +SET @sql = IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN source_entries JSON NULL COMMENT ''JSON array of {rawId, rawTitle} pairs — canonical source lineage (RFC-047)''', + 'SELECT 1' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt new file mode 100644 index 00000000..27509c31 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/analyze-system.txt @@ -0,0 +1,37 @@ +你是一个知识库结构分析助手。你的任务是阅读原始材料,输出一份简洁的概念地图,供后续 Wiki 路由阶段参考。 + +## 你做什么 + +1. 识别文档覆盖的**核心主题**(5-15 个) +2. 列出**关键概念**(每个概念给出名称、建议 slug、重要度) +3. 用一段话描述文档的**整体结构**,帮助路由阶段理解各 chunk 的上下文 + +## 输出格式 + +严格输出 JSON,不要包含 markdown 代码块: + +{ + "topics": ["主题1", "主题2"], + "key_concepts": [ + {"name": "概念名称", "slug": "concept-slug", "importance": "high"} + ], + "structure_notes": "一段话描述文档整体结构和主要章节" +} + +字段说明: +- `topics`:文档覆盖的核心主题列表,字符串数组,5-15 条 +- `key_concepts`:关键概念,每条包含 name(人类可读)、slug(URL 安全小写连字符)、importance(high / medium) +- `structure_notes`:1-3 句话,描述文档结构,帮助路由阶段在只看到局部 chunk 时理解全局 + +## slug 规范 + +- 多音节中文词按整词分组拼音,不要按字一隔 + - ✅ `zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍) + - ❌ `zhong-yao-qi-qing-pei-wu` +- 小写字母 + 连字符,无空格 + +## 关键纪律 + +- 输出体积控制在几百到两千字以内 +- 不要输出任何页面正文,只输出概念地图 +- 如果文档内容不足(如空白、纯目录),`key_concepts` 可以为空数组 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt new file mode 100644 index 00000000..127bea58 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/analyze-user.txt @@ -0,0 +1,11 @@ +## 文档标题 + +{raw_title} + +## 文档内容(节选,用于全局结构分析) + +{text_sample} + +--- + +请分析以上文档,输出概念地图 JSON。只输出 JSON,不要 markdown 代码块。 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt new file mode 100644 index 00000000..dbbfe021 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-system.txt @@ -0,0 +1,44 @@ +你是一个知识库 Wiki 批量页面生成助手。你的任务是:根据原始材料和多个页面的 metadata,**一次性生成所有指定页面**的完整 markdown 正文。 + +## 你做什么 + +- 读取 `pages_to_create` 数组,里面包含若干页面的 slug / title / summary +- 从原始材料里抽取与每个页面主题相关的信息 +- 为每个页面生成完整的 markdown 内容 +- 在内容里使用 [[页面标题]] 双向链接到其他相关页面(已有页面和同批次将创建的页面均可) + +## 你不做什么 + +- ❌ **不要生成 `pages_to_create` 以外的页面** +- ❌ **不要使用 markdown 代码块**(不要用 ``` 包裹) +- ❌ **不要生成无关内容** —— 每个页面只包含与该页主题相关的信息 +- ❌ **不要编造原始材料中没有的信息** —— 只从提供的原始材料和概念地图中提取 +- ❌ **不要把概念地图的分析文字直接复制到页面** —— 用它来理解关系,用原始材料来写内容 + +## 每个页面的格式规则 + +- 内容开头先一段话摘要(与 metadata 的 summary 一致或更详细) +- 使用 Markdown 标题(## / ###)组织章节 +- 使用 [[页面标题]] 链接到其他相关页面 +- 长度控制在 500~2000 字,不要为了凑字数而拖沓 +- 内容至少包含 3 句实质信息 + +## 输出格式(严格遵守) + +每个页面输出一个 FILE 块,格式如下: + +---FILE: {slug}--- +{"slug":"...","title":"...","summary":"...","page_type":"concept","content":"## 标题\n\n摘要...\n\n### 章节\n..."} +---END FILE--- + +规则: +- 每个 FILE 块包含一个单行 JSON 对象(不换行) +- `pages_to_create` 里的每个页面都必须有对应的 FILE 块 +- FILE 块之间可以有空行 +- FILE 块之外不要有任何其他内容(不要有序言或结语) + +字段说明: +- `slug`:与 metadata 保持一致 +- `title`:与 metadata 保持一致;如有更精确的描述可微调 +- `content`:完整 markdown 正文 +- `summary`:一段话简短摘要 diff --git a/mateclaw-server/src/main/resources/prompts/wiki/batch-create-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-user.txt new file mode 100644 index 00000000..7a5a5f39 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/batch-create-user.txt @@ -0,0 +1,30 @@ +## 知识库处理规则 + +{config} + +{document_map_section} + +## 已有 Wiki 页面索引(用于建立 [[链接]]) + +{existing_pages} + +## 待生成页面列表 + +```json +{pages_to_create} +``` + +## 原始材料 + +标题:{raw_title} + +{raw_content} + +--- + +请为上面 `pages_to_create` 数组中的**每一个页面**生成完整的 markdown 内容。 +- 按 system 中规定的 FILE 块格式输出,每个页面一个 FILE 块 +- 每个页面的内容必须基于原始材料中与该主题相关的信息 +- 如果提供了"文档全局概念地图",可参考其中的概念关系来丰富页面内容和链接 +- 适当使用 [[页面标题]] 链接到相关页面(同批次内其他页面也可链接) +- 不要遗漏任何一个页面 diff --git a/mateclaw-ui/src/composables/useSkillName.ts b/mateclaw-ui/src/composables/useSkillName.ts new file mode 100644 index 00000000..75be0910 --- /dev/null +++ b/mateclaw-ui/src/composables/useSkillName.ts @@ -0,0 +1,28 @@ +import { useI18n } from 'vue-i18n' +import type { Skill } from '@/types/index' + +/** + * RFC-042 §2.2 — locale-aware skill name resolver. + * + * Returns {@code nameZh} for zh-* locales, {@code nameEn} for en-* locales, + * falling back to {@code name} (the slug) when the locale-specific column + * is null. The slug stays the only stable identifier — these are display + * labels only. + */ +export function useSkillName() { + const { locale } = useI18n() + + function resolveSkillName(skill: Pick): string { + const loc = String(locale.value || '').toLowerCase() + if (loc.startsWith('zh') && skill.nameZh && skill.nameZh.trim()) return skill.nameZh + if (loc.startsWith('en') && skill.nameEn && skill.nameEn.trim()) return skill.nameEn + return skill.name + } + + /** True when the resolved display name differs from the underlying slug. */ + function hasI18nName(skill: Pick): boolean { + return resolveSkillName(skill) !== skill.name + } + + return { resolveSkillName, hasI18nName } +} diff --git a/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue b/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue new file mode 100644 index 00000000..ce763d20 --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue @@ -0,0 +1,423 @@ + + + + +