mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(wiki): add LLM Wiki knowledge base system
This commit is contained in:
parent
7007ea844d
commit
642360a773
@ -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;
|
||||
}
|
||||
|
||||
// ==================== 模型选项构建 ====================
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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 "";
|
||||
}
|
||||
}
|
||||
@ -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() : "";
|
||||
}
|
||||
}
|
||||
@ -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 + 公共 KB(agent_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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将标题转换为 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<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 "[]";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
157
mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java
Normal file
157
mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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` 是一段话的简短摘要
|
||||
@ -0,0 +1,21 @@
|
||||
## 知识库处理规则
|
||||
|
||||
{config}
|
||||
|
||||
## 已有 Wiki 页面索引
|
||||
|
||||
{existing_pages}
|
||||
|
||||
## 待消化的原始材料
|
||||
|
||||
标题:{raw_title}
|
||||
|
||||
{raw_content}
|
||||
|
||||
---
|
||||
|
||||
请根据以上原始材料:
|
||||
1. 创建新的 Wiki 页面(每个材料通常产生 5-15 个页面)
|
||||
2. 如果已有页面与新材料相关,更新这些页面
|
||||
3. 确保页面间有充分的 [[双向链接]]
|
||||
4. 每个页面聚焦单一主题,内容结构清晰
|
||||
@ -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`),
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 执行消息或目标任务',
|
||||
|
||||
@ -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',
|
||||
|
||||
146
mateclaw-ui/src/stores/useWikiStore.ts
Normal file
146
mateclaw-ui/src/stores/useWikiStore.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
284
mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue
Normal file
284
mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue
Normal 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>
|
||||
82
mateclaw-ui/src/views/Wiki/components/WikiConfig.vue
Normal file
82
mateclaw-ui/src/views/Wiki/components/WikiConfig.vue
Normal 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>
|
||||
171
mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue
Normal file
171
mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue
Normal 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>·</span>
|
||||
<span>{{ store.currentPage.lastUpdatedBy === 'ai' ? 'AI generated' : 'Manually edited' }}</span>
|
||||
<span>·</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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 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>
|
||||
248
mateclaw-ui/src/views/Wiki/index.vue
Normal file
248
mateclaw-ui/src/views/Wiki/index.vue
Normal 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 }} · {{ 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>
|
||||
@ -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'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user