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

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

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);
-- 内置技能:从 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);

View File

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

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>