diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 1b06eef0..491eb05f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -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; } // ==================== 模型选项构建 ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java new file mode 100644 index 00000000..8757fb61 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiAutoConfiguration.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java new file mode 100644 index 00000000..9aa9a51d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/WikiProperties.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/config/WikiSchemaMigration.java b/mateclaw-server/src/main/java/vip/mate/wiki/config/WikiSchemaMigration.java new file mode 100644 index 00000000..c5bbdcbb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/config/WikiSchemaMigration.java @@ -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 迁移 + *

+ * 确保 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()); + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java new file mode 100644 index 00000000..c0965f31 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -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> listKBs() { + return R.ok(kbService.listAll()); + } + + @Operation(summary = "获取知识库详情") + @GetMapping("/knowledge-bases/{id}") + public R 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> listKBsByAgent(@PathVariable Long agentId) { + return R.ok(kbService.listByAgentId(agentId)); + } + + @Operation(summary = "创建知识库") + @PostMapping("/knowledge-bases") + public R createKB(@RequestBody Map 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 updateKB(@PathVariable Long id, @RequestBody Map 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 deleteKB(@PathVariable Long id) { + kbService.delete(id); + return R.ok(); + } + + @Operation(summary = "获取知识库配置") + @GetMapping("/knowledge-bases/{id}/config") + public R> 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 updateConfig(@PathVariable Long id, @RequestBody Map body) { + kbService.updateConfig(id, body.get("content")); + return R.ok(); + } + + // ==================== Directory Scan ==================== + + @Operation(summary = "设置知识库关联目录") + @PutMapping("/knowledge-bases/{id}/source-directory") + public R setSourceDirectory(@PathVariable Long id, @RequestBody Map body) { + String path = body.get("path"); + kbService.updateSourceDirectory(id, path); + return R.ok(); + } + + @Operation(summary = "扫描关联目录导入文件") + @PostMapping("/knowledge-bases/{id}/scan") + public R> scanDirectory(@PathVariable Long id) { + WikiDirectoryScanService.ScanResult result = scanService.scan(id); + Map 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> listRaw(@PathVariable Long kbId) { + return R.ok(rawService.listByKbId(kbId)); + } + + @Operation(summary = "添加文本材料") + @PostMapping("/knowledge-bases/{kbId}/raw/text") + public R addRawText(@PathVariable Long kbId, @RequestBody Map 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 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 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 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> listPages(@PathVariable Long kbId) { + return R.ok(pageService.listByKbId(kbId)); + } + + @Operation(summary = "获取 Wiki 页面内容") + @GetMapping("/knowledge-bases/{kbId}/pages/{slug}") + public R 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 updatePage(@PathVariable Long kbId, @PathVariable String slug, + @RequestBody Map 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 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> getBacklinks(@PathVariable Long kbId, @PathVariable String slug) { + return R.ok(pageService.getBacklinks(kbId, slug)); + } + + // ==================== Processing ==================== + + @Operation(summary = "触发知识库处理(异步)") + @PostMapping("/knowledge-bases/{kbId}/process") + public R> processKB(@PathVariable Long kbId) { + List 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> getProcessingStatus(@PathVariable Long kbId) { + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + if (kb == null) return R.fail("Knowledge base not found"); + + List 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() + )); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiProcessingEvent.java b/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiProcessingEvent.java new file mode 100644 index 00000000..295e9142 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiProcessingEvent.java @@ -0,0 +1,24 @@ +package vip.mate.wiki.event; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +/** + * Wiki 处理事件 + *

+ * 当原始材料需要被 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiProcessingListener.java b/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiProcessingListener.java new file mode 100644 index 00000000..b95cdc10 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiProcessingListener.java @@ -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 处理事件监听器 + *

+ * 异步处理原始材料消化事件。 + * + * @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); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java new file mode 100644 index 00000000..9d83964c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java new file mode 100644 index 00000000..4ea02c42 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java @@ -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; + + /** 来源原始材料 ID(JSON 数组) */ + @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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java new file mode 100644 index 00000000..a6e8d136 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiRawMaterialEntity.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiKnowledgeBaseMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiKnowledgeBaseMapper.java new file mode 100644 index 00000000..69277dbe --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiKnowledgeBaseMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java new file mode 100644 index 00000000..d863465a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java new file mode 100644 index 00000000..2f4ae307 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiRawMaterialMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java new file mode 100644 index 00000000..9500d1c8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java @@ -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 上下文服务 + *

+ * 为 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 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 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 ""; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java new file mode 100644 index 00000000..8130618b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java @@ -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 目录扫描服务 + *

+ * 扫描本地目录中的文档文件,为每个文件创建原始材料。 + * 基于 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 SUPPORTED_EXTENSIONS = Set.of( + "txt", "md", "pdf", "docx", "doc", "pptx", "xlsx" + ); + + private static final Set TEXT_EXTENSIONS = Set.of("txt", "md"); + + /** + * 扫描结果 + */ + public record ScanResult(int scanned, int added, int skipped, List 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 files = new ArrayList<>(); + List 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() : ""; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java new file mode 100644 index 00000000..a2f6ddf4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -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 listAll() { + return kbMapper.selectList( + new LambdaQueryWrapper() + .orderByDesc(WikiKnowledgeBaseEntity::getUpdateTime)); + } + + /** + * 获取 Agent 可访问的知识库:Agent 专属 KB + 公共 KB(agent_id IS NULL) + */ + public List listByAgentId(Long agentId) { + return kbMapper.selectList( + new LambdaQueryWrapper() + .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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java new file mode 100644 index 00000000..4af94861 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -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 listByKbId(Long kbId) { + List pages = pageMapper.selectList( + new LambdaQueryWrapper() + .eq(WikiPageEntity::getKbId, kbId) + .orderByAsc(WikiPageEntity::getTitle)); + pages.forEach(p -> p.setContent(null)); + return pages; + } + + /** + * 列出页面摘要(用于上下文注入和 LLM 消化) + */ + public List listSummaries(Long kbId) { + List pages = pageMapper.selectList( + new LambdaQueryWrapper() + .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() + .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 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 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 getBacklinks(Long kbId, String slug) { + // 在 outgoing_links JSON 中搜索包含此 slug 的页面 + List allPages = pageMapper.selectList( + new LambdaQueryWrapper() + .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() + .eq(WikiPageEntity::getKbId, kbId) + .eq(WikiPageEntity::getSlug, slug)); + } + + public int countByKbId(Long kbId) { + return Math.toIntExact(pageMapper.selectCount( + new LambdaQueryWrapper() + .eq(WikiPageEntity::getKbId, kbId))); + } + + /** + * 从 Markdown 内容中提取 [[links]] 并返回 JSON 数组 + */ + String extractLinksAsJson(String content) { + if (content == null) return "[]"; + List 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); + } + + /** + * 将标题转换为 slug(URL 安全标识符) + */ + 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 parseSourceRawIds(String json) { + if (json == null || json.isBlank()) return new ArrayList<>(); + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + return new ArrayList<>(); + } + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (Exception e) { + return "[]"; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java new file mode 100644 index 00000000..0fbe0d2a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -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 处理服务 + *

+ * 核心管线:将原始材料通过 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 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 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; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java new file mode 100644 index 00000000..0a1bf343 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -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 listByKbId(Long kbId) { + List list = rawMapper.selectList( + new LambdaQueryWrapper() + .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() + .eq(WikiRawMaterialEntity::getKbId, kbId) + .eq(WikiRawMaterialEntity::getSourcePath, sourcePath)); + } + + public List listPending(Long kbId) { + return rawMapper.selectList( + new LambdaQueryWrapper() + .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); + } + + /** + * 获取可用文本内容 + *

+ * 优先级:已缓存的 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; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java new file mode 100644 index 00000000..a4840437 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -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 知识库工具 + *

+ * 供 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 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 pages = pageService.listSummaries(kbId); + List 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(); + } +} diff --git a/mateclaw-server/src/main/resources/db/tools-sync-mysql.sql b/mateclaw-server/src/main/resources/db/tools-sync-mysql.sql index 1a134090..832c2732 100644 --- a/mateclaw-server/src/main/resources/db/tools-sync-mysql.sql +++ b/mateclaw-server/src/main/resources/db/tools-sync-mysql.sql @@ -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(); diff --git a/mateclaw-server/src/main/resources/db/tools-sync.sql b/mateclaw-server/src/main/resources/db/tools-sync.sql index c8c9346f..9f96e21f 100644 --- a/mateclaw-server/src/main/resources/db/tools-sync.sql +++ b/mateclaw-server/src/main/resources/db/tools-sync.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/prompts/wiki/digest-system.txt b/mateclaw-server/src/main/resources/prompts/wiki/digest-system.txt new file mode 100644 index 00000000..aabc5ee0 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/digest-system.txt @@ -0,0 +1,58 @@ +你是一个知识库 Wiki 编辑助手。你的任务是将原始材料消化成结构化的 Wiki 页面。 + +## 核心职责 + +1. **阅读并理解原始材料** +2. **创建新的 Wiki 页面**:每个页面聚焦一个概念、实体或主题 +3. **更新已有 Wiki 页面**:当新材料包含已有页面的相关信息时,合并更新 +4. **建立双向链接**:使用 [[页面标题]] 语法在页面间建立交叉引用 + +## 页面创建规则 + +- 每个页面以一段话摘要开头 +- 使用清晰的 Markdown 标题(## 和 ###)组织内容 +- 在提到相关概念时使用 [[链接标题]] 链接到其他页面 +- 页面标题应简洁准确,反映核心内容 +- slug(URL 标识符)使用小写字母、数字和连字符 + +## 更新规则 + +- 合并新旧信息,不要简单替换 +- 如果新信息与已有信息矛盾,明确标注矛盾点 +- 保留手动编辑的内容(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` 是一段话的简短摘要 \ No newline at end of file diff --git a/mateclaw-server/src/main/resources/prompts/wiki/digest-user.txt b/mateclaw-server/src/main/resources/prompts/wiki/digest-user.txt new file mode 100644 index 00000000..b65d7fb5 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/wiki/digest-user.txt @@ -0,0 +1,21 @@ +## 知识库处理规则 + +{config} + +## 已有 Wiki 页面索引 + +{existing_pages} + +## 待消化的原始材料 + +标题:{raw_title} + +{raw_content} + +--- + +请根据以上原始材料: +1. 创建新的 Wiki 页面(每个材料通常产生 5-15 个页面) +2. 如果已有页面与新材料相关,更新这些页面 +3. 确保页面间有充分的 [[双向链接]] +4. 每个页面聚焦单一主题,内容结构清晰 \ No newline at end of file diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 40c507d3..9105b3e7 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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`), +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index dd66eb2d..29a2a543 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index b66448c3..8a2fea57 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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 执行消息或目标任务', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index 1cf33ac6..0d12a963 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -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', diff --git a/mateclaw-ui/src/stores/useWikiStore.ts b/mateclaw-ui/src/stores/useWikiStore.ts new file mode 100644 index 00000000..bdd6e903 --- /dev/null +++ b/mateclaw-ui/src/stores/useWikiStore.ts @@ -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([]) + const currentKB = ref(null) + const rawMaterials = ref([]) + const pages = ref([]) + const currentPage = ref(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, + } +}) diff --git a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue new file mode 100644 index 00000000..2aa566e3 --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue b/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue new file mode 100644 index 00000000..f1b719b4 --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue b/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue new file mode 100644 index 00000000..429960a1 --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/mateclaw-ui/src/views/Wiki/index.vue b/mateclaw-ui/src/views/Wiki/index.vue new file mode 100644 index 00000000..903dd213 --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/index.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/mateclaw-ui/src/views/layout/MainLayout.vue b/mateclaw-ui/src/views/layout/MainLayout.vue index dd0d90e8..11873783 100644 --- a/mateclaw-ui/src/views/layout/MainLayout.vue +++ b/mateclaw-ui/src/views/layout/MainLayout.vue @@ -216,6 +216,11 @@ const navGroups = computed(() => [ label: t('nav.skills'), icon: ``, }, + { + path: '/wiki', + label: t('nav.wiki'), + icon: ``, + }, { path: '/tools', label: t('nav.tools'),