feat(skill): BuiltinSkillSeedService — close SQL/SKILL.md double-write

This commit is contained in:
matevip 2026-04-24 06:55:18 +08:00
parent cd74f94ffa
commit 1073890e64
18 changed files with 1128 additions and 0 deletions

View File

@ -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.
*
* <p>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.
*
* <p>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.
*
* <p><b>Upsert key:</b> {@code name}. The mate_skill primary key {@code id}
* is preserved on update, so nothing referencing a skill by id breaks.
*
* <p><b>Field merge policy:</b> 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.
*
* <p><b>Order:</b> 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<SkillEntity>().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.
*
* <p>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<String, Object> 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).
*
* <p>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<String, Object> 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<String, Object> 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) {}
}

View File

@ -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.
* <p>
* Expected format:
* <pre>
* ---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---
* </pre>
* <p>
* 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<ParsedPage> parse(String response) {
List<ParsedPage> 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 }
}

View File

@ -457,6 +457,11 @@ VALUES (
); );
-- Built-in skills: skill metadata -- Built-in skills: skill metadata
-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now
-- classpath:skills/<name>/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/<name>/ 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) MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
KEY (id) 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); 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);

View File

@ -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); 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 -- Built-in skills: skill metadata
-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now
-- classpath:skills/<name>/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/<name>/ 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) 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) 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); 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);

View File

@ -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); 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 迁移的技能元数据 -- 内置技能:从 MateClaw 迁移的技能元数据
-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now
-- classpath:skills/<name>/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/<name>/ 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) 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) 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); 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);

View File

@ -463,6 +463,11 @@ VALUES (
); );
-- 内置技能:从 MateClaw 迁移的技能元数据 -- 内置技能:从 MateClaw 迁移的技能元数据
-- DEPRECATED (RFC-044 §4.2): The authoritative source for builtin skills is now
-- classpath:skills/<name>/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/<name>/ 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) MERGE INTO mate_skill (id, name, description, skill_type, icon, version, author, config_json, enabled, builtin, tags, create_time, update_time, deleted)
KEY (id) KEY (id)
VALUES (1000000001, 'cron', '定时任务管理。通过命令或控制台创建、查询、暂停、恢复、删除任务,按时间表执行并把结果发到频道。', 'builtin', '', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'cron,schedule,automation', NOW(), NOW(), 0); VALUES (1000000001, 'cron', '定时任务管理。通过命令或控制台创建、查询、暂停、恢复、删除任务,按时间表执行并把结果发到频道。', 'builtin', '', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'cron,schedule,automation', NOW(), NOW(), 0);

View File

@ -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;

View File

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

View File

@ -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)';

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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人类可读、slugURL 安全小写连字符、importancehigh / medium
- `structure_notes`1-3 句话,描述文档结构,帮助路由阶段在只看到局部 chunk 时理解全局
## slug 规范
- 多音节中文词按整词分组拼音,不要按字一隔
- ✅ `zhongyao-qiqing-peiwu`(中药 / 七情 / 配伍)
- ❌ `zhong-yao-qi-qing-pei-wu`
- 小写字母 + 连字符,无空格
## 关键纪律
- 输出体积控制在几百到两千字以内
- 不要输出任何页面正文,只输出概念地图
- 如果文档内容不足(如空白、纯目录),`key_concepts` 可以为空数组

View File

@ -0,0 +1,11 @@
## 文档标题
{raw_title}
## 文档内容(节选,用于全局结构分析)
{text_sample}
---
请分析以上文档,输出概念地图 JSON。只输出 JSON不要 markdown 代码块。

View File

@ -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`:一段话简短摘要

View File

@ -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 块
- 每个页面的内容必须基于原始材料中与该主题相关的信息
- 如果提供了"文档全局概念地图",可参考其中的概念关系来丰富页面内容和链接
- 适当使用 [[页面标题]] 链接到相关页面(同批次内其他页面也可链接)
- 不要遗漏任何一个页面

View File

@ -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<Skill, 'name' | 'nameZh' | 'nameEn'>): 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<Skill, 'name' | 'nameZh' | 'nameEn'>): boolean {
return resolveSkillName(skill) !== skill.name
}
return { resolveSkillName, hasI18nName }
}

View File

@ -0,0 +1,423 @@
<template>
<div class="graph-view">
<!-- Toolbar -->
<div class="graph-toolbar">
<div class="graph-stats">
<span class="stat-item">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="10" stroke-width="1.5"/></svg>
{{ nodes.length }} {{ t('wiki.graph.nodes') }}
</span>
<span class="stat-item">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ edges.length }} {{ t('wiki.graph.edges') }}
</span>
<span v-if="orphanCount > 0" class="stat-item stat-warn">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
{{ orphanCount }} {{ t('wiki.graph.orphans') }}
</span>
</div>
<div class="graph-controls">
<label class="filter-label">
<input v-model="showOrphans" type="checkbox" />
{{ t('wiki.graph.showOrphans') }}
</label>
<label class="filter-label">
<input v-model="selectedType" type="checkbox" value="" @change="typeFilter = ''" />
</label>
<select v-model="typeFilter" class="type-select">
<option value="">{{ t('wiki.graph.allTypes') }}</option>
<option v-for="type in availableTypes" :key="type" :value="type">
{{ t(`wiki.pageTypes.${type}`, type) }}
</option>
</select>
<button class="btn-icon-sm" :title="t('wiki.graph.resetView')" @click="resetChart">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
</div>
</div>
<!-- Chart container -->
<div ref="chartEl" class="graph-canvas" />
<!-- Hover tooltip / selected node panel -->
<div v-if="selectedNode" class="node-panel">
<div class="node-panel-header">
<span class="node-type-badge" :style="{ background: typeColor(selectedNode.pageType) }">
{{ t(`wiki.pageTypes.${selectedNode.pageType || 'other'}`, selectedNode.pageType || 'other') }}
</span>
<button class="node-panel-close" @click="selectedNode = null"></button>
</div>
<div class="node-panel-title">{{ selectedNode.title }}</div>
<div class="node-panel-summary">{{ selectedNode.summary }}</div>
<div class="node-panel-links" v-if="selectedNodeLinks.length > 0">
<div class="links-label">{{ t('wiki.graph.linksTo') }} ({{ selectedNodeLinks.length }})</div>
<div class="links-list">
<button
v-for="link in selectedNodeLinks.slice(0, 8)" :key="link.slug"
class="link-chip"
@click="emit('open-page', link.slug)"
>{{ link.title }}</button>
<span v-if="selectedNodeLinks.length > 8" class="link-more">+{{ selectedNodeLinks.length - 8 }}</span>
</div>
</div>
<button class="btn-open-page" @click="emit('open-page', selectedNode.slug)">
{{ t('wiki.graph.openPage') }}
</button>
</div>
<!-- Empty state -->
<div v-if="nodes.length === 0" class="graph-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/>
<line x1="5" y1="5" x2="19" y2="19" stroke-width="0.5"/>
</svg>
<p>{{ t('wiki.graph.empty') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import * as echarts from 'echarts/core'
import { GraphChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import type { WikiPage } from '@/stores/useWikiStore'
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
const { t } = useI18n()
const props = defineProps<{ pages: WikiPage[] }>()
const emit = defineEmits<{ (e: 'open-page', slug: string): void }>()
const chartEl = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null
const showOrphans = ref(true)
const typeFilter = ref('')
const selectedNode = ref<WikiPage | null>(null)
const selectedType = ref(false)
// Type color map
const TYPE_COLORS: Record<string, string> = {
concept: '#D96E46',
person: '#5B8DEF',
place: '#4CAF82',
event: '#F59E0B',
technology: '#8B5CF6',
organization: '#EC4899',
product: '#14B8A6',
term: '#6B7280',
process: '#F97316',
other: '#9CA3AF',
}
function typeColor(type: string | null | undefined): string {
return TYPE_COLORS[(type || 'other').toLowerCase()] || TYPE_COLORS.other
}
// Parse outgoing links JSON string slug[]
function parseLinks(outgoingLinks: string | null | undefined): string[] {
if (!outgoingLinks) return []
try {
const arr = JSON.parse(outgoingLinks)
return Array.isArray(arr) ? arr : []
} catch { return [] }
}
const slugToPage = computed(() => {
const map = new Map<string, WikiPage>()
for (const p of props.pages) map.set(p.slug, p)
return map
})
// Pages filtered by type
const filteredPages = computed(() => {
let ps = props.pages
if (typeFilter.value) ps = ps.filter(p => (p.pageType || 'other').toLowerCase() === typeFilter.value)
return ps
})
// Build edges from outgoing links
const edges = computed(() => {
const result: { source: string; target: string }[] = []
const slugSet = new Set(filteredPages.value.map(p => p.slug))
for (const page of filteredPages.value) {
for (const link of parseLinks(page.outgoingLinks)) {
if (slugSet.has(link) && link !== page.slug) {
result.push({ source: page.slug, target: link })
}
}
}
return result
})
// Compute in-degree for each node
const inDegree = computed(() => {
const map = new Map<string, number>()
for (const e of edges.value) {
map.set(e.target, (map.get(e.target) || 0) + 1)
}
return map
})
const orphanCount = computed(() =>
filteredPages.value.filter(p => (inDegree.value.get(p.slug) || 0) === 0 && parseLinks(p.outgoingLinks).length === 0).length
)
const nodes = computed(() => {
let ps = filteredPages.value
if (!showOrphans.value) {
ps = ps.filter(p =>
(inDegree.value.get(p.slug) || 0) > 0 || parseLinks(p.outgoingLinks).length > 0
)
}
return ps
})
const availableTypes = computed(() => {
const types = new Set(props.pages.map(p => (p.pageType || 'other').toLowerCase()))
return [...types].sort()
})
const selectedNodeLinks = computed(() => {
if (!selectedNode.value) return []
return parseLinks(selectedNode.value.outgoingLinks)
.map(slug => slugToPage.value.get(slug))
.filter(Boolean) as WikiPage[]
})
function buildOption() {
const nodeList = nodes.value.map(p => {
const deg = (inDegree.value.get(p.slug) || 0) + parseLinks(p.outgoingLinks).length
const size = Math.max(10, Math.min(40, 10 + deg * 3))
return {
id: p.slug,
name: p.title,
symbolSize: size,
itemStyle: { color: typeColor(p.pageType) },
label: { show: size > 18, fontSize: 10, color: 'var(--mc-text-secondary)' },
_page: p,
}
})
const edgeList = edges.value
.filter(e => nodes.value.some(n => n.slug === e.source) && nodes.value.some(n => n.slug === e.target))
.map(e => ({
source: e.source,
target: e.target,
lineStyle: { color: 'rgba(150,150,150,0.3)', width: 1 },
}))
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
formatter: (params: any) => {
if (params.dataType !== 'node') return ''
const p = params.data._page as WikiPage
return `<div style="max-width:220px"><strong>${p.title}</strong><br/><small style="color:#999">${t(`wiki.pageTypes.${p.pageType || 'other'}`, p.pageType || 'other')}</small><br/><span style="font-size:11px">${(p.summary || '').substring(0, 80)}${(p.summary || '').length > 80 ? '…' : ''}</span></div>`
},
},
series: [{
type: 'graph',
layout: 'force',
data: nodeList,
links: edgeList,
roam: true,
force: {
repulsion: 200,
gravity: 0.08,
edgeLength: [60, 150],
friction: 0.6,
},
emphasis: {
focus: 'adjacency',
lineStyle: { width: 2 },
},
lineStyle: { color: 'rgba(150,150,150,0.3)', curveness: 0.1 },
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: 6,
}],
}
}
function renderChart() {
if (!chartEl.value) return
if (!chart) {
chart = echarts.init(chartEl.value, undefined, { renderer: 'canvas' })
chart.on('click', (params: any) => {
if (params.dataType === 'node' && params.data._page) {
selectedNode.value = params.data._page
}
})
}
chart.setOption(buildOption(), { notMerge: true })
}
function resetChart() {
selectedNode.value = null
if (chart) chart.setOption(buildOption(), { notMerge: true })
}
const resizeObserver = new ResizeObserver(() => {
chart?.resize()
})
onMounted(async () => {
await nextTick()
renderChart()
if (chartEl.value) resizeObserver.observe(chartEl.value)
})
onBeforeUnmount(() => {
resizeObserver.disconnect()
chart?.dispose()
chart = null
})
watch([nodes, edges], async () => {
await nextTick()
renderChart()
})
watch(() => props.pages.length, async () => {
await nextTick()
renderChart()
})
</script>
<style scoped>
.graph-view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
position: relative;
}
.graph-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--mc-border-light);
gap: 12px;
flex-shrink: 0;
flex-wrap: wrap;
}
.graph-stats { display: flex; align-items: center; gap: 12px; }
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--mc-text-tertiary);
}
.stat-warn { color: var(--mc-danger, #f56c6c); }
.graph-controls { display: flex; align-items: center; gap: 8px; }
.filter-label { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--mc-text-secondary); cursor: pointer; }
.type-select {
padding: 3px 8px;
font-size: 11px;
border: 1px solid var(--mc-border-light);
border-radius: 7px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
cursor: pointer;
outline: none;
}
.btn-icon-sm {
width: 26px;
height: 26px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-elevated);
border-radius: 7px;
cursor: pointer;
color: var(--mc-text-secondary);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.btn-icon-sm:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); }
.graph-canvas {
flex: 1;
min-height: 0;
width: 100%;
}
.node-panel {
position: absolute;
right: 12px;
top: 52px;
width: 240px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
padding: 14px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 10;
}
.node-panel-header { display: flex; align-items: center; justify-content: space-between; }
.node-type-badge {
font-size: 10px;
font-weight: 600;
color: white;
padding: 2px 8px;
border-radius: 99px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.node-panel-close { border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 12px; }
.node-panel-close:hover { color: var(--mc-text-primary); }
.node-panel-title { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); }
.node-panel-summary { font-size: 12px; color: var(--mc-text-secondary); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.links-label { font-size: 10px; font-weight: 600; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.06em; }
.links-list { display: flex; flex-wrap: wrap; gap: 4px; }
.link-chip {
padding: 2px 8px;
font-size: 11px;
border: 1px solid var(--mc-border-light);
border-radius: 99px;
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
cursor: pointer;
transition: all 0.12s;
}
.link-chip:hover { border-color: var(--mc-primary); color: var(--mc-primary); background: var(--mc-primary-bg); }
.link-more { font-size: 11px; color: var(--mc-text-tertiary); padding: 2px 4px; }
.btn-open-page {
padding: 6px 12px;
border: 1px solid var(--mc-border-light);
border-radius: 8px;
background: var(--mc-bg-muted);
color: var(--mc-primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.btn-open-page:hover { background: var(--mc-primary-bg); border-color: var(--mc-primary); }
.graph-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--mc-text-tertiary);
pointer-events: none;
}
.graph-empty p { font-size: 14px; }
</style>