mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): BuiltinSkillSeedService — close SQL/SKILL.md double-write
This commit is contained in:
parent
cd74f94ffa
commit
1073890e64
@ -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) {}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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/<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)
|
||||
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);
|
||||
|
||||
@ -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/<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)
|
||||
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);
|
||||
|
||||
@ -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/<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)
|
||||
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);
|
||||
|
||||
@ -463,6 +463,11 @@ VALUES (
|
||||
);
|
||||
|
||||
-- 内置技能:从 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)
|
||||
KEY (id)
|
||||
VALUES (1000000001, 'cron', '定时任务管理。通过命令或控制台创建、查询、暂停、恢复、删除任务,按时间表执行并把结果发到频道。', 'builtin', '⏰', '1.0.0', 'MateClaw', '{"upstream":"mateclaw","entryFile":"SKILL.md"}', TRUE, TRUE, 'cron,schedule,automation', NOW(), NOW(), 0);
|
||||
|
||||
@ -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;
|
||||
@ -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';
|
||||
@ -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)';
|
||||
@ -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;
|
||||
@ -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';
|
||||
@ -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;
|
||||
@ -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` 可以为空数组
|
||||
@ -0,0 +1,11 @@
|
||||
## 文档标题
|
||||
|
||||
{raw_title}
|
||||
|
||||
## 文档内容(节选,用于全局结构分析)
|
||||
|
||||
{text_sample}
|
||||
|
||||
---
|
||||
|
||||
请分析以上文档,输出概念地图 JSON。只输出 JSON,不要 markdown 代码块。
|
||||
@ -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`:一段话简短摘要
|
||||
@ -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 块
|
||||
- 每个页面的内容必须基于原始材料中与该主题相关的信息
|
||||
- 如果提供了"文档全局概念地图",可参考其中的概念关系来丰富页面内容和链接
|
||||
- 适当使用 [[页面标题]] 链接到相关页面(同批次内其他页面也可链接)
|
||||
- 不要遗漏任何一个页面
|
||||
28
mateclaw-ui/src/composables/useSkillName.ts
Normal file
28
mateclaw-ui/src/composables/useSkillName.ts
Normal 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 }
|
||||
}
|
||||
423
mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue
Normal file
423
mateclaw-ui/src/views/Wiki/components/WikiGraphView.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user