feat(wiki): add LLM Wiki knowledge base system

This commit is contained in:
matevip 2026-04-08 10:55:50 +08:00
parent 7007ea844d
commit 642360a773
34 changed files with 3252 additions and 1 deletions

View File

@ -69,6 +69,7 @@ import vip.mate.tool.guard.service.ToolGuardService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.wiki.service.WikiContextService;
import java.lang.reflect.Field;
import java.util.ArrayList;
@ -114,6 +115,7 @@ public class AgentGraphBuilder {
private final WorkspaceFileService workspaceFileService;
private final vip.mate.agent.context.ConversationWindowManager conversationWindowManager;
private final vip.mate.llm.chatgpt.ChatGPTResponsesClient chatGPTResponsesClient;
private final WikiContextService wikiContextService;
/**
* 根据 AgentEntity 构建完整的 Agent 实例
@ -611,7 +613,10 @@ public class AgentGraphBuilder {
""";
}
return basePrompt + skillEnhancement + toolGuidance + searchGuidance;
// Wiki 知识库上下文注入
String wikiContext = wikiContextService.buildWikiContext(entity.getId());
return basePrompt + skillEnhancement + toolGuidance + searchGuidance + wikiContext;
}
// ==================== 模型选项构建 ====================

View File

@ -0,0 +1,16 @@
package vip.mate.wiki;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* Wiki 知识库模块自动配置
*
* @author MateClaw Team
*/
@Configuration
@EnableAsync
@EnableConfigurationProperties(WikiProperties.class)
public class WikiAutoConfiguration {
}

View File

@ -0,0 +1,38 @@
package vip.mate.wiki;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Wiki 知识库配置
*
* @author MateClaw Team
*/
@Data
@ConfigurationProperties(prefix = "mate.wiki")
public class WikiProperties {
/** 是否启用 Wiki 知识库功能 */
private boolean enabled = true;
/** LLM 单次处理最大字符数(超过则分块) */
private int maxChunkSize = 30000;
/** 注入 agent prompt 的最大字符数 */
private int maxContextChars = 10000;
/** 单个原始材料最多生成的 Wiki 页面数 */
private int maxPagesPerRaw = 15;
/** 上传后是否自动触发处理 */
private boolean autoProcessOnUpload = true;
/** 上传文件存储目录 */
private String uploadDir = "./data/wiki-uploads";
/** 目录扫描最大文件数 */
private int maxScanFiles = 500;
/** 扫描时跳过大于此大小的文件(字节),默认 50MB */
private long maxScanFileSize = 50 * 1024 * 1024;
}

View File

@ -0,0 +1,113 @@
package vip.mate.wiki.config;
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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* Wiki 模块 Schema 迁移
* <p>
* 确保 Wiki 相关表存在兼容已有部署
*
* @author MateClaw Team
*/
@Slf4j
@Component
@Order(202)
@RequiredArgsConstructor
public class WikiSchemaMigration implements ApplicationRunner {
private final JdbcTemplate jdbcTemplate;
@Override
public void run(ApplicationArguments args) {
createKnowledgeBaseTable();
createRawMaterialTable();
createPageTable();
migrateKnowledgeBaseColumns();
log.info("[WikiSchemaMigration] Wiki schema migration completed");
}
private void createKnowledgeBaseTable() {
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS mate_wiki_knowledge_base (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
agent_id BIGINT,
config_content CLOB,
status VARCHAR(32) NOT NULL DEFAULT 'active',
page_count INT NOT NULL DEFAULT 0,
raw_count INT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
)
""");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_wiki_kb_agent ON mate_wiki_knowledge_base(agent_id)");
}
private void createRawMaterialTable() {
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS mate_wiki_raw_material (
id BIGINT NOT NULL PRIMARY KEY,
kb_id BIGINT NOT NULL,
title VARCHAR(256) NOT NULL,
source_type VARCHAR(32) NOT NULL DEFAULT 'text',
source_path VARCHAR(512),
original_content CLOB,
extracted_text CLOB,
content_hash VARCHAR(64),
file_size BIGINT NOT NULL DEFAULT 0,
processing_status VARCHAR(32) NOT NULL DEFAULT 'pending',
last_processed_at DATETIME,
error_message VARCHAR(512),
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
)
""");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_wiki_raw_kb ON mate_wiki_raw_material(kb_id)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_wiki_raw_status ON mate_wiki_raw_material(kb_id, processing_status)");
}
private void createPageTable() {
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS mate_wiki_page (
id BIGINT NOT NULL PRIMARY KEY,
kb_id BIGINT NOT NULL,
slug VARCHAR(256) NOT NULL,
title VARCHAR(256) NOT NULL,
content CLOB,
summary VARCHAR(1024),
outgoing_links CLOB,
source_raw_ids CLOB,
version INT NOT NULL DEFAULT 1,
last_updated_by VARCHAR(32) NOT NULL DEFAULT 'ai',
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0,
CONSTRAINT uk_wiki_page_kb_slug UNIQUE (kb_id, slug)
)
""");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_wiki_page_kb ON mate_wiki_page(kb_id)");
}
/**
* 增量字段迁移兼容已有部署
*/
private void migrateKnowledgeBaseColumns() {
try {
jdbcTemplate.execute("ALTER TABLE mate_wiki_knowledge_base ADD COLUMN IF NOT EXISTS source_directory VARCHAR(512)");
} catch (Exception e) {
// MySQL 不支持 ADD COLUMN IF NOT EXISTS忽略已存在的错误
if (!e.getMessage().contains("Duplicate column")) {
log.warn("[WikiSchemaMigration] Failed to add source_directory column: {}", e.getMessage());
}
}
}
}

View File

@ -0,0 +1,275 @@
package vip.mate.wiki.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import vip.mate.common.result.R;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.event.WikiProcessingEvent;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import vip.mate.wiki.service.WikiDirectoryScanService;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiPageService;
import vip.mate.wiki.service.WikiProcessingService;
import vip.mate.wiki.service.WikiRawMaterialService;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Wiki 知识库接口
*
* @author MateClaw Team
*/
@Tag(name = "Wiki 知识库")
@RestController
@RequestMapping("/api/v1/wiki")
@RequiredArgsConstructor
public class WikiController {
private final WikiKnowledgeBaseService kbService;
private final WikiRawMaterialService rawService;
private final WikiPageService pageService;
private final WikiProcessingService processingService;
private final WikiDirectoryScanService scanService;
private final WikiProperties properties;
private final ApplicationEventPublisher eventPublisher;
// ==================== Knowledge Base ====================
@Operation(summary = "获取所有知识库")
@GetMapping("/knowledge-bases")
public R<List<WikiKnowledgeBaseEntity>> listKBs() {
return R.ok(kbService.listAll());
}
@Operation(summary = "获取知识库详情")
@GetMapping("/knowledge-bases/{id}")
public R<WikiKnowledgeBaseEntity> getKB(@PathVariable Long id) {
WikiKnowledgeBaseEntity kb = kbService.getById(id);
if (kb == null) return R.fail("Knowledge base not found");
return R.ok(kb);
}
@Operation(summary = "按 Agent 获取知识库")
@GetMapping("/knowledge-bases/agent/{agentId}")
public R<List<WikiKnowledgeBaseEntity>> listKBsByAgent(@PathVariable Long agentId) {
return R.ok(kbService.listByAgentId(agentId));
}
@Operation(summary = "创建知识库")
@PostMapping("/knowledge-bases")
public R<WikiKnowledgeBaseEntity> createKB(@RequestBody Map<String, Object> body) {
String name = (String) body.get("name");
String description = (String) body.get("description");
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null;
return R.ok(kbService.create(name, description, agentId));
}
@Operation(summary = "更新知识库")
@PutMapping("/knowledge-bases/{id}")
public R<WikiKnowledgeBaseEntity> updateKB(@PathVariable Long id, @RequestBody Map<String, Object> body) {
String name = (String) body.get("name");
String description = (String) body.get("description");
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null;
return R.ok(kbService.update(id, name, description, agentId));
}
@Operation(summary = "删除知识库")
@DeleteMapping("/knowledge-bases/{id}")
public R<Void> deleteKB(@PathVariable Long id) {
kbService.delete(id);
return R.ok();
}
@Operation(summary = "获取知识库配置")
@GetMapping("/knowledge-bases/{id}/config")
public R<Map<String, String>> getConfig(@PathVariable Long id) {
WikiKnowledgeBaseEntity kb = kbService.getById(id);
if (kb == null) return R.fail("Knowledge base not found");
return R.ok(Map.of("content", kb.getConfigContent() != null ? kb.getConfigContent() : ""));
}
@Operation(summary = "更新知识库配置")
@PutMapping("/knowledge-bases/{id}/config")
public R<Void> updateConfig(@PathVariable Long id, @RequestBody Map<String, String> body) {
kbService.updateConfig(id, body.get("content"));
return R.ok();
}
// ==================== Directory Scan ====================
@Operation(summary = "设置知识库关联目录")
@PutMapping("/knowledge-bases/{id}/source-directory")
public R<Void> setSourceDirectory(@PathVariable Long id, @RequestBody Map<String, String> body) {
String path = body.get("path");
kbService.updateSourceDirectory(id, path);
return R.ok();
}
@Operation(summary = "扫描关联目录导入文件")
@PostMapping("/knowledge-bases/{id}/scan")
public R<Map<String, Object>> scanDirectory(@PathVariable Long id) {
WikiDirectoryScanService.ScanResult result = scanService.scan(id);
Map<String, Object> response = new LinkedHashMap<>();
response.put("scanned", result.scanned());
response.put("added", result.added());
response.put("skipped", result.skipped());
response.put("errors", result.errors());
return R.ok(response);
}
// ==================== Raw Materials ====================
@Operation(summary = "获取原始材料列表")
@GetMapping("/knowledge-bases/{kbId}/raw")
public R<List<WikiRawMaterialEntity>> listRaw(@PathVariable Long kbId) {
return R.ok(rawService.listByKbId(kbId));
}
@Operation(summary = "添加文本材料")
@PostMapping("/knowledge-bases/{kbId}/raw/text")
public R<WikiRawMaterialEntity> addRawText(@PathVariable Long kbId, @RequestBody Map<String, String> body) {
String title = body.get("title");
String content = body.get("content");
return R.ok(rawService.addText(kbId, title, content));
}
@Operation(summary = "上传文件材料")
@PostMapping("/knowledge-bases/{kbId}/raw/upload")
public R<WikiRawMaterialEntity> uploadRaw(@PathVariable Long kbId,
@RequestParam("file") MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String extension = originalName != null && originalName.contains(".")
? originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase()
: "txt";
// 确定 sourceType
String sourceType = switch (extension) {
case "pdf" -> "pdf";
case "docx", "doc" -> "docx";
case "txt", "md" -> "text";
default -> "text";
};
if ("text".equals(sourceType)) {
// 文本文件直接读取内容
String content = new String(file.getBytes(), StandardCharsets.UTF_8);
return R.ok(rawService.addText(kbId, originalName, content));
} else {
// 二进制文件保存到磁盘转绝对路径避免 Tomcat 临时目录解析问题
Path uploadDir = Paths.get(properties.getUploadDir()).toAbsolutePath().normalize();
Files.createDirectories(uploadDir);
Path targetPath = uploadDir.resolve(System.currentTimeMillis() + "_" + originalName);
file.transferTo(targetPath);
return R.ok(rawService.addFile(kbId, originalName, sourceType,
targetPath.toString(), file.getSize()));
}
}
@Operation(summary = "删除原始材料")
@DeleteMapping("/knowledge-bases/{kbId}/raw/{rawId}")
public R<Void> deleteRaw(@PathVariable Long kbId, @PathVariable Long rawId) {
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw == null || !kbId.equals(raw.getKbId())) {
return R.fail("Raw material not found in this knowledge base");
}
rawService.delete(rawId);
kbService.decrementRawCount(kbId);
return R.ok();
}
@Operation(summary = "重新处理原始材料")
@PostMapping("/knowledge-bases/{kbId}/raw/{rawId}/reprocess")
public R<Void> reprocessRaw(@PathVariable Long kbId, @PathVariable Long rawId) {
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw == null || !kbId.equals(raw.getKbId())) {
return R.fail("Raw material not found in this knowledge base");
}
rawService.reprocess(rawId);
return R.ok();
}
// ==================== Wiki Pages ====================
@Operation(summary = "获取 Wiki 页面列表")
@GetMapping("/knowledge-bases/{kbId}/pages")
public R<List<WikiPageEntity>> listPages(@PathVariable Long kbId) {
return R.ok(pageService.listByKbId(kbId));
}
@Operation(summary = "获取 Wiki 页面内容")
@GetMapping("/knowledge-bases/{kbId}/pages/{slug}")
public R<WikiPageEntity> getPage(@PathVariable Long kbId, @PathVariable String slug) {
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) return R.fail("Page not found");
return R.ok(page);
}
@Operation(summary = "手动编辑 Wiki 页面")
@PutMapping("/knowledge-bases/{kbId}/pages/{slug}")
public R<WikiPageEntity> updatePage(@PathVariable Long kbId, @PathVariable String slug,
@RequestBody Map<String, String> body) {
return R.ok(pageService.updatePageManually(kbId, slug, body.get("content"), body.get("summary")));
}
@Operation(summary = "删除 Wiki 页面")
@DeleteMapping("/knowledge-bases/{kbId}/pages/{slug}")
public R<Void> deletePage(@PathVariable Long kbId, @PathVariable String slug) {
pageService.delete(kbId, slug);
kbService.setPageCount(kbId, pageService.countByKbId(kbId));
return R.ok();
}
@Operation(summary = "获取反向链接")
@GetMapping("/knowledge-bases/{kbId}/pages/{slug}/backlinks")
public R<List<WikiPageEntity>> getBacklinks(@PathVariable Long kbId, @PathVariable String slug) {
return R.ok(pageService.getBacklinks(kbId, slug));
}
// ==================== Processing ====================
@Operation(summary = "触发知识库处理(异步)")
@PostMapping("/knowledge-bases/{kbId}/process")
public R<Map<String, Object>> processKB(@PathVariable Long kbId) {
List<WikiRawMaterialEntity> pending = rawService.listPending(kbId);
for (WikiRawMaterialEntity raw : pending) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), kbId));
}
return R.ok(Map.of("queued", pending.size()));
}
@Operation(summary = "获取处理状态")
@GetMapping("/knowledge-bases/{kbId}/processing-status")
public R<Map<String, Object>> getProcessingStatus(@PathVariable Long kbId) {
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
if (kb == null) return R.fail("Knowledge base not found");
List<WikiRawMaterialEntity> rawList = rawService.listByKbId(kbId);
long pending = rawList.stream().filter(r -> "pending".equals(r.getProcessingStatus())).count();
long processing = rawList.stream().filter(r -> "processing".equals(r.getProcessingStatus())).count();
long completed = rawList.stream().filter(r -> "completed".equals(r.getProcessingStatus())).count();
long failed = rawList.stream().filter(r -> "failed".equals(r.getProcessingStatus())).count();
return R.ok(Map.of(
"status", kb.getStatus(),
"pending", pending,
"processing", processing,
"completed", completed,
"failed", failed,
"totalRaw", rawList.size(),
"totalPages", kb.getPageCount()
));
}
}

View File

@ -0,0 +1,24 @@
package vip.mate.wiki.event;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
/**
* Wiki 处理事件
* <p>
* 当原始材料需要被 AI 消化时发布此事件
*
* @author MateClaw Team
*/
@Getter
public class WikiProcessingEvent extends ApplicationEvent {
private final Long rawMaterialId;
private final Long kbId;
public WikiProcessingEvent(Object source, Long rawMaterialId, Long kbId) {
super(source);
this.rawMaterialId = rawMaterialId;
this.kbId = kbId;
}
}

View File

@ -0,0 +1,34 @@
package vip.mate.wiki.event;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import vip.mate.wiki.service.WikiProcessingService;
/**
* Wiki 处理事件监听器
* <p>
* 异步处理原始材料消化事件
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WikiProcessingListener {
private final WikiProcessingService processingService;
@Async
@EventListener
public void onWikiProcessing(WikiProcessingEvent event) {
log.info("[Wiki] Processing event received: rawId={}, kbId={}", event.getRawMaterialId(), event.getKbId());
try {
processingService.processRawMaterial(event.getRawMaterialId());
} catch (Exception e) {
log.error("[Wiki] Async processing failed for rawId={}: {}", event.getRawMaterialId(), e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,53 @@
package vip.mate.wiki.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Wiki 知识库实体
*
* @author MateClaw Team
*/
@Data
@TableName("mate_wiki_knowledge_base")
public class WikiKnowledgeBaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 知识库名称 */
private String name;
/** 描述 */
private String description;
/** 关联的 Agent ID可选 */
private Long agentId;
/** Wiki 处理规则配置WIKI.md 等效物) */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String configContent;
/** 关联的本地目录路径(可选,用于批量扫描导入) */
private String sourceDirectory;
/** 状态active / processing / error */
private String status;
/** Wiki 页面数量 */
private Integer pageCount;
/** 原始材料数量 */
private Integer rawCount;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,59 @@
package vip.mate.wiki.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Wiki 页面实体AI 生成的结构化知识页面
*
* @author MateClaw Team
*/
@Data
@TableName("mate_wiki_page")
public class WikiPageEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 所属知识库 ID */
private Long kbId;
/** URL 安全标识符,也是 [[link]] 的目标 */
private String slug;
/** 页面标题 */
private String title;
/** Markdown 内容(包含 [[links]] */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String content;
/** 一段话摘要(用于上下文注入) */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String summary;
/** 出站链接JSON 数组,如 ["slug-a","slug-b"] */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String outgoingLinks;
/** 来源原始材料 IDJSON 数组) */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String sourceRawIds;
/** 版本号(每次 AI 更新递增) */
private Integer version;
/** 最后更新者ai / manual */
private String lastUpdatedBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,63 @@
package vip.mate.wiki.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Wiki 原始材料实体
*
* @author MateClaw Team
*/
@Data
@TableName("mate_wiki_raw_material")
public class WikiRawMaterialEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 所属知识库 ID */
private Long kbId;
/** 材料标题 */
private String title;
/** 来源类型text / pdf / docx / url / paste */
private String sourceType;
/** 原始文件路径(二进制文件) */
private String sourcePath;
/** 原始文本内容(文本类型) */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String originalContent;
/** 提取后的文本PDF/DOCX 等) */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String extractedText;
/** 内容 SHA-256 哈希(用于去重和变更检测) */
private String contentHash;
/** 文件大小(字节) */
private Long fileSize;
/** 处理状态pending / processing / completed / failed */
private String processingStatus;
/** 上次处理时间 */
private LocalDateTime lastProcessedAt;
/** 错误信息 */
private String errorMessage;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,14 @@
package vip.mate.wiki.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
/**
* Wiki 知识库 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface WikiKnowledgeBaseMapper extends BaseMapper<WikiKnowledgeBaseEntity> {
}

View File

@ -0,0 +1,14 @@
package vip.mate.wiki.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.wiki.model.WikiPageEntity;
/**
* Wiki 页面 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface WikiPageMapper extends BaseMapper<WikiPageEntity> {
}

View File

@ -0,0 +1,14 @@
package vip.mate.wiki.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.wiki.model.WikiRawMaterialEntity;
/**
* Wiki 原始材料 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface WikiRawMaterialMapper extends BaseMapper<WikiRawMaterialEntity> {
}

View File

@ -0,0 +1,86 @@
package vip.mate.wiki.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import java.util.List;
/**
* Wiki 上下文服务
* <p>
* Agent 对话构建 Wiki 知识库上下文注入到系统提示词中
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiContextService {
private final WikiKnowledgeBaseService kbService;
private final WikiPageService pageService;
private final WikiProperties properties;
/**
* 构建指定 Agent 关联的 Wiki 上下文
*
* @param agentId Agent ID
* @return Wiki 上下文字符串如果没有关联知识库或页面则返回空字符串
*/
public String buildWikiContext(Long agentId) {
if (!properties.isEnabled()) {
return "";
}
List<WikiKnowledgeBaseEntity> kbs = kbService.listByAgentId(agentId);
if (kbs.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
sb.append("\n\n## Wiki Knowledge Base\n\n");
sb.append("You have access to structured wiki knowledge bases. ");
sb.append("Use `wiki_read_page` tool to read full page content when needed.\n\n");
int totalChars = 0;
int maxChars = properties.getMaxContextChars();
for (WikiKnowledgeBaseEntity kb : kbs) {
List<WikiPageEntity> pages = pageService.listSummaries(kb.getId());
if (pages.isEmpty()) continue;
sb.append("### ").append(kb.getName());
if (kb.getDescription() != null && !kb.getDescription().isBlank()) {
sb.append("").append(kb.getDescription());
}
sb.append("\n\n");
sb.append("Knowledge Base ID: `").append(kb.getId()).append("`\n\n");
sb.append("Available pages:\n");
for (WikiPageEntity page : pages) {
String line = "- **[[" + page.getTitle() + "]]** (`" + page.getSlug() + "`): "
+ (page.getSummary() != null ? page.getSummary() : "No summary") + "\n";
if (totalChars + line.length() > maxChars) {
sb.append("- ... and more pages (use `wiki_list_pages` to see all)\n");
break;
}
sb.append(line);
totalChars += line.length();
}
sb.append("\n");
}
String result = sb.toString();
if (result.contains("Available pages:")) {
return result;
}
return "";
}
}

View File

@ -0,0 +1,174 @@
package vip.mate.wiki.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
/**
* Wiki 目录扫描服务
* <p>
* 扫描本地目录中的文档文件为每个文件创建原始材料
* 基于 sourcePath 去重避免重复导入
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiDirectoryScanService {
private final WikiKnowledgeBaseService kbService;
private final WikiRawMaterialService rawService;
private final WikiProperties properties;
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(
"txt", "md", "pdf", "docx", "doc", "pptx", "xlsx"
);
private static final Set<String> TEXT_EXTENSIONS = Set.of("txt", "md");
/**
* 扫描结果
*/
public record ScanResult(int scanned, int added, int skipped, List<String> errors) {}
/**
* 扫描指定知识库关联的目录
*/
public ScanResult scan(Long kbId) {
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
if (kb == null) {
return new ScanResult(0, 0, 0, List.of("Knowledge base not found"));
}
String dirPath = kb.getSourceDirectory();
if (dirPath == null || dirPath.isBlank()) {
return new ScanResult(0, 0, 0, List.of("No source directory configured"));
}
return scanDirectory(kbId, dirPath);
}
/**
* 扫描指定目录为每个支持的文件创建原始材料
*/
public ScanResult scanDirectory(Long kbId, String directoryPath) {
Path dir = Paths.get(directoryPath).toAbsolutePath().normalize();
if (!Files.exists(dir)) {
return new ScanResult(0, 0, 0, List.of("Directory does not exist: " + dir));
}
if (!Files.isDirectory(dir)) {
return new ScanResult(0, 0, 0, List.of("Path is not a directory: " + dir));
}
List<Path> files = new ArrayList<>();
List<String> errors = new ArrayList<>();
int maxFiles = properties.getMaxScanFiles();
long maxFileSize = properties.getMaxScanFileSize();
// 递归遍历目录
try {
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) {
// 跳过隐藏目录
String name = d.getFileName().toString();
if (name.startsWith(".") && !d.equals(dir)) {
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (files.size() >= maxFiles) {
return FileVisitResult.TERMINATE;
}
String fileName = file.getFileName().toString();
// 跳过隐藏文件
if (fileName.startsWith(".")) return FileVisitResult.CONTINUE;
// 跳过过大文件
if (attrs.size() > maxFileSize) {
log.debug("[Wiki] Skipping large file: {} ({} bytes)", file, attrs.size());
return FileVisitResult.CONTINUE;
}
// 检查扩展名
String ext = getExtension(fileName);
if (SUPPORTED_EXTENSIONS.contains(ext)) {
files.add(file);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
errors.add("Cannot read: " + file.getFileName() + " (" + exc.getMessage() + ")");
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
return new ScanResult(0, 0, 0, List.of("Failed to scan directory: " + e.getMessage()));
}
int scanned = files.size();
int added = 0;
int skipped = 0;
for (Path file : files) {
try {
String absolutePath = file.toAbsolutePath().normalize().toString();
String fileName = file.getFileName().toString();
String ext = getExtension(fileName);
// 基于 sourcePath 去重
WikiRawMaterialEntity existing = rawService.findBySourcePath(kbId, absolutePath);
if (existing != null) {
skipped++;
continue;
}
if (TEXT_EXTENSIONS.contains(ext)) {
// 文本文件读取内容
String content = Files.readString(file, StandardCharsets.UTF_8);
rawService.addText(kbId, fileName, content);
} else {
// 二进制文件直接引用原始路径不复制
String sourceType = switch (ext) {
case "pdf" -> "pdf";
case "docx", "doc" -> "docx";
case "pptx" -> "pptx";
case "xlsx" -> "xlsx";
default -> "text";
};
rawService.addFile(kbId, fileName, sourceType, absolutePath, Files.size(file));
}
added++;
} catch (Exception e) {
errors.add("Failed to import: " + file.getFileName() + " (" + e.getMessage() + ")");
}
}
if (files.size() >= maxFiles) {
errors.add("Scan limit reached (" + maxFiles + " files). Some files may have been skipped.");
}
log.info("[Wiki] Directory scan completed: dir={}, scanned={}, added={}, skipped={}, errors={}",
directoryPath, scanned, added, skipped, errors.size());
return new ScanResult(scanned, added, skipped, errors);
}
private String getExtension(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot > 0 ? fileName.substring(dot + 1).toLowerCase() : "";
}
}

View File

@ -0,0 +1,163 @@
package vip.mate.wiki.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.repository.WikiKnowledgeBaseMapper;
import java.util.List;
/**
* Wiki 知识库服务
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiKnowledgeBaseService {
private final WikiKnowledgeBaseMapper kbMapper;
private static final String DEFAULT_CONFIG = """
# Wiki Processing Rules
## Page Generation
- Create 10-15 wiki pages per source document
- Each page should cover a single concept, entity, or topic
- Use clear Markdown headers (## and ###)
- Include a one-paragraph summary at the top of each page
## Linking
- Use [[Page Title]] syntax for bidirectional links
- Pages should cross-reference each other liberally
- Link to existing pages whenever relevant concepts are mentioned
## Updates
- When updating existing pages with new information, merge rather than replace
- Preserve manually edited content (last_updated_by = 'manual')
- Mark contradictions between new and existing information clearly
## Language
- Write wiki pages in the same language as the source material
- Keep technical terms consistent across pages
""";
public List<WikiKnowledgeBaseEntity> listAll() {
return kbMapper.selectList(
new LambdaQueryWrapper<WikiKnowledgeBaseEntity>()
.orderByDesc(WikiKnowledgeBaseEntity::getUpdateTime));
}
/**
* 获取 Agent 可访问的知识库Agent 专属 KB + 公共 KBagent_id IS NULL
*/
public List<WikiKnowledgeBaseEntity> listByAgentId(Long agentId) {
return kbMapper.selectList(
new LambdaQueryWrapper<WikiKnowledgeBaseEntity>()
.and(w -> w.eq(WikiKnowledgeBaseEntity::getAgentId, agentId)
.or().isNull(WikiKnowledgeBaseEntity::getAgentId))
.orderByDesc(WikiKnowledgeBaseEntity::getUpdateTime));
}
public WikiKnowledgeBaseEntity getById(Long id) {
return kbMapper.selectById(id);
}
@Transactional
public WikiKnowledgeBaseEntity create(String name, String description, Long agentId) {
WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity();
entity.setName(name);
entity.setDescription(description);
entity.setAgentId(agentId);
entity.setConfigContent(DEFAULT_CONFIG);
entity.setStatus("active");
entity.setPageCount(0);
entity.setRawCount(0);
kbMapper.insert(entity);
log.info("[Wiki] Knowledge base created: id={}, name={}", entity.getId(), name);
return entity;
}
@Transactional
public WikiKnowledgeBaseEntity update(Long id, String name, String description, Long agentId) {
WikiKnowledgeBaseEntity entity = kbMapper.selectById(id);
if (entity == null) {
throw new IllegalArgumentException("Knowledge base not found: " + id);
}
if (name != null) entity.setName(name);
if (description != null) entity.setDescription(description);
if (agentId != null) entity.setAgentId(agentId);
kbMapper.updateById(entity);
return entity;
}
@Transactional
public void updateConfig(Long id, String configContent) {
WikiKnowledgeBaseEntity entity = kbMapper.selectById(id);
if (entity == null) {
throw new IllegalArgumentException("Knowledge base not found: " + id);
}
entity.setConfigContent(configContent);
kbMapper.updateById(entity);
}
@Transactional
public void updateCounts(Long kbId) {
WikiKnowledgeBaseEntity entity = kbMapper.selectById(kbId);
if (entity == null) return;
// counts will be updated by callers via specific methods
kbMapper.updateById(entity);
}
@Transactional
public void updateStatus(Long kbId, String status) {
WikiKnowledgeBaseEntity entity = kbMapper.selectById(kbId);
if (entity == null) return;
entity.setStatus(status);
kbMapper.updateById(entity);
}
@Transactional
public void incrementRawCount(Long kbId) {
WikiKnowledgeBaseEntity entity = kbMapper.selectById(kbId);
if (entity == null) return;
entity.setRawCount(entity.getRawCount() + 1);
kbMapper.updateById(entity);
}
@Transactional
public void setPageCount(Long kbId, int count) {
WikiKnowledgeBaseEntity entity = kbMapper.selectById(kbId);
if (entity == null) return;
entity.setPageCount(count);
kbMapper.updateById(entity);
}
@Transactional
public void updateSourceDirectory(Long id, String path) {
WikiKnowledgeBaseEntity entity = kbMapper.selectById(id);
if (entity == null) {
throw new IllegalArgumentException("Knowledge base not found: " + id);
}
entity.setSourceDirectory(path);
kbMapper.updateById(entity);
}
@Transactional
public void decrementRawCount(Long kbId) {
WikiKnowledgeBaseEntity entity = kbMapper.selectById(kbId);
if (entity == null) return;
entity.setRawCount(Math.max(0, entity.getRawCount() - 1));
kbMapper.updateById(entity);
}
@Transactional
public void delete(Long id) {
kbMapper.deleteById(id);
log.info("[Wiki] Knowledge base deleted: id={}", id);
}
}

View File

@ -0,0 +1,255 @@
package vip.mate.wiki.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageMapper;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Wiki 页面服务
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiPageService {
private final WikiPageMapper pageMapper;
private final ObjectMapper objectMapper;
private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[\\[([^\\]]+)]]");
/**
* 列出知识库的所有页面不含 content
*/
public List<WikiPageEntity> listByKbId(Long kbId) {
List<WikiPageEntity> pages = pageMapper.selectList(
new LambdaQueryWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getKbId, kbId)
.orderByAsc(WikiPageEntity::getTitle));
pages.forEach(p -> p.setContent(null));
return pages;
}
/**
* 列出页面摘要用于上下文注入和 LLM 消化
*/
public List<WikiPageEntity> listSummaries(Long kbId) {
List<WikiPageEntity> pages = pageMapper.selectList(
new LambdaQueryWrapper<WikiPageEntity>()
.select(WikiPageEntity::getSlug, WikiPageEntity::getTitle,
WikiPageEntity::getSummary, WikiPageEntity::getLastUpdatedBy)
.eq(WikiPageEntity::getKbId, kbId)
.orderByAsc(WikiPageEntity::getTitle));
return pages;
}
public WikiPageEntity getBySlug(Long kbId, String slug) {
return pageMapper.selectOne(
new LambdaQueryWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getKbId, kbId)
.eq(WikiPageEntity::getSlug, slug));
}
public WikiPageEntity getById(Long id) {
return pageMapper.selectById(id);
}
/**
* 创建新 Wiki 页面
*/
@Transactional
public WikiPageEntity createPage(Long kbId, String slug, String title, String content,
String summary, String sourceRawIds) {
WikiPageEntity entity = new WikiPageEntity();
entity.setKbId(kbId);
entity.setSlug(slug);
entity.setTitle(title);
entity.setContent(content);
entity.setSummary(summary);
entity.setOutgoingLinks(extractLinksAsJson(content));
entity.setSourceRawIds(sourceRawIds);
entity.setVersion(1);
entity.setLastUpdatedBy("ai");
pageMapper.insert(entity);
return entity;
}
/**
* AI 更新页面内容手动编辑的页面不覆盖内容仅追加来源
*/
@Transactional
public WikiPageEntity updatePageByAi(Long kbId, String slug, String content,
String summary, Long newRawId) {
WikiPageEntity existing = getBySlug(kbId, slug);
if (existing == null) {
log.warn("[Wiki] Page not found for AI update: kbId={}, slug={}", kbId, slug);
return null;
}
// 手动编辑的页面AI 不覆盖内容仅追加来源 raw id
if ("manual".equals(existing.getLastUpdatedBy())) {
log.info("[Wiki] Skipping AI content update for manually edited page: kbId={}, slug={}", kbId, slug);
if (newRawId != null) {
List<Long> rawIds = parseSourceRawIds(existing.getSourceRawIds());
if (!rawIds.contains(newRawId)) {
rawIds.add(newRawId);
existing.setSourceRawIds(toJson(rawIds));
pageMapper.updateById(existing);
}
}
return existing;
}
existing.setContent(content);
existing.setSummary(summary);
existing.setOutgoingLinks(extractLinksAsJson(content));
existing.setVersion(existing.getVersion() + 1);
existing.setLastUpdatedBy("ai");
// 追加新的 source raw id
if (newRawId != null) {
List<Long> rawIds = parseSourceRawIds(existing.getSourceRawIds());
if (!rawIds.contains(newRawId)) {
rawIds.add(newRawId);
existing.setSourceRawIds(toJson(rawIds));
}
}
pageMapper.updateById(existing);
return existing;
}
/**
* 手动更新页面内容
*/
@Transactional
public WikiPageEntity updatePageManually(Long kbId, String slug, String content, String summary) {
WikiPageEntity existing = getBySlug(kbId, slug);
if (existing == null) {
throw new IllegalArgumentException("Page not found: " + slug);
}
existing.setContent(content);
existing.setOutgoingLinks(extractLinksAsJson(content));
existing.setVersion(existing.getVersion() + 1);
existing.setLastUpdatedBy("manual");
// 同步更新摘要防止与 content 漂移
if (summary != null) {
existing.setSummary(summary);
} else {
// 无显式摘要时 content 首段提取
existing.setSummary(extractFirstParagraph(content));
}
pageMapper.updateById(existing);
return existing;
}
/**
* Markdown 内容提取首段作为摘要
*/
private String extractFirstParagraph(String content) {
if (content == null || content.isBlank()) return null;
String[] lines = content.split("\n");
StringBuilder sb = new StringBuilder();
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.isEmpty() && sb.length() > 0) break; // 空行分段
if (trimmed.startsWith("#")) continue; // 跳过标题行
if (!trimmed.isEmpty()) {
if (sb.length() > 0) sb.append(" ");
sb.append(trimmed);
}
}
String para = sb.toString();
if (para.length() > 300) para = para.substring(0, 300) + "...";
return para.isEmpty() ? null : para;
}
/**
* 获取反向链接哪些页面链接到了这个页面
*/
public List<WikiPageEntity> getBacklinks(Long kbId, String slug) {
// outgoing_links JSON 中搜索包含此 slug 的页面
List<WikiPageEntity> allPages = pageMapper.selectList(
new LambdaQueryWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getKbId, kbId)
.ne(WikiPageEntity::getSlug, slug));
return allPages.stream()
.filter(p -> p.getOutgoingLinks() != null && p.getOutgoingLinks().contains("\"" + slug + "\""))
.peek(p -> p.setContent(null))
.collect(Collectors.toList());
}
@Transactional
public void delete(Long kbId, String slug) {
pageMapper.delete(
new LambdaQueryWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getKbId, kbId)
.eq(WikiPageEntity::getSlug, slug));
}
public int countByKbId(Long kbId) {
return Math.toIntExact(pageMapper.selectCount(
new LambdaQueryWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getKbId, kbId)));
}
/**
* Markdown 内容中提取 [[links]] 并返回 JSON 数组
*/
String extractLinksAsJson(String content) {
if (content == null) return "[]";
List<String> links = new ArrayList<>();
Matcher matcher = WIKI_LINK_PATTERN.matcher(content);
while (matcher.find()) {
String link = matcher.group(1).trim();
String slug = toSlug(link);
if (!links.contains(slug)) {
links.add(slug);
}
}
return toJson(links);
}
/**
* 将标题转换为 slugURL 安全标识符
*/
public static String toSlug(String title) {
if (title == null) return "";
return title.trim()
.toLowerCase()
.replaceAll("[^a-z0-9\\u4e00-\\u9fff\\s-]", "")
.replaceAll("\\s+", "-")
.replaceAll("-+", "-")
.replaceAll("^-|-$", "");
}
private List<Long> parseSourceRawIds(String json) {
if (json == null || json.isBlank()) return new ArrayList<>();
try {
return objectMapper.readValue(json, new TypeReference<List<Long>>() {});
} catch (Exception e) {
return new ArrayList<>();
}
}
private String toJson(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
return "[]";
}
}
}

View File

@ -0,0 +1,307 @@
package vip.mate.wiki.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Service;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import java.util.List;
/**
* Wiki 处理服务
* <p>
* 核心管线将原始材料通过 LLM 消化为结构化 Wiki 页面
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiProcessingService {
private final WikiKnowledgeBaseService kbService;
private final WikiRawMaterialService rawService;
private final WikiPageService pageService;
private final WikiProperties properties;
private final ModelConfigService modelConfigService;
private final AgentGraphBuilder agentGraphBuilder;
private final ObjectMapper objectMapper;
/**
* 处理单个原始材料
*/
public void processRawMaterial(Long rawId) {
// CAS 式抢占防止并发重复处理
if (!rawService.claimForProcessing(rawId)) {
log.debug("[Wiki] Raw material {} already claimed or not pending, skipping", rawId);
return;
}
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw == null) {
log.warn("[Wiki] Raw material not found: {}", rawId);
return;
}
WikiKnowledgeBaseEntity kb = kbService.getById(raw.getKbId());
if (kb == null) {
log.warn("[Wiki] Knowledge base not found for raw material: kbId={}", raw.getKbId());
return;
}
kbService.updateStatus(kb.getId(), "processing");
try {
// Phase 1: 获取文本内容
String textContent = rawService.getTextContent(raw);
if (textContent == null || textContent.isBlank()) {
rawService.updateProcessingStatus(rawId, "failed", "No text content available");
kbService.updateStatus(kb.getId(), "active");
return;
}
// Phase 2: LLM 消化
int totalPages;
if (textContent.length() > properties.getMaxChunkSize()) {
totalPages = processInChunks(kb, raw, textContent);
} else {
totalPages = processChunk(kb, raw, textContent);
}
// Phase 3: 更新状态和计数
if (totalPages == 0) {
rawService.updateProcessingStatus(rawId, "failed", "No pages generated from LLM response");
} else {
rawService.updateProcessingStatus(rawId, "completed", null);
}
int pageCount = pageService.countByKbId(kb.getId());
kbService.setPageCount(kb.getId(), pageCount);
kbService.updateStatus(kb.getId(), "active");
log.info("[Wiki] Processing completed for raw={}, kbId={}, generatedPages={}, totalPages={}",
rawId, kb.getId(), totalPages, pageCount);
} catch (Exception e) {
log.error("[Wiki] Processing failed for raw={}: {}", rawId, e.getMessage(), e);
rawService.updateProcessingStatus(rawId, "failed", e.getMessage());
kbService.updateStatus(kb.getId(), "active");
}
}
/**
* 处理知识库中所有待处理的原始材料
*/
public void processAllPending(Long kbId) {
List<WikiRawMaterialEntity> pendingList = rawService.listPending(kbId);
if (pendingList.isEmpty()) {
log.info("[Wiki] No pending raw materials for kbId={}", kbId);
return;
}
log.info("[Wiki] Processing {} pending raw materials for kbId={}", pendingList.size(), kbId);
for (WikiRawMaterialEntity raw : pendingList) {
processRawMaterial(raw.getId());
}
}
/**
* 分块处理大文档
*
* @return 创建+更新的页面总数
*/
private int processInChunks(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, String text) {
int chunkSize = properties.getMaxChunkSize();
int overlap = 500; // 块间重叠
int start = 0;
int totalPages = 0;
int chunkIndex = 0;
while (start < text.length()) {
int previousStart = start;
int end = Math.min(start + chunkSize, text.length());
// 在句子边界切分
if (end < text.length()) {
int lastPeriod = text.lastIndexOf("", end);
int lastNewline = text.lastIndexOf("\n", end);
int breakAt = Math.max(lastPeriod, lastNewline);
if (breakAt > start + chunkSize / 2) {
end = breakAt + 1;
}
}
String chunk = text.substring(start, end);
log.info("[Wiki] Processing chunk {}: chars {}-{} of {}", chunkIndex, start, end, text.length());
totalPages += processChunk(kb, raw, chunk);
start = end - overlap;
if (start < 0) start = 0;
// 保证前进防止死循环
if (start <= previousStart) start = end;
chunkIndex++;
}
return totalPages;
}
/**
* 处理单个文本块
*
* @return 创建+更新的页面数
*/
private int processChunk(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw, String textContent) {
// 构建已有页面索引
String existingPagesIndex = buildExistingPagesIndex(kb.getId());
// 加载 prompt 模板
String systemPrompt = PromptLoader.loadPrompt("wiki/digest-system");
String userTemplate = PromptLoader.loadPrompt("wiki/digest-user");
String userPrompt = userTemplate
.replace("{config}", kb.getConfigContent() != null ? kb.getConfigContent() : "")
.replace("{existing_pages}", existingPagesIndex)
.replace("{raw_title}", raw.getTitle())
.replace("{raw_content}", textContent);
// 调用 LLM
String llmResponse;
try {
ChatModel chatModel = buildChatModel();
Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)
));
ChatResponse response = chatModel.call(prompt);
llmResponse = response.getResult().getOutput().getText();
} catch (Exception e) {
throw new RuntimeException("LLM call failed: " + e.getMessage(), e);
}
// 解析并持久化页面
return applyLlmResponse(kb.getId(), raw.getId(), llmResponse);
}
/**
* 解析 LLM 响应并创建/更新 Wiki 页面
*
* @return 创建+更新的页面总数
*/
private int applyLlmResponse(Long kbId, Long rawId, String llmResponse) {
JsonNode root = parseJsonResponse(llmResponse);
if (root == null) {
log.warn("[Wiki] Failed to parse LLM response for kbId={}, rawId={}", kbId, rawId);
return 0;
}
String sourceRawIds = "[" + rawId + "]";
int created = 0;
int updated = 0;
// 新页面
JsonNode pagesNode = root.path("pages");
if (pagesNode.isArray()) {
for (JsonNode pageNode : pagesNode) {
String slug = pageNode.path("slug").asText("");
String title = pageNode.path("title").asText("");
String content = pageNode.path("content").asText("");
String summary = pageNode.path("summary").asText("");
if (slug.isBlank() || title.isBlank()) continue;
// 检查是否已存在LLM 可能将已有页面误判为新页面
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
if (existing != null) {
pageService.updatePageByAi(kbId, slug, content, summary, rawId);
updated++;
} else {
pageService.createPage(kbId, slug, title, content, summary, sourceRawIds);
created++;
}
}
}
// 更新的页面
JsonNode updatedPagesNode = root.path("updated_pages");
if (updatedPagesNode.isArray()) {
for (JsonNode pageNode : updatedPagesNode) {
String slug = pageNode.path("slug").asText("");
String content = pageNode.path("content").asText("");
String summary = pageNode.path("summary").asText("");
if (slug.isBlank()) continue;
WikiPageEntity existing = pageService.getBySlug(kbId, slug);
if (existing != null) {
// 保护手动编辑的页面仍然更新 LLM 已在 prompt 中被告知要保留手动内容
pageService.updatePageByAi(kbId, slug, content, summary, rawId);
updated++;
}
}
}
log.info("[Wiki] Applied LLM response: kbId={}, rawId={}, created={}, updated={}",
kbId, rawId, created, updated);
return created + updated;
}
/**
* 构建已有 Wiki 页面索引 LLM 参考
*/
private String buildExistingPagesIndex(Long kbId) {
List<WikiPageEntity> summaries = pageService.listSummaries(kbId);
if (summaries.isEmpty()) {
return "(暂无已有页面)";
}
StringBuilder sb = new StringBuilder();
for (WikiPageEntity page : summaries) {
sb.append("- **[[").append(page.getTitle()).append("]]** (slug: `").append(page.getSlug()).append("`");
if ("manual".equals(page.getLastUpdatedBy())) {
sb.append(", 手动编辑");
}
sb.append("): ");
sb.append(page.getSummary() != null ? page.getSummary() : "无摘要");
sb.append("\n");
}
return sb.toString().trim();
}
private ChatModel buildChatModel() {
ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
return agentGraphBuilder.buildRuntimeChatModel(defaultModel);
}
private JsonNode parseJsonResponse(String response) {
if (response == null || response.isBlank()) return null;
String cleaned = response.trim();
if (cleaned.startsWith("```json")) {
cleaned = cleaned.substring(7);
} else if (cleaned.startsWith("```")) {
cleaned = cleaned.substring(3);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
cleaned = cleaned.trim();
try {
return objectMapper.readTree(cleaned);
} catch (Exception e) {
log.warn("[Wiki] Failed to parse JSON response: {}", e.getMessage());
return null;
}
}
}

View File

@ -0,0 +1,233 @@
package vip.mate.wiki.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.tool.builtin.DocumentExtractTool;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.event.WikiProcessingEvent;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import vip.mate.wiki.repository.WikiRawMaterialMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
/**
* Wiki 原始材料服务
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiRawMaterialService {
private final WikiRawMaterialMapper rawMapper;
private final WikiKnowledgeBaseService kbService;
private final WikiProperties properties;
private final ApplicationEventPublisher eventPublisher;
private final DocumentExtractTool documentExtractTool;
public List<WikiRawMaterialEntity> listByKbId(Long kbId) {
List<WikiRawMaterialEntity> list = rawMapper.selectList(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, kbId)
.orderByDesc(WikiRawMaterialEntity::getCreateTime));
// 不返回大文本字段
list.forEach(r -> {
r.setOriginalContent(null);
r.setExtractedText(null);
});
return list;
}
public WikiRawMaterialEntity getById(Long id) {
return rawMapper.selectById(id);
}
public WikiRawMaterialEntity findBySourcePath(Long kbId, String sourcePath) {
return rawMapper.selectOne(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, kbId)
.eq(WikiRawMaterialEntity::getSourcePath, sourcePath));
}
public List<WikiRawMaterialEntity> listPending(Long kbId) {
return rawMapper.selectList(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, kbId)
.eq(WikiRawMaterialEntity::getProcessingStatus, "pending"));
}
/**
* 添加文本类型的原始材料
*/
@Transactional
public WikiRawMaterialEntity addText(Long kbId, String title, String content) {
WikiRawMaterialEntity entity = new WikiRawMaterialEntity();
entity.setKbId(kbId);
entity.setTitle(title);
entity.setSourceType("text");
entity.setOriginalContent(content);
entity.setFileSize((long) content.getBytes(StandardCharsets.UTF_8).length);
entity.setContentHash(computeHash(content));
entity.setProcessingStatus("pending");
rawMapper.insert(entity);
kbService.incrementRawCount(kbId);
if (properties.isAutoProcessOnUpload()) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, entity.getId(), kbId));
}
log.info("[Wiki] Raw material added: id={}, kbId={}, title={}", entity.getId(), kbId, title);
return entity;
}
/**
* 添加文件类型的原始材料PDF/DOCX
*/
@Transactional
public WikiRawMaterialEntity addFile(Long kbId, String title, String sourceType,
String sourcePath, long fileSize) {
WikiRawMaterialEntity entity = new WikiRawMaterialEntity();
entity.setKbId(kbId);
entity.setTitle(title);
entity.setSourceType(sourceType);
entity.setSourcePath(sourcePath);
entity.setFileSize(fileSize);
entity.setProcessingStatus("pending");
rawMapper.insert(entity);
kbService.incrementRawCount(kbId);
if (properties.isAutoProcessOnUpload()) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, entity.getId(), kbId));
}
log.info("[Wiki] Raw file added: id={}, kbId={}, type={}", entity.getId(), kbId, sourceType);
return entity;
}
/**
* CAS 式抢占仅当当前状态为 pending 时才更新为 processing
*
* @return true 表示抢占成功false 表示已被其他线程处理
*/
@Transactional
public boolean claimForProcessing(Long id) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null || !"pending".equals(entity.getProcessingStatus())) {
return false;
}
entity.setProcessingStatus("processing");
entity.setErrorMessage(null);
rawMapper.updateById(entity);
return true;
}
@Transactional
public void updateProcessingStatus(Long id, String status, String errorMessage) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setProcessingStatus(status);
entity.setErrorMessage(errorMessage);
if ("completed".equals(status)) {
entity.setLastProcessedAt(java.time.LocalDateTime.now());
}
rawMapper.updateById(entity);
}
@Transactional
public void updateExtractedText(Long id, String extractedText, String contentHash) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setExtractedText(extractedText);
entity.setContentHash(contentHash);
rawMapper.updateById(entity);
}
/**
* 重新处理重置状态为 pending 并发布事件
*/
@Transactional
public void reprocess(Long id) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) {
throw new IllegalArgumentException("Raw material not found: " + id);
}
entity.setProcessingStatus("pending");
entity.setErrorMessage(null);
rawMapper.updateById(entity);
eventPublisher.publishEvent(new WikiProcessingEvent(this, entity.getId(), entity.getKbId()));
log.info("[Wiki] Raw material queued for reprocessing: id={}", id);
}
@Transactional
public void delete(Long id) {
rawMapper.deleteById(id);
}
/**
* 获取可用文本内容
* <p>
* 优先级已缓存的 extractedText 原始文本 调用 DocumentExtractTool 提取二进制文件
*/
public String getTextContent(WikiRawMaterialEntity entity) {
// 已有缓存的提取文本
if (entity.getExtractedText() != null && !entity.getExtractedText().isBlank()) {
return entity.getExtractedText();
}
// 文本类型直接返回原始内容
if ("text".equals(entity.getSourceType())) {
return entity.getOriginalContent();
}
// 二进制文件调用 DocumentExtractTool 提取
if (entity.getSourcePath() != null && !entity.getSourcePath().isBlank()) {
try {
String result = documentExtractTool.extract_document_text(entity.getSourcePath(), null);
JSONObject json = JSONUtil.parseObj(result);
if (json.getBool("success", false)) {
String text = json.getStr("text");
if (text != null && !text.isBlank()) {
boolean truncated = json.getBool("truncated", false);
if (truncated) {
// 截断的结果不缓存避免永久丢失后半内容返回文本供分块处理使用
log.warn("[Wiki] Extracted text truncated at {} chars for: {} (full document may be larger)",
text.length(), entity.getSourcePath());
} else {
// 完整提取结果缓存以避免重复提取
updateExtractedText(entity.getId(), text, computeHash(text));
}
log.info("[Wiki] Extracted text from {}: {} chars, method={}, truncated={}",
entity.getSourcePath(), text.length(), json.getStr("method"), truncated);
return text;
}
}
log.warn("[Wiki] Document extraction returned no text for: {}", entity.getSourcePath());
} catch (Exception e) {
log.error("[Wiki] Document extraction failed for {}: {}", entity.getSourcePath(), e.getMessage());
}
}
return entity.getOriginalContent();
}
private String computeHash(String content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(content.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (Exception e) {
log.warn("[Wiki] Failed to compute content hash: {}", e.getMessage());
return null;
}
}
}

View File

@ -0,0 +1,157 @@
package vip.mate.wiki.tool;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiPageService;
import java.util.List;
/**
* Wiki 知识库工具
* <p>
* Agent 在对话中按需读取 Wiki 页面内容
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WikiTool {
private final WikiPageService pageService;
private final WikiKnowledgeBaseService kbService;
@Tool(description = """
读取 Wiki 知识库中指定页面的完整内容
当系统提示词中的 Wiki 页面摘要不够详细时使用此工具获取完整内容
返回 Markdown 格式的页面内容包含 [[双向链接]]
""")
public String wiki_read_page(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "知识库 ID") Long kbId,
@ToolParam(description = "页面标识符 (slug)") String slug) {
if (kbId == null || slug == null || slug.isBlank()) {
return error("kbId and slug are required");
}
String accessError = checkAccess(agentId, kbId);
if (accessError != null) return accessError;
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) {
return error("Page not found: " + slug);
}
JSONObject result = JSONUtil.createObj()
.set("title", page.getTitle())
.set("slug", page.getSlug())
.set("version", page.getVersion())
.set("lastUpdatedBy", page.getLastUpdatedBy())
.set("content", page.getContent());
return result.toString();
}
@Tool(description = """
列出 Wiki 知识库中的所有页面
返回页面列表包含标题slug 和摘要
""")
public String wiki_list_pages(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "知识库 ID") Long kbId) {
if (kbId == null) {
return error("kbId is required");
}
String accessError = checkAccess(agentId, kbId);
if (accessError != null) return accessError;
List<WikiPageEntity> pages = pageService.listSummaries(kbId);
JSONArray arr = new JSONArray();
for (WikiPageEntity page : pages) {
arr.add(JSONUtil.createObj()
.set("title", page.getTitle())
.set("slug", page.getSlug())
.set("summary", page.getSummary()));
}
return JSONUtil.createObj()
.set("kbId", kbId)
.set("pageCount", pages.size())
.set("pages", arr)
.toString();
}
@Tool(description = """
Wiki 知识库中搜索页面
按关键词搜索页面标题和摘要返回匹配的页面列表
""")
public String wiki_search_pages(
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
@ToolParam(description = "知识库 ID") Long kbId,
@ToolParam(description = "搜索关键词") String query) {
if (kbId == null || query == null || query.isBlank()) {
return error("kbId and query are required");
}
String accessError = checkAccess(agentId, kbId);
if (accessError != null) return accessError;
String queryLower = query.toLowerCase();
List<WikiPageEntity> pages = pageService.listSummaries(kbId);
List<WikiPageEntity> matched = pages.stream()
.filter(p -> (p.getTitle() != null && p.getTitle().toLowerCase().contains(queryLower))
|| (p.getSummary() != null && p.getSummary().toLowerCase().contains(queryLower)))
.toList();
JSONArray arr = new JSONArray();
for (WikiPageEntity page : matched) {
arr.add(JSONUtil.createObj()
.set("title", page.getTitle())
.set("slug", page.getSlug())
.set("summary", page.getSummary()));
}
return JSONUtil.createObj()
.set("kbId", kbId)
.set("query", query)
.set("matchCount", matched.size())
.set("pages", arr)
.toString();
}
/**
* 校验 Agent 是否有权访问指定知识库
*/
private String checkAccess(Long agentId, Long kbId) {
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
if (kb == null) {
return error("Knowledge base not found: " + kbId);
}
// KB 绑定了 agent 必须提供匹配的 agentId
if (kb.getAgentId() != null) {
if (agentId == null) {
return error("Access denied: agentId is required for this knowledge base");
}
if (!kb.getAgentId().equals(agentId)) {
return error("Access denied: knowledge base is not associated with this agent");
}
}
return null;
}
private String error(String message) {
return JSONUtil.createObj().set("error", message).toString();
}
}

View File

@ -49,3 +49,15 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), de
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000014, 'DelegateAgentTool', 'Agent 委派', '委派任务给其他 Agent 执行,实现多 Agent 协作。支持按名称调用目标 Agent在独立会话中运行并返回结果。', 'builtin', 'delegateAgentTool', '🤝', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000015, 'DatasourceTool', '数据源查询', '查询外部数据源的元数据:列出可用数据源、查看表列表、查看表结构(列名/类型/注释)。支持 MySQL、PostgreSQL、ClickHouse。', 'builtin', 'datasourceTool', '🗄', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000016, 'SqlQueryTool', 'SQL 查询', '在外部数据源上执行只读 SQL 查询。仅允许 SELECT 语句,自动添加 LIMIT 保护,结果格式化为表格展示。', 'builtin', 'sqlQueryTool', '📊', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000017, 'WikiTool', 'Wiki 知识库', '读取、列出和搜索 Wiki 知识库中的结构化页面。支持 wiki_read_page、wiki_list_pages、wiki_search_pages 三个工具。', 'builtin', 'wikiTool', '📚', TRUE, TRUE, NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), builtin=VALUES(builtin), update_time=NOW();

View File

@ -60,3 +60,7 @@ VALUES (1000000015, 'DatasourceTool', '数据源查询', '查询外部数据源
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000016, 'SqlQueryTool', 'SQL 查询', '在外部数据源上执行只读 SQL 查询。仅允许 SELECT 语句,自动添加 LIMIT 保护,结果格式化为表格展示。', 'builtin', 'sqlQueryTool', '📊', TRUE, TRUE, NOW(), NOW(), 0);
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)
VALUES (1000000017, 'WikiTool', 'Wiki 知识库', '读取、列出和搜索 Wiki 知识库中的结构化页面。支持 wiki_read_page、wiki_list_pages、wiki_search_pages 三个工具。', 'builtin', 'wikiTool', '📚', TRUE, TRUE, NOW(), NOW(), 0);

View File

@ -0,0 +1,58 @@
你是一个知识库 Wiki 编辑助手。你的任务是将原始材料消化成结构化的 Wiki 页面。
## 核心职责
1. **阅读并理解原始材料**
2. **创建新的 Wiki 页面**:每个页面聚焦一个概念、实体或主题
3. **更新已有 Wiki 页面**:当新材料包含已有页面的相关信息时,合并更新
4. **建立双向链接**:使用 [[页面标题]] 语法在页面间建立交叉引用
## 页面创建规则
- 每个页面以一段话摘要开头
- 使用清晰的 Markdown 标题(## 和 ###)组织内容
- 在提到相关概念时使用 [[链接标题]] 链接到其他页面
- 页面标题应简洁准确,反映核心内容
- slugURL 标识符)使用小写字母、数字和连字符
## 更新规则
- 合并新旧信息,不要简单替换
- 如果新信息与已有信息矛盾,明确标注矛盾点
- 保留手动编辑的内容last_updated_by = 'manual' 的页面),仅添加不冲突的新信息
- 不要删除已有页面中仍然有效的信息
## 语言规则
- Wiki 页面使用与原始材料相同的语言
- 保持术语在所有页面间的一致性
## 输出格式
严格输出 JSON不要包含 markdown 代码块标记:
{
"pages": [
{
"slug": "concept-name",
"title": "概念名称",
"content": "## 概念名称\n\n一段话摘要...\n\n### 详细内容\n...\n\n参见[[相关主题]]",
"summary": "一段话摘要"
}
],
"updated_pages": [
{
"slug": "existing-page-slug",
"title": "已有页面标题",
"content": "...合并后的完整内容...",
"summary": "更新后的摘要"
}
]
}
字段说明:
- `pages`: 新创建的页面数组
- `updated_pages`: 需要更新的已有页面数组
- 每个页面的 `slug` 必须是 URL 安全的标识符(小写、连字符分隔)
- `content` 是完整的 Markdown 内容(不是增量更新)
- `summary` 是一段话的简短摘要

View File

@ -0,0 +1,21 @@
## 知识库处理规则
{config}
## 已有 Wiki 页面索引
{existing_pages}
## 待消化的原始材料
标题:{raw_title}
{raw_content}
---
请根据以上原始材料:
1. 创建新的 Wiki 页面(每个材料通常产生 5-15 个页面)
2. 如果已有页面与新材料相关,更新这些页面
3. 确保页面间有充分的 [[双向链接]]
4. 每个页面聚焦单一主题,内容结构清晰

View File

@ -321,3 +321,52 @@ export const cronJobApi = {
http.put(`/cron-jobs/${id}/toggle`, null, { params: { enabled } }),
runNow: (id: string | number) => http.post(`/cron-jobs/${id}/run`),
}
// ==================== Wiki Knowledge Base ====================
export const wikiApi = {
// Knowledge Base
listKBs: () => http.get('/wiki/knowledge-bases'),
getKB: (id: number) => http.get(`/wiki/knowledge-bases/${id}`),
listKBsByAgent: (agentId: number) => http.get(`/wiki/knowledge-bases/agent/${agentId}`),
createKB: (data: { name: string; description?: string; agentId?: number }) =>
http.post('/wiki/knowledge-bases', data),
updateKB: (id: number, data: { name?: string; description?: string; agentId?: number }) =>
http.put(`/wiki/knowledge-bases/${id}`, data),
deleteKB: (id: number) => http.delete(`/wiki/knowledge-bases/${id}`),
getConfig: (id: number) => http.get(`/wiki/knowledge-bases/${id}/config`),
updateConfig: (id: number, content: string) =>
http.put(`/wiki/knowledge-bases/${id}/config`, { content }),
// Directory Scan
setSourceDirectory: (id: number, path: string) =>
http.put(`/wiki/knowledge-bases/${id}/source-directory`, { path }),
scanDirectory: (id: number) => http.post(`/wiki/knowledge-bases/${id}/scan`),
// Raw Materials
listRaw: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/raw`),
addRawText: (kbId: number, data: { title: string; content: string }) =>
http.post(`/wiki/knowledge-bases/${kbId}/raw/text`, data),
uploadRaw: (kbId: number, formData: FormData) =>
http.post(`/wiki/knowledge-bases/${kbId}/raw/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
deleteRaw: (kbId: number, rawId: number) =>
http.delete(`/wiki/knowledge-bases/${kbId}/raw/${rawId}`),
reprocessRaw: (kbId: number, rawId: number) =>
http.post(`/wiki/knowledge-bases/${kbId}/raw/${rawId}/reprocess`),
// Wiki Pages
listPages: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/pages`),
getPage: (kbId: number, slug: string) =>
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}`),
updatePage: (kbId: number, slug: string, content: string) =>
http.put(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}`, { content }),
deletePage: (kbId: number, slug: string) =>
http.delete(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}`),
getBacklinks: (kbId: number, slug: string) =>
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/backlinks`),
// Processing
processKB: (kbId: number) => http.post(`/wiki/knowledge-bases/${kbId}/process`),
getProcessingStatus: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/processing-status`),
}

View File

@ -18,6 +18,7 @@ export default {
copy: 'Copy',
copied: 'Copied',
confirm: 'Confirm',
add: 'Add',
search: 'Search',
expandSidebar: 'Expand sidebar',
collapseSidebar: 'Collapse sidebar',
@ -185,6 +186,7 @@ export default {
agent: 'Agent',
workspace: 'Workspace',
skills: 'Skills',
wiki: 'Wiki KB',
tools: 'Tools',
datasources: 'Datasources',
mcpServers: 'MCP Servers',
@ -929,6 +931,38 @@ export default {
testFailed: 'Connection failed. Please check configuration.',
},
},
wiki: {
desc: 'AI-powered structured knowledge base that digests raw materials into Wiki pages',
createKB: 'New Knowledge Base',
knowledgeBases: 'Knowledge Bases',
noKB: 'No knowledge bases yet',
selectKB: 'Select a knowledge base',
selectPage: 'Select a page from the sidebar',
kbName: 'Name',
kbNamePlaceholder: 'Enter knowledge base name',
kbDescription: 'Description',
kbDescPlaceholder: 'Briefly describe the knowledge base purpose',
rawMaterials: 'Raw Materials',
pages: 'Wiki Pages',
config: 'Config',
searchPages: 'Search pages...',
dropFiles: 'Drop files here or click to upload',
addText: 'Add Text',
noRawMaterials: 'No raw materials yet',
reprocess: 'Reprocess',
processAll: 'Process All Pending',
materialTitle: 'Title',
materialContent: 'Content',
pasteContent: 'Paste or type content here...',
backlinks: 'Backlinks',
configTitle: 'Wiki Processing Rules',
configDesc: 'Configure the rules AI follows when digesting raw materials',
configPlaceholder: 'Edit wiki processing rules here...',
dirPlaceholder: 'Enter local directory path, e.g. /Users/me/docs',
scan: 'Scan',
scanning: 'Scanning...',
scanResult: 'Scanned {scanned} files, added {added}, skipped {skipped}',
},
cronJobs: {
title: 'Cron Jobs',
desc: 'Schedule agents to run messages or goals on a timer',

View File

@ -18,6 +18,7 @@ export default {
copy: '复制',
copied: '已复制',
confirm: '确认',
add: '添加',
search: '搜索',
expandSidebar: '展开侧边栏',
collapseSidebar: '折叠侧边栏',
@ -185,6 +186,7 @@ export default {
agent: '智能体',
workspace: '工作区',
skills: '技能',
wiki: 'Wiki 知识库',
tools: '工具',
datasources: '数据源',
mcpServers: 'MCP 服务',
@ -939,6 +941,38 @@ export default {
testFailed: '连接失败,请检查配置',
},
},
wiki: {
desc: 'AI 驱动的结构化知识库,自动消化原始材料为 Wiki 页面',
createKB: '新建知识库',
knowledgeBases: '知识库',
noKB: '暂无知识库',
selectKB: '请选择一个知识库',
selectPage: '从左侧选择一个页面查看',
kbName: '名称',
kbNamePlaceholder: '输入知识库名称',
kbDescription: '描述',
kbDescPlaceholder: '简要描述知识库用途',
rawMaterials: '原始材料',
pages: 'Wiki 页面',
config: '处理配置',
searchPages: '搜索页面...',
dropFiles: '拖拽文件到此处或点击上传',
addText: '添加文本',
noRawMaterials: '暂无原始材料',
reprocess: '重新处理',
processAll: '处理所有待处理材料',
materialTitle: '标题',
materialContent: '内容',
pasteContent: '粘贴或输入文本内容...',
backlinks: '反向链接',
configTitle: '知识库处理规则',
configDesc: '配置 AI 消化原始材料时遵循的规则',
configPlaceholder: '在此编辑 Wiki 处理规则...',
dirPlaceholder: '输入本地目录路径,如 /Users/me/docs',
scan: '扫描',
scanning: '扫描中...',
scanResult: '已扫描 {scanned} 个文件,新增 {added} 个,跳过 {skipped} 个',
},
cronJobs: {
title: '定时任务',
desc: '定时触发 Agent 执行消息或目标任务',

View File

@ -44,6 +44,12 @@ const router = createRouter({
component: () => import('@/views/SkillMarket.vue'),
meta: { title: 'Skills' },
},
{
path: 'wiki',
name: 'Wiki',
component: () => import('@/views/Wiki/index.vue'),
meta: { title: 'Wiki' },
},
{
path: 'tools',
name: 'Tools',

View File

@ -0,0 +1,146 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { wikiApi } from '@/api/index'
export interface WikiKB {
id: number
name: string
description: string
agentId: number | null
configContent: string
sourceDirectory: string | null
status: string
pageCount: number
rawCount: number
createTime: string
updateTime: string
}
export interface WikiRawMaterial {
id: number
kbId: number
title: string
sourceType: string
fileSize: number
processingStatus: string
lastProcessedAt: string | null
errorMessage: string | null
createTime: string
}
export interface WikiPage {
id: number
kbId: number
slug: string
title: string
content: string | null
summary: string
outgoingLinks: string
sourceRawIds: string
version: number
lastUpdatedBy: string
createTime: string
updateTime: string
}
export const useWikiStore = defineStore('wiki', () => {
const knowledgeBases = ref<WikiKB[]>([])
const currentKB = ref<WikiKB | null>(null)
const rawMaterials = ref<WikiRawMaterial[]>([])
const pages = ref<WikiPage[]>([])
const currentPage = ref<WikiPage | null>(null)
const loading = ref(false)
async function fetchKnowledgeBases() {
loading.value = true
try {
const res: any = await wikiApi.listKBs()
knowledgeBases.value = res.data || []
} catch (e) {
console.error('Failed to fetch knowledge bases', e)
} finally {
loading.value = false
}
}
async function selectKB(id: number) {
const res: any = await wikiApi.getKB(id)
currentKB.value = res.data || res
await Promise.all([fetchRawMaterials(id), fetchPages(id)])
}
async function createKB(data: { name: string; description?: string; agentId?: number }) {
const res: any = await wikiApi.createKB(data)
const kb = res.data || res
knowledgeBases.value.unshift(kb)
return kb
}
async function deleteKB(id: number) {
await wikiApi.deleteKB(id)
knowledgeBases.value = knowledgeBases.value.filter((kb) => kb.id !== id)
if (currentKB.value?.id === id) {
currentKB.value = null
rawMaterials.value = []
pages.value = []
}
}
async function fetchRawMaterials(kbId: number) {
const res: any = await wikiApi.listRaw(kbId)
rawMaterials.value = res.data || []
}
async function fetchPages(kbId: number) {
const res: any = await wikiApi.listPages(kbId)
pages.value = res.data || []
}
async function loadPage(kbId: number, slug: string) {
const res: any = await wikiApi.getPage(kbId, slug)
currentPage.value = res.data || res
}
async function addRawText(kbId: number, title: string, content: string) {
const res: any = await wikiApi.addRawText(kbId, { title, content })
const raw = res.data || res
rawMaterials.value.unshift(raw)
return raw
}
async function uploadRawFile(kbId: number, file: File) {
const formData = new FormData()
formData.append('file', file)
const res: any = await wikiApi.uploadRaw(kbId, formData)
const raw = res.data || res
rawMaterials.value.unshift(raw)
return raw
}
async function scanDirectory(kbId: number) {
const res: any = await wikiApi.scanDirectory(kbId)
const result = res.data || res
// 扫描后刷新材料列表
await fetchRawMaterials(kbId)
return result
}
return {
knowledgeBases,
currentKB,
rawMaterials,
pages,
currentPage,
loading,
fetchKnowledgeBases,
selectKB,
createKB,
deleteKB,
fetchRawMaterials,
fetchPages,
loadPage,
addRawText,
uploadRawFile,
scanDirectory,
}
})

View File

@ -0,0 +1,284 @@
<template>
<div class="raw-panel">
<!-- Upload + Add text row -->
<div class="upload-row">
<div class="upload-zone" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleDrop">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div class="upload-text">
<span class="upload-label">{{ t('wiki.dropFiles') }}</span>
<span class="upload-hint">.txt, .md, .pdf, .docx</span>
</div>
</div>
<input ref="fileInput" type="file" style="display:none" accept=".txt,.md,.pdf,.docx,.doc" multiple @change="handleFileSelect" />
<button class="btn-secondary add-text-btn" @click="showAddText = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('wiki.addText') }}
</button>
</div>
<!-- Directory scan -->
<div class="dir-scan-row">
<div class="dir-input-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<input
v-model="dirPath"
type="text"
class="dir-input"
:placeholder="t('wiki.dirPlaceholder')"
@keyup.enter="handleScanDir"
/>
</div>
<button class="btn-secondary" @click="handleScanDir" :disabled="scanning || !dirPath.trim()">
<svg v-if="!scanning" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
{{ scanning ? t('wiki.scanning') : t('wiki.scan') }}
</button>
</div>
<div v-if="scanResult" class="scan-result">
{{ t('wiki.scanResult', { scanned: scanResult.scanned, added: scanResult.added, skipped: scanResult.skipped }) }}
</div>
<!-- Raw materials list -->
<div class="raw-list">
<h4 class="raw-list-title">
{{ t('wiki.rawMaterials') }} ({{ store.rawMaterials.length }})
</h4>
<div v-if="store.rawMaterials.length === 0" class="empty-hint">
{{ t('wiki.noRawMaterials') }}
</div>
<div v-for="raw in store.rawMaterials" :key="raw.id" class="raw-item">
<div class="raw-item-info">
<span class="raw-item-title">{{ raw.title }}</span>
<span class="raw-item-type">{{ raw.sourceType }}</span>
</div>
<div class="raw-item-meta">
<span class="status-badge" :class="raw.processingStatus">
{{ raw.processingStatus }}
</span>
<span v-if="raw.errorMessage" class="error-hint" :title="raw.errorMessage">
{{ raw.errorMessage }}
</span>
</div>
<div class="raw-item-actions">
<button
v-if="raw.processingStatus === 'failed' || raw.processingStatus === 'completed'"
class="btn-icon" :title="t('wiki.reprocess')"
@click="reprocess(raw.id)"
>
<svg width="14" height="14" 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>
<button class="btn-icon btn-icon-danger" :title="t('common.delete')" @click="deleteRaw(raw.id)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Process all button -->
<button
v-if="store.currentKB && store.rawMaterials.some(r => r.processingStatus === 'pending')"
class="btn-primary process-btn"
@click="processAll"
>
{{ t('wiki.processAll') }}
</button>
<!-- Add Text Modal -->
<div v-if="showAddText" class="modal-overlay" @click.self="showAddText = false">
<div class="modal-content">
<h3 class="modal-title">{{ t('wiki.addText') }}</h3>
<div class="form-group">
<label>{{ t('wiki.materialTitle') }}</label>
<input v-model="textTitle" type="text" class="form-input" />
</div>
<div class="form-group">
<label>{{ t('wiki.materialContent') }}</label>
<textarea v-model="textContent" class="form-input" rows="12" :placeholder="t('wiki.pasteContent')"></textarea>
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="showAddText = false">{{ t('common.cancel') }}</button>
<button class="btn-primary" @click="handleAddText" :disabled="!textTitle.trim() || !textContent.trim()">
{{ t('common.add') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
const { t } = useI18n()
const store = useWikiStore()
const fileInput = ref<HTMLInputElement | null>(null)
const showAddText = ref(false)
const textTitle = ref('')
const textContent = ref('')
const dirPath = ref(store.currentKB?.sourceDirectory || '')
const scanning = ref(false)
const scanResult = ref<{ scanned: number; added: number; skipped: number } | null>(null)
function triggerFileInput() {
fileInput.value?.click()
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files || !store.currentKB) return
for (const file of Array.from(input.files)) {
await store.uploadRawFile(store.currentKB.id, file)
}
input.value = ''
}
async function handleDrop(event: DragEvent) {
if (!event.dataTransfer?.files || !store.currentKB) return
for (const file of Array.from(event.dataTransfer.files)) {
await store.uploadRawFile(store.currentKB.id, file)
}
}
async function handleAddText() {
if (!store.currentKB) return
await store.addRawText(store.currentKB.id, textTitle.value, textContent.value)
showAddText.value = false
textTitle.value = ''
textContent.value = ''
}
async function reprocess(rawId: number) {
if (!store.currentKB) return
await wikiApi.reprocessRaw(store.currentKB.id, rawId)
await store.fetchRawMaterials(store.currentKB.id)
}
async function deleteRaw(rawId: number) {
if (!store.currentKB) return
await wikiApi.deleteRaw(store.currentKB.id, rawId)
await store.fetchRawMaterials(store.currentKB.id)
}
async function processAll() {
if (!store.currentKB) return
await wikiApi.processKB(store.currentKB.id)
await store.fetchRawMaterials(store.currentKB.id)
}
async function handleScanDir() {
if (!store.currentKB || !dirPath.value.trim()) return
scanning.value = true
scanResult.value = null
try {
//
await wikiApi.setSourceDirectory(store.currentKB.id, dirPath.value.trim())
//
const result = await store.scanDirectory(store.currentKB.id)
scanResult.value = result
} catch (e: any) {
console.error('Scan failed', e)
} finally {
scanning.value = false
}
}
</script>
<style scoped>
/* Buttons */
.btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; white-space: nowrap; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
/* Directory scan */
.dir-scan-row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
.dir-input-wrap { flex: 1; display: flex; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-elevated); color: var(--mc-text-tertiary); }
.dir-input-wrap:focus-within { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.dir-input { flex: 1; border: none; background: transparent; font-size: 13px; color: var(--mc-text-primary); outline: none; }
.dir-input::placeholder { color: var(--mc-text-tertiary); }
.scan-result { font-size: 12px; color: var(--mc-text-secondary); margin-bottom: 12px; padding: 6px 10px; background: rgba(90,138,90,0.1); border-radius: 6px; }
/* Upload row: zone + add text side by side */
.upload-row { display: flex; gap: 12px; align-items: stretch; margin-bottom: 20px; }
.upload-zone {
flex: 1;
border: 1px dashed var(--mc-border);
border-radius: 12px;
padding: 20px 24px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
display: flex;
align-items: center;
gap: 12px;
color: var(--mc-text-tertiary);
}
.upload-zone:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); }
.upload-zone svg { flex-shrink: 0; }
.upload-text { display: flex; flex-direction: column; gap: 2px; }
.upload-label { font-size: 14px; color: var(--mc-text-secondary); }
.upload-hint { font-size: 12px; color: var(--mc-text-tertiary); }
.add-text-btn { flex-shrink: 0; }
/* Raw list */
.raw-list { display: flex; flex-direction: column; gap: 8px; }
.raw-list-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--mc-text-tertiary); margin-bottom: 4px; }
.empty-hint { text-align: center; padding: 24px 0; font-size: 14px; color: var(--mc-text-tertiary); }
.raw-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; background: var(--mc-bg-elevated); border: 1px solid var(--mc-border-light); border-radius: 10px; font-size: 13px; transition: border-color 0.15s; }
.raw-item:hover { border-color: var(--mc-border); }
.raw-item-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.raw-item-title { font-weight: 500; color: var(--mc-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.raw-item-type { font-size: 10px; padding: 2px 6px; background: var(--mc-bg-sunken); border-radius: 4px; text-transform: uppercase; color: var(--mc-text-tertiary); letter-spacing: 0.02em; }
.raw-item-meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.raw-item-actions { display: flex; gap: 4px; flex-shrink: 0; }
.error-hint { font-size: 11px; color: var(--mc-danger); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Icon button */
.btn-icon { width: 28px; height: 28px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-elevated); cursor: pointer; border-radius: 6px; color: var(--mc-text-secondary); transition: all 0.15s; display: flex; align-items: center; justify-content: center; }
.btn-icon:hover { background: var(--mc-bg-sunken); color: var(--mc-primary); border-color: var(--mc-border); }
.btn-icon-danger:hover { background: var(--mc-danger-bg); color: var(--mc-danger); border-color: var(--mc-danger); }
/* Status badges */
.status-badge { font-size: 10px; padding: 2px 8px; border-radius: 9999px; text-transform: uppercase; font-weight: 500; letter-spacing: 0.02em; }
.status-badge.pending { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.status-badge.processing { background: var(--mc-primary-bg); color: var(--mc-primary); }
.status-badge.completed { background: rgba(90, 138, 90, 0.15); color: var(--mc-success); }
.status-badge.failed { background: var(--mc-danger-bg); color: var(--mc-danger); }
/* Process button */
.process-btn { width: 100%; justify-content: center; margin-top: 16px; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.modal-content { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-width: 640px; padding: 24px; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.modal-title { font-size: 18px; font-weight: 600; color: var(--mc-text-primary); margin: 0 0 16px; }
/* Form */
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: var(--mc-text-secondary); }
.form-input { width: 100%; padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); outline: none; font-family: inherit; }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
</style>

View File

@ -0,0 +1,82 @@
<template>
<div class="wiki-config">
<div class="config-header">
<h3 class="config-title">{{ t('wiki.configTitle') }}</h3>
<p class="config-desc">{{ t('wiki.configDesc') }}</p>
</div>
<textarea
v-model="configContent"
class="config-editor"
rows="20"
:placeholder="t('wiki.configPlaceholder')"
></textarea>
<div class="config-actions">
<button class="btn-secondary" @click="loadConfig">{{ t('common.reset') }}</button>
<button class="btn-primary" @click="saveConfig" :disabled="saving">
{{ saving ? 'Saving...' : t('common.save') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
const { t } = useI18n()
const store = useWikiStore()
const configContent = ref('')
const saving = ref(false)
async function loadConfig() {
if (!store.currentKB) return
try {
const res: any = await wikiApi.getConfig(store.currentKB.id)
configContent.value = res.data?.content || ''
} catch (e) {
console.error('Failed to load config', e)
}
}
async function saveConfig() {
if (!store.currentKB) return
saving.value = true
try {
await wikiApi.updateConfig(store.currentKB.id, configContent.value)
} catch (e) {
console.error('Failed to save config', e)
} finally {
saving.value = false
}
}
watch(() => store.currentKB, () => {
loadConfig()
}, { immediate: true })
</script>
<style scoped>
/* Header */
.config-header { margin-bottom: 16px; }
.config-title { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin: 0 0 4px; }
.config-desc { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; }
/* Editor */
.config-editor { width: 100%; padding: 16px; border: 1px solid var(--mc-border); border-radius: 12px; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.7; resize: vertical; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }
.config-editor:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
/* Actions */
.config-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
/* Buttons */
.btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
</style>

View File

@ -0,0 +1,171 @@
<template>
<div class="page-viewer" v-if="store.currentPage">
<div class="page-viewer-header">
<div>
<h2 class="page-viewer-title">{{ store.currentPage.title }}</h2>
<div class="page-viewer-meta">
<span>v{{ store.currentPage.version }}</span>
<span>&middot;</span>
<span>{{ store.currentPage.lastUpdatedBy === 'ai' ? 'AI generated' : 'Manually edited' }}</span>
<span>&middot;</span>
<span>{{ store.currentPage.slug }}</span>
</div>
</div>
<div class="page-viewer-actions">
<button class="btn-secondary btn-sm" @click="editing = !editing">
{{ editing ? t('common.cancel') : t('common.edit') }}
</button>
<button v-if="editing" class="btn-primary btn-sm" @click="saveEdit">
{{ t('common.save') }}
</button>
</div>
</div>
<!-- Summary -->
<div v-if="store.currentPage.summary" class="page-summary">
{{ store.currentPage.summary }}
</div>
<!-- Content -->
<div v-if="!editing" class="page-content" v-html="renderedContent"></div>
<textarea v-else v-model="editContent" class="page-editor" rows="30"></textarea>
<!-- Backlinks -->
<div v-if="backlinks.length > 0" class="backlinks-section">
<h4 class="backlinks-title">{{ t('wiki.backlinks') }} ({{ backlinks.length }})</h4>
<div class="backlinks-list">
<span
v-for="bl in backlinks" :key="bl.slug"
class="backlink-tag"
@click="openPage(bl.slug)"
>
{{ bl.title }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore, type WikiPage } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
const { t } = useI18n()
const store = useWikiStore()
const editing = ref(false)
const editContent = ref('')
const backlinks = ref<WikiPage[]>([])
// Simple markdown to HTML renderer with [[link]] support
const renderedContent = computed(() => {
if (!store.currentPage?.content) return ''
let html = store.currentPage.content
// Escape HTML
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Headers
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Bold and italic
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Wiki links
.replace(/\[\[([^\]]+)\]\]/g, (_match, title) => {
const slug = title.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff\s-]/g, '').replace(/\s+/g, '-')
return `<a class="wiki-link" data-slug="${slug}" onclick="return false">${title}</a>`
})
// Regular links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
// Lists
.replace(/^- (.+)$/gm, '<li>$1</li>')
// Code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
return `<p>${html}</p>`
})
watch(() => store.currentPage, async (page) => {
if (page && store.currentKB) {
editing.value = false
editContent.value = page.content || ''
// Fetch backlinks
try {
const res: any = await wikiApi.getBacklinks(store.currentKB.id, page.slug)
backlinks.value = res.data || []
} catch {
backlinks.value = []
}
}
}, { immediate: true })
async function saveEdit() {
if (!store.currentKB || !store.currentPage) return
await wikiApi.updatePage(store.currentKB.id, store.currentPage.slug, editContent.value)
await store.loadPage(store.currentKB.id, store.currentPage.slug)
editing.value = false
}
async function openPage(slug: string) {
if (!store.currentKB) return
await store.loadPage(store.currentKB.id, slug)
}
// Handle wiki link clicks via event delegation
onMounted(() => {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement
if (target.classList.contains('wiki-link')) {
const slug = target.dataset.slug
if (slug) openPage(slug)
}
})
})
</script>
<style scoped>
/* Buttons */
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-primary.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-secondary.btn-sm { padding: 6px 14px; font-size: 13px; }
/* Header */
.page-viewer-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
.page-viewer-title { font-size: 24px; font-weight: 600; color: var(--mc-text-primary); }
.page-viewer-meta { font-size: 12px; color: var(--mc-text-secondary); display: flex; gap: 8px; margin-top: 4px; }
.page-viewer-actions { display: flex; gap: 8px; }
/* Summary */
.page-summary { padding: 12px 16px; background: var(--mc-bg-sunken); border-radius: 8px; font-size: 14px; color: var(--mc-text-secondary); margin-bottom: 16px; border-left: 3px solid var(--mc-primary); }
/* Content */
.page-content { font-size: 15px; line-height: 1.75; color: var(--mc-text-primary); }
.page-content :deep(h1) { font-size: 24px; font-weight: 600; margin: 24px 0 12px; color: var(--mc-text-primary); }
.page-content :deep(h2) { font-size: 20px; font-weight: 600; margin: 20px 0 8px; color: var(--mc-text-primary); }
.page-content :deep(h3) { font-size: 18px; font-weight: 600; margin: 16px 0 8px; color: var(--mc-text-primary); }
.page-content :deep(li) { margin-left: 24px; list-style: disc; }
.page-content :deep(code) { background: var(--mc-bg-sunken); padding: 2px 6px; border-radius: 4px; font-size: 0.85em; }
.page-content :deep(.wiki-link) { color: var(--mc-primary); text-decoration: none; cursor: pointer; border-bottom: 1px dashed var(--mc-primary); }
.page-content :deep(.wiki-link:hover) { text-decoration: underline; }
/* Editor */
.page-editor { width: 100%; padding: 16px; border: 1px solid var(--mc-border); border-radius: 8px; font-family: 'JetBrains Mono', monospace; font-size: 14px; line-height: 1.6; resize: vertical; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }
.page-editor:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
/* Backlinks */
.backlinks-section { margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--mc-border); }
.backlinks-title { font-size: 12px; font-weight: 600; text-transform: uppercase; color: var(--mc-text-secondary); margin-bottom: 8px; letter-spacing: 0.05em; }
.backlinks-list { display: flex; flex-wrap: wrap; gap: 6px; }
.backlink-tag { padding: 4px 10px; background: var(--mc-bg-sunken); border-radius: 9999px; font-size: 12px; cursor: pointer; color: var(--mc-primary); transition: background 0.15s; }
.backlink-tag:hover { background: var(--mc-primary-bg); }
</style>

View File

@ -0,0 +1,248 @@
<template>
<div class="page-container">
<div class="page-header">
<div>
<h1 class="page-title">{{ t('nav.wiki') }}</h1>
<p class="page-desc">{{ t('wiki.desc') }}</p>
</div>
<button class="btn-primary" @click="showCreateKB = true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('wiki.createKB') }}
</button>
</div>
<div class="wiki-layout">
<!-- Left: Knowledge Base List -->
<div class="wiki-sidebar">
<div class="sidebar-section">
<h3 class="sidebar-title">{{ t('wiki.knowledgeBases') }}</h3>
<div v-if="store.loading" class="text-center py-4 text-gray-400">Loading...</div>
<div v-else-if="store.knowledgeBases.length === 0" class="text-center py-4 text-gray-400">
{{ t('wiki.noKB') }}
</div>
<div v-else class="kb-list">
<div
v-for="kb in store.knowledgeBases" :key="kb.id"
class="kb-item" :class="{ active: store.currentKB?.id === kb.id }"
@click="selectKB(kb.id)"
>
<div class="kb-item-name">{{ kb.name }}</div>
<div class="kb-item-meta">
<span>{{ kb.pageCount }} pages</span>
<span>{{ kb.rawCount }} sources</span>
</div>
<span class="kb-status" :class="kb.status">{{ kb.status }}</span>
</div>
</div>
</div>
<!-- Pages List when KB selected -->
<div v-if="store.currentKB" class="sidebar-section">
<h3 class="sidebar-title">
{{ t('wiki.pages') }}
<span class="text-xs text-gray-400">({{ store.pages.length }})</span>
</h3>
<input
v-model="pageSearch"
type="text"
:placeholder="t('wiki.searchPages')"
class="sidebar-search"
/>
<div class="page-list">
<div
v-for="page in filteredPages" :key="page.slug"
class="page-item" :class="{ active: store.currentPage?.slug === page.slug }"
@click="openPage(page.slug)"
>
<div class="page-item-title">{{ page.title }}</div>
<div class="page-item-meta">v{{ page.version }} &middot; {{ page.lastUpdatedBy }}</div>
</div>
</div>
</div>
</div>
<!-- Right: Content Area -->
<div class="wiki-content">
<!-- No KB selected -->
<div v-if="!store.currentKB" class="empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="mx-auto mb-4 text-gray-400">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
<p>{{ t('wiki.selectKB') }}</p>
</div>
<!-- Tab navigation -->
<div v-else>
<div class="content-tabs">
<button
v-for="tab in tabs" :key="tab.key"
class="tab-btn" :class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- Raw Materials Tab -->
<div v-if="activeTab === 'raw'" class="tab-content">
<RawMaterialPanel />
</div>
<!-- Wiki Pages Tab -->
<div v-if="activeTab === 'pages'" class="tab-content">
<WikiPageViewer v-if="store.currentPage" />
<div v-else class="empty-state">
<p>{{ t('wiki.selectPage') }}</p>
</div>
</div>
<!-- Config Tab -->
<div v-if="activeTab === 'config'" class="tab-content">
<WikiConfig />
</div>
</div>
</div>
</div>
<!-- Create KB Modal -->
<div v-if="showCreateKB" class="modal-overlay" @click.self="showCreateKB = false">
<div class="modal-content">
<h3 class="modal-title">{{ t('wiki.createKB') }}</h3>
<div class="form-group">
<label>{{ t('wiki.kbName') }}</label>
<input v-model="newKBName" type="text" class="form-input" :placeholder="t('wiki.kbNamePlaceholder')" />
</div>
<div class="form-group">
<label>{{ t('wiki.kbDescription') }}</label>
<textarea v-model="newKBDesc" class="form-input" rows="3" :placeholder="t('wiki.kbDescPlaceholder')"></textarea>
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="showCreateKB = false">{{ t('common.cancel') }}</button>
<button class="btn-primary" @click="handleCreateKB" :disabled="!newKBName.trim()">{{ t('common.create') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import RawMaterialPanel from './components/RawMaterialPanel.vue'
import WikiPageViewer from './components/WikiPageViewer.vue'
import WikiConfig from './components/WikiConfig.vue'
const { t } = useI18n()
const store = useWikiStore()
const showCreateKB = ref(false)
const newKBName = ref('')
const newKBDesc = ref('')
const activeTab = ref('raw')
const pageSearch = ref('')
const tabs = computed(() => [
{ key: 'raw', label: t('wiki.rawMaterials') },
{ key: 'pages', label: t('wiki.pages') },
{ key: 'config', label: t('wiki.config') },
])
const filteredPages = computed(() => {
const q = pageSearch.value.toLowerCase()
if (!q) return store.pages
return store.pages.filter(
(p) => p.title.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q)
)
})
async function selectKB(id: number) {
await store.selectKB(id)
activeTab.value = 'raw'
}
async function openPage(slug: string) {
if (!store.currentKB) return
await store.loadPage(store.currentKB.id, slug)
activeTab.value = 'pages'
}
async function handleCreateKB() {
await store.createKB({ name: newKBName.value, description: newKBDesc.value })
showCreateKB.value = false
newKBName.value = ''
newKBDesc.value = ''
}
onMounted(() => {
store.fetchKnowledgeBases()
})
</script>
<style scoped>
/* Base styles */
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
/* Layout */
.wiki-layout { display: flex; gap: 16px; height: calc(100vh - 180px); overflow: hidden; }
.wiki-sidebar { width: 280px; min-width: 280px; overflow-y: auto; border-right: 1px solid var(--mc-border); padding-right: 16px; display: flex; flex-direction: column; gap: 16px; }
.sidebar-section { display: flex; flex-direction: column; gap: 8px; }
.sidebar-title { font-size: 12px; font-weight: 600; text-transform: uppercase; color: var(--mc-text-secondary); letter-spacing: 0.05em; }
.sidebar-search { width: 100%; padding: 6px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 13px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); outline: none; }
.sidebar-search:focus { border-color: var(--mc-primary); }
.kb-list, .page-list { display: flex; flex-direction: column; gap: 4px; }
.kb-item, .page-item { padding: 8px 12px; border-radius: 8px; cursor: pointer; transition: background 0.15s; position: relative; }
.kb-item:hover, .page-item:hover { background: var(--mc-sidebar-hover); }
.kb-item.active, .page-item.active { background: var(--mc-primary-bg); border-left: 2px solid var(--mc-primary); }
.kb-item-name { font-size: 14px; font-weight: 500; color: var(--mc-text-primary); }
.kb-item-meta, .page-item-meta { font-size: 12px; color: var(--mc-text-secondary); display: flex; gap: 8px; }
.page-item-title { font-size: 13px; color: var(--mc-text-primary); }
.kb-status { position: absolute; right: 8px; top: 8px; font-size: 10px; padding: 2px 6px; border-radius: 9999px; text-transform: uppercase; font-weight: 500; }
.kb-status.active { background: rgba(90, 138, 90, 0.15); color: var(--mc-success); }
.kb-status.processing { background: var(--mc-primary-bg); color: var(--mc-primary); }
.kb-status.error { background: var(--mc-danger-bg); color: var(--mc-danger); }
/* Content area */
.wiki-content { flex: 1; overflow-y: auto; min-width: 0; }
.content-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--mc-border); margin-bottom: 16px; }
.tab-btn { padding: 8px 16px; border: none; background: none; cursor: pointer; font-size: 14px; color: var(--mc-text-secondary); border-bottom: 2px solid transparent; transition: all 0.15s; }
.tab-btn:hover { color: var(--mc-text-primary); }
.tab-btn.active { color: var(--mc-primary); border-bottom-color: var(--mc-primary); }
.tab-content { min-height: 400px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 300px; color: var(--mc-text-tertiary); }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.modal-content { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-width: 520px; padding: 24px; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.modal-title { font-size: 18px; font-weight: 600; color: var(--mc-text-primary); margin: 0 0 16px; }
/* Form */
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: var(--mc-text-secondary); }
.form-input { width: 100%; padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); outline: none; font-family: inherit; }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
</style>

View File

@ -216,6 +216,11 @@ const navGroups = computed(() => [
label: t('nav.skills'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
},
{
path: '/wiki',
label: t('nav.wiki'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
},
{
path: '/tools',
label: t('nav.tools'),