From d5e35028293df68d13fee35033eae1d127fcacd1 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 13 Apr 2026 15:37:12 +0800 Subject: [PATCH] fix(wiki): backend hardening + batch delete UI --- .../java/vip/mate/channel/ChannelMessage.java | 9 + .../mate/channel/ChannelMessageRouter.java | 160 +++++++++++++++++- .../channel/feishu/FeishuChannelAdapter.java | 3 +- .../telegram/TelegramChannelAdapter.java | 2 + .../channel/wecom/WeComChannelAdapter.java | 32 ++++ .../vip/mate/channel/weixin/ILinkClient.java | 17 ++ .../channel/weixin/WeixinChannelAdapter.java | 21 +++ .../main/java/vip/mate/tts/TtsService.java | 8 + .../mate/wiki/controller/WikiController.java | 1 + .../mate/wiki/service/WikiContextService.java | 16 +- .../mate/wiki/service/WikiPageService.java | 29 ++++ .../wiki/service/WikiRawMaterialService.java | 40 ++++- .../java/vip/mate/wiki/tool/WikiTool.java | 8 + mateclaw-ui/src/i18n/locales/en-US.ts | 3 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 3 + mateclaw-ui/src/views/Wiki/index.vue | 105 +++++++++++- 16 files changed, 434 insertions(+), 23 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessage.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessage.java index 505152c5..6f319db9 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessage.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessage.java @@ -42,6 +42,15 @@ public class ChannelMessage { /** 消息类型:text / image / file */ private String contentType; + /** + * 用户输入方式:text / voice / image / mixed + *

+ * 各渠道 Adapter 在解析入站消息时设置。 + * Router 据此决定是否触发 TTS 语音回复、是否注入语音场景提示词。 + */ + @Builder.Default + private String inputMode = "text"; + /** * 结构化消息内容(多模态)。 * 各渠道 Adapter 在解析原生消息时构建此列表, diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 9941c956..343a8580 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -11,9 +11,16 @@ import vip.mate.channel.notification.ApprovalNotificationService; import vip.mate.channel.service.ChannelService; import org.springframework.context.ApplicationEventPublisher; import vip.mate.memory.event.ConversationCompletedEvent; +import vip.mate.tts.TtsService; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageContentPart; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import java.util.Map; import java.util.concurrent.*; @@ -41,6 +48,8 @@ public class ChannelMessageRouter { private final ApprovalService approvalService; private final ApprovalNotificationService approvalNotificationService; private final ApplicationEventPublisher eventPublisher; + private final TtsService ttsService; + private final ObjectMapper objectMapper; /** 队列条目:封装消息及其路由上下文 */ private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {} @@ -82,7 +91,9 @@ public class ChannelMessageRouter { ChannelSessionStore channelSessionStore, ApprovalService approvalService, ApprovalNotificationService approvalNotificationService, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + TtsService ttsService, + ObjectMapper objectMapper) { this.agentService = agentService; this.conversationService = conversationService; this.channelService = channelService; @@ -90,6 +101,8 @@ public class ChannelMessageRouter { this.approvalService = approvalService; this.approvalNotificationService = approvalNotificationService; this.eventPublisher = eventPublisher; + this.ttsService = ttsService; + this.objectMapper = objectMapper; } // ==================== 防抖辅助类 ==================== @@ -401,12 +414,12 @@ public class ChannelMessageRouter { List parts = message.getContentParts(); conversationService.saveMessage(conversationId, "user", message.getContent(), parts); - // 构建 prompt - String promptText = buildPromptFromParts(message.getContent(), parts); + // 构建 prompt(语音输入时注入场景提示词) + String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode()); // 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件 if (adapter instanceof StreamingChannelAdapter streamingAdapter) { - processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText); + processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity); } else { // 同步路径:直接获取完整回复 String reply = agentService.chat(agentId, promptText, conversationId); @@ -426,6 +439,9 @@ public class ChannelMessageRouter { adapter.renderAndSend(replyTarget, reply); log.info("[{}] Reply sent to {}: {}chars", adapter.getChannelType(), replyTarget, reply.length()); + + // 语音回复:异步 TTS 合成并追加发送(先文本后语音,不阻塞) + maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity); } } @@ -453,7 +469,8 @@ public class ChannelMessageRouter { * - Router 负责后续的审批检查、消息持久化、事件发布 */ private void processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter, - String conversationId, Long agentId, String promptText) { + String conversationId, Long agentId, String promptText, + ChannelEntity channelEntity) { String channelType = streamingAdapter.getChannelType(); log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId); @@ -476,6 +493,13 @@ public class ChannelMessageRouter { conversationService.saveMessage(conversationId, "assistant", finalContent); publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent); log.info("[{}] Streaming completed: contentLen={}", channelType, finalContent.length()); + + // 流式回复完成后也触发语音回复 + String replyTarget = resolveReplyTarget(message); + if (replyTarget != null) { + maybeGenerateVoiceReply(message, streamingAdapter, replyTarget, + conversationId, finalContent, channelEntity); + } } } catch (Exception e) { @@ -570,7 +594,7 @@ public class ChannelMessageRouter { List parts = message.getContentParts(); conversationService.saveMessage(conversationId, "user", message.getContent(), parts); - String promptText = buildPromptFromParts(message.getContent(), parts); + String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode()); return agentService.chatStream(agentId, promptText, conversationId); } @@ -615,6 +639,10 @@ public class ChannelMessageRouter { channelExecutors.clear(); channelQueues.clear(); sessionLocks.clear(); + + // 4. 关闭语音回复线程池 + voiceReplyExecutor.shutdownNow(); + log.info("ChannelMessageRouter shutdown complete"); } @@ -644,8 +672,9 @@ public class ChannelMessageRouter { /** * 从 contentParts 构建完整 prompt 文本。 * 文本直接拼接;媒体类型生成描述性占位符,让 Agent 知道用户发送了什么。 + * 语音输入时注入场景提示词,引导 Agent 用简短口语化方式回复。 */ - private String buildPromptFromParts(String fallbackContent, List parts) { + private String buildPromptFromParts(String fallbackContent, List parts, String inputMode) { if (parts == null || parts.isEmpty()) { return fallbackContent != null ? fallbackContent : ""; } @@ -662,7 +691,14 @@ public class ChannelMessageRouter { } } String result = sb.toString().trim(); - return result.isEmpty() ? (fallbackContent != null ? fallbackContent : "") : result; + if (result.isEmpty()) { + return fallbackContent != null ? fallbackContent : ""; + } + // 语音场景:注入提示词让 Agent 回复更简短口语化 + if ("voice".equals(inputMode)) { + result = "[用户通过语音输入,请用简短口语化的方式回复]\n" + result; + } + return result; } private void appendLine(StringBuilder sb, String text) { @@ -681,4 +717,112 @@ public class ChannelMessageRouter { private String safe(String s) { return s == null ? "" : s; } + + // ==================== 语音回复(TTS)==================== + + /** TTS 异步工作线程池 */ + private final ExecutorService voiceReplyExecutor = Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r, "voice-reply-worker"); + t.setDaemon(true); + return t; + }); + + /** + * 根据消息上下文和渠道配置,判断是否需要生成语音回复。 + * 若需要,异步合成 TTS 并通过渠道发送音频文件。 + *

+ * 设计原则(借鉴 OpenClaw): + * - 文本先行,语音异步追加,不阻塞用户体验 + * - 短回复(<10字)跳过 TTS(不值得合成) + * - TTS 失败静默降级,不影响已发出的文本回复 + */ + private void maybeGenerateVoiceReply(ChannelMessage message, ChannelAdapter adapter, + String replyTarget, String conversationId, + String replyText, ChannelEntity channelEntity) { + if (!shouldGenerateVoiceReply(message, channelEntity, replyText)) { + return; + } + + voiceReplyExecutor.submit(() -> { + try { + // 读取渠道级语音配置 + Map channelConfig = parseChannelConfig(channelEntity.getConfigJson()); + String voiceName = channelConfig.getOrDefault("voice_name", "").toString(); + double voiceSpeed = 1.0; + Object speedObj = channelConfig.get("voice_speed"); + if (speedObj instanceof Number n) voiceSpeed = n.doubleValue(); + + // 调用 TtsService 合成 + Map result = ttsService.synthesize( + conversationId, replyText, + voiceName.isBlank() ? null : voiceName, + voiceSpeed, "mp3"); + + if (!Boolean.TRUE.equals(result.get("success"))) { + log.debug("[voice-reply] TTS synthesis failed: {}", result.get("error")); + return; + } + + // 构建音频 MessageContentPart + String audioUrl = (String) result.get("audioUrl"); + String fileName = Paths.get(audioUrl).getFileName().toString(); + Path audioPath = Paths.get("data", "chat-uploads", conversationId, fileName); + + if (!Files.exists(audioPath)) { + log.warn("[voice-reply] TTS output file not found: {}", audioPath); + return; + } + + MessageContentPart audioPart = new MessageContentPart(); + audioPart.setType("audio"); + audioPart.setFileName(fileName); + audioPart.setPath(audioPath.toString()); + audioPart.setContentType("audio/mpeg"); + + adapter.sendContentParts(replyTarget, List.of(audioPart)); + log.info("[voice-reply] Sent to {} via {}: {} ({}KB)", + replyTarget, adapter.getChannelType(), fileName, + Files.size(audioPath) / 1024); + + } catch (Exception e) { + // 静默降级:TTS 失败不影响已发出的文本回复 + log.warn("[voice-reply] Failed for conversation {}: {}", conversationId, e.getMessage()); + } + }); + } + + /** + * 判断是否需要为此消息生成语音回复 + */ + private boolean shouldGenerateVoiceReply(ChannelMessage message, ChannelEntity channelEntity, + String replyText) { + // 1. TTS 全局开关 + if (!ttsService.isTtsEnabled()) return false; + + // 2. 回复内容过短或为空,跳过 TTS(借鉴 OpenClaw: <10字不合成) + if (replyText == null || replyText.trim().length() < 10) return false; + + // 3. 读取渠道级语音回复模式 + Map channelConfig = parseChannelConfig(channelEntity.getConfigJson()); + String voiceMode = channelConfig.getOrDefault("voice_reply_mode", "off").toString(); + + if ("off".equals(voiceMode)) return false; + if ("always".equals(voiceMode)) return true; + + // 4. auto 模式:仅当用户通过语音输入时回语音 + return "auto".equals(voiceMode) && "voice".equals(message.getInputMode()); + } + + /** + * 解析 Channel 的 configJson 为 Map + */ + private Map parseChannelConfig(String configJson) { + if (configJson == null || configJson.isBlank()) return Map.of(); + try { + return objectMapper.readValue(configJson, new TypeReference<>() {}); + } catch (Exception e) { + log.debug("[voice-reply] Failed to parse channel config: {}", e.getMessage()); + return Map.of(); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 4ddd4434..eb63fd73 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -485,11 +485,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { .content(textContent != null ? textContent : "") .contentType(messageType) .contentParts(contentParts) + .inputMode("audio".equals(messageType) ? "voice" : "text") .timestamp(LocalDateTime.now()) .rawPayload(rawPayload) .build(); - // replyToken 保留完整 chatId(发送消息需要完整 ID) + // replyToken ��留完整 chatId(��送消息需要完整 ID) channelMessage.setReplyToken(chatId); onMessage(channelMessage); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java index aa79a5d6..2932a3dd 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/telegram/TelegramChannelAdapter.java @@ -394,6 +394,7 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter { List contentParts = new ArrayList<>(); String textContent = (String) message.get("text"); String caption = (String) message.get("caption"); + boolean hasVoice = message.get("voice") != null; if (textContent != null && !textContent.isBlank()) { contentParts.add(MessageContentPart.text(textContent)); @@ -461,6 +462,7 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter { .content(textContent != null ? textContent : "") .contentType(determineContentType(contentParts)) .contentParts(contentParts) + .inputMode(hasVoice ? "voice" : "text") .timestamp(LocalDateTime.now()) .replyToken(chatId) .rawPayload(update) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index b9ddbf45..68916e96 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -770,6 +770,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { } } case "image" -> sendImagePart(targetId, part, ctx); + case "audio" -> sendAudioPart(targetId, part, ctx); case "file" -> sendFilePart(targetId, part, ctx); default -> { if (part.getText() != null) { @@ -837,6 +838,36 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { } } + /** + * 发送音频部分:读取字节 → 上传 → 发送 + *

+ * WeCom 原生语音消息要求 AMR 格式。TTS 输出为 MP3, + * Phase 1 以 file 类型发送(用户可点击播放),避免引入 AMR 转码依赖。 + * 借鉴 CoPaw: 非 AMR 格式走 file 类型而非 voice 类型。 + */ + private void sendAudioPart(String targetId, MessageContentPart part, WeComReplyContext ctx) { + byte[] audioBytes = resolveFileBytes(part); + if (audioBytes == null) { + sendFallbackText(targetId, part); + return; + } + + String fileName = part.getFileName() != null ? part.getFileName() : "voice_reply.mp3"; + boolean isAmr = fileName.toLowerCase().endsWith(".amr"); + + // AMR 格式:以原生 voice 类型发送(语音气泡) + // 其他格式(MP3 等):以 file 类型发送(文件卡片,可点击播放) + String uploadType = isAmr ? "voice" : "file"; + String mediaId = uploadMedia(audioBytes, fileName, uploadType); + if (mediaId != null) { + String frameReqId = ctx != null ? ctx.frameReqId() : null; + sendMediaMessage(targetId, mediaId, uploadType, frameReqId); + log.info("[wecom] Audio sent as {}: {} ({}KB)", uploadType, fileName, audioBytes.length / 1024); + } else { + sendFallbackText(targetId, part); + } + } + /** * 从 MessageContentPart 解析文件字节:优先本地路径,其次 URL 下载 */ @@ -885,6 +916,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { String name = part.getFileName() != null ? part.getFileName() : "file"; sendMessage(targetId, "[文件: " + name + "]"); } + case "audio" -> sendMessage(targetId, "[语音回复]"); default -> { if (part.getText() != null) sendMessage(targetId, part.getText()); } } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java index 45e4be32..a10b1b76 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java @@ -562,6 +562,23 @@ public class ILinkClient { sendMessage(msg); } + /** + * 发送语音消息(以文件形式发送 MP3,用户可点击播放) + *

+ * 注意:iLink Bot API 的语音发送接口(mediaType=4, item type=3)尚未经过完全验证。 + * 若原生语音发送失败,自动降级为 sendFile() 以文件形式发送。 + * + * @param toUserId 收件人 ID + * @param voiceBytes 音频字节(MP3 格式) + * @param fileName 文件名(如 "reply.mp3") + * @param contextToken 上下文 token + */ + public void sendVoice(String toUserId, byte[] voiceBytes, String fileName, String contextToken) throws Exception { + // 降级策略:直接以文件形式发送 MP3(可靠性最高) + // 后续验证 iLink API 的原生语音接口后,可改为 voice item type=3 + sendFile(toUserId, voiceBytes, fileName, contextToken); + } + // ==================== 工具方法 ==================== private static String md5Hex(byte[] input) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java index 1ba10b6a..4f895ad8 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java @@ -264,6 +264,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { // 解析消息内容 List contentParts = new ArrayList<>(); List textParts = new ArrayList<>(); + boolean hasVoice = false; List> itemList = (List>) msg.getOrDefault("item_list", List.of()); boolean mediaDownloadEnabled = getConfigBoolean("media_download_enabled", true); @@ -312,6 +313,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { } case 3 -> { // Voice — 使用 ASR 语音识别文本 + hasVoice = true; Map voiceItem = (Map) item.getOrDefault("voice_item", Map.of()); Map voiceTextItem = (Map) voiceItem.getOrDefault("text_item", Map.of()); String asrText = getStr(voiceTextItem, "text").strip(); @@ -401,6 +403,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { .content(textContent) .contentType(contentParts.size() == 1 && "text".equals(contentParts.getFirst().getType()) ? "text" : "mixed") .contentParts(contentParts) + .inputMode(hasVoice ? "voice" : "text") .timestamp(LocalDateTime.now()) .replyToken(replyToken) .rawPayload(msg) @@ -511,6 +514,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { } } case "image" -> sendImagePart(toUserId, contextToken, part); + case "audio" -> sendAudioPart(toUserId, contextToken, part); case "file" -> sendFilePart(toUserId, contextToken, part); case "video" -> sendVideoPart(toUserId, contextToken, part); default -> { @@ -582,6 +586,22 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { log.info("[weixin] Video sent to {}: {}bytes", toUserId.substring(0, Math.min(12, toUserId.length())), videoBytes.length); } + /** + * 发送音频部分:以文件形式发送 MP3(用户可点击播放) + */ + private void sendAudioPart(String toUserId, String contextToken, MessageContentPart part) throws Exception { + byte[] audioBytes = resolveFileBytes(part); + if (audioBytes == null) { + sendFallbackText(contextToken + "|" + toUserId, part); + return; + } + String fileName = part.getFileName() != null ? part.getFileName() : "voice_reply.mp3"; + client.sendVoice(toUserId, audioBytes, fileName, contextToken); + log.info("[weixin] Audio sent to {}: {} ({}KB)", + toUserId.substring(0, Math.min(12, toUserId.length())), + fileName, audioBytes.length / 1024); + } + /** * 从 MessageContentPart 解析文件字节:优先本地路径,其次 URL 下载 */ @@ -617,6 +637,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { private void sendFallbackText(String targetId, MessageContentPart part) { switch (part.getType()) { case "image" -> sendMessage(targetId, "[图片]"); + case "audio" -> sendMessage(targetId, "[语音回复]"); case "file" -> sendMessage(targetId, "[文件: " + (part.getFileName() != null ? part.getFileName() : "file") + "]"); case "video" -> sendMessage(targetId, "[视频]"); default -> { if (part.getText() != null) sendMessage(targetId, part.getText()); } diff --git a/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java b/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java index 912b4668..b5f91520 100644 --- a/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java +++ b/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java @@ -110,6 +110,14 @@ public class TtsService { }); } + /** + * 检查 TTS 功能是否全局启用(供 ChannelMessageRouter 等外部组件调用) + */ + public boolean isTtsEnabled() { + SystemSettingsDTO config = systemSettingService.getSettings(); + return Boolean.TRUE.equals(config.getTtsEnabled()); + } + /** * 获取当前配置是否开启自动 TTS */ diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 5699ce7f..8e3bb318 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -296,6 +296,7 @@ public class WikiController { return R.ok(); } + @RequireWorkspaceRole("admin") @Operation(summary = "批量删除 Wiki 页面") @DeleteMapping("/knowledge-bases/{kbId}/pages/batch") public R batchDeletePages(@PathVariable Long kbId, diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java index 34662641..819b5825 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java @@ -80,20 +80,28 @@ public class WikiContextService { return ""; } - // 取 top-5 最相关页面,只注入摘要(不注入全文) + // 取 top-5 最相关页面,只注入摘要(不注入全文),受 token 预算限制 scored.sort((a, b) -> Integer.compare(b.score, a.score)); int topN = Math.min(5, scored.size()); + int maxChars = properties.getMaxContextChars(); + int totalChars = 0; StringBuilder sb = new StringBuilder(); sb.append("\n"); sb.append("[Relevant wiki pages for this query. Use wiki_read_page(slug) for full content.]\n\n"); for (int i = 0; i < topN; i++) { WikiPageEntity page = scored.get(i).page; - sb.append("- **").append(page.getTitle()).append("** (`").append(page.getSlug()).append("`)"); + String line = "- **" + page.getTitle() + "** (`" + page.getSlug() + "`)"; if (page.getSummary() != null && !page.getSummary().isBlank()) { - sb.append(" — ").append(page.getSummary()); + line += " — " + page.getSummary(); } - sb.append("\n"); + line += "\n"; + if (totalChars + line.length() > maxChars) { + sb.append("- ... (use wiki_search_pages for more)\n"); + break; + } + sb.append(line); + totalChars += line.length(); } sb.append(""); return sb.toString(); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index 130f69f7..154bdb5e 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -39,6 +39,34 @@ public class WikiPageService { private final ConcurrentHashMap summaryCache = new ConcurrentHashMap<>(); private static final long SUMMARY_CACHE_TTL_MS = 5 * 60_000; // 5 分钟 + /** Agent 引用计数器(内存,不持久化,重启归零) */ + private final ConcurrentHashMap refCounter = new ConcurrentHashMap<>(); + + /** 记录 Agent 引用(WikiTool 调用时触发) */ + public void trackReference(Long kbId, String slug) { + refCounter.computeIfAbsent(kbId + ":" + slug, k -> new java.util.concurrent.atomic.AtomicInteger(0)) + .incrementAndGet(); + } + + /** Agent 引用记录 */ + public record ReferenceEntry(String slug, String title, int refCount) {} + + /** 获取被引用最多的页面 Top N */ + public List getTopReferenced(Long kbId, int limit) { + String prefix = kbId + ":"; + return refCounter.entrySet().stream() + .filter(e -> e.getKey().startsWith(prefix)) + .sorted((a, b) -> Integer.compare(b.getValue().get(), a.getValue().get())) + .limit(limit) + .map(e -> { + String slug = e.getKey().substring(prefix.length()); + WikiPageEntity page = getBySlug(kbId, slug); + String title = page != null ? page.getTitle() : slug; + return new ReferenceEntry(slug, title, e.getValue().get()); + }) + .toList(); + } + /** * 列出知识库的所有页面(不含 content) */ @@ -198,6 +226,7 @@ public class WikiPageService { existing.setSummary(extractFirstParagraph(content)); } pageMapper.updateById(existing); + evictSummaryCache(kbId); return existing; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java index 0a1bf343..a81e57f6 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java @@ -71,13 +71,27 @@ public class WikiRawMaterialService { */ @Transactional public WikiRawMaterialEntity addText(Long kbId, String title, String content) { + String hash = computeHash(content); + + // 去重:相同 hash 且已处理过的材料直接返回 + WikiRawMaterialEntity existing = rawMapper.selectOne( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getKbId, kbId) + .eq(WikiRawMaterialEntity::getContentHash, hash) + .eq(WikiRawMaterialEntity::getProcessingStatus, "completed") + .last("LIMIT 1")); + if (existing != null) { + log.info("[Wiki] Duplicate text detected (hash={}), returning existing id={}", hash, existing.getId()); + return existing; + } + 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.setContentHash(hash); entity.setProcessingStatus("pending"); rawMapper.insert(entity); @@ -104,8 +118,30 @@ public class WikiRawMaterialService { entity.setSourcePath(sourcePath); entity.setFileSize(fileSize); entity.setProcessingStatus("pending"); - rawMapper.insert(entity); + // 计算文件内容 hash(用于上传去重) + try { + byte[] bytes = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(sourcePath)); + entity.setContentHash(computeHash(new String(bytes, java.nio.charset.StandardCharsets.UTF_8))); + } catch (Exception e) { + log.warn("[Wiki] Could not compute file hash for dedup: {}", e.getMessage()); + } + + // 去重:相同 hash 且已处理过的材料直接返回已有记录 + if (entity.getContentHash() != null) { + WikiRawMaterialEntity existing = rawMapper.selectOne( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getKbId, kbId) + .eq(WikiRawMaterialEntity::getContentHash, entity.getContentHash()) + .eq(WikiRawMaterialEntity::getProcessingStatus, "completed") + .last("LIMIT 1")); + if (existing != null) { + log.info("[Wiki] Duplicate file detected (hash={}), returning existing id={}", entity.getContentHash(), existing.getId()); + return existing; + } + } + + rawMapper.insert(entity); kbService.incrementRawCount(kbId); if (properties.isAutoProcessOnUpload()) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index 1c0a362c..dbdcb921 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -59,6 +59,9 @@ public class WikiTool { return error("Page not found: " + slug); } + // Agent 引用追踪 + pageService.trackReference(kbId, slug); + JSONObject result = JSONUtil.createObj() .set("title", page.getTitle()) .set("slug", page.getSlug()) @@ -117,6 +120,11 @@ public class WikiTool { // DB 级别搜索(不加载 content CLOB 到 Java 内存) List matched = pageService.searchPages(kbId, query); + // Agent 引用追踪(搜索结果中的页面都算被引用) + for (WikiPageEntity p : matched) { + pageService.trackReference(kbId, p.getSlug()); + } + JSONArray arr = new JSONArray(); for (WikiPageEntity page : matched) { JSONObject obj = JSONUtil.createObj() diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 0838d63c..518e6d20 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1134,6 +1134,9 @@ export default { selectPage: 'Select a page from the sidebar', pageKicker: 'Knowledge Page', confirmDelete: 'Delete page "{title}"? This cannot be undone.', + confirmBatchDelete: 'Delete {count} pages? This cannot be undone.', + batchSelect: 'Batch select', + selectAll: 'Select all', kbName: 'Name', kbNamePlaceholder: 'Enter knowledge base name', kbDescription: 'Description', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 79a8709e..5f1db64e 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1144,6 +1144,9 @@ export default { selectPage: '从左侧选择一个页面查看', pageKicker: '知识页面', confirmDelete: '确认删除页面「{title}」?此操作不可撤销。', + confirmBatchDelete: '确认删除 {count} 个页面?此操作不可撤销。', + batchSelect: '批量选择', + selectAll: '全选', kbName: '名称', kbNamePlaceholder: '输入知识库名称', kbDescription: '描述', diff --git a/mateclaw-ui/src/views/Wiki/index.vue b/mateclaw-ui/src/views/Wiki/index.vue index 6ef00548..f553e1c0 100644 --- a/mateclaw-ui/src/views/Wiki/index.vue +++ b/mateclaw-ui/src/views/Wiki/index.vue @@ -43,24 +43,57 @@

@@ -136,6 +169,7 @@ import { ref, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useWikiStore } from '@/stores/useWikiStore' +import { wikiApi } from '@/api/index' import RawMaterialPanel from './components/RawMaterialPanel.vue' import WikiPageViewer from './components/WikiPageViewer.vue' import WikiConfig from './components/WikiConfig.vue' @@ -149,6 +183,46 @@ const newKBDesc = ref('') const activeTab = ref('raw') const pageSearch = ref('') +// Batch selection +const batchMode = ref(false) +const selectedSlugs = ref([]) + +const allSelected = computed(() => + filteredPages.value.length > 0 && selectedSlugs.value.length === filteredPages.value.length +) + +function toggleSelect(slug: string) { + const idx = selectedSlugs.value.indexOf(slug) + if (idx >= 0) selectedSlugs.value.splice(idx, 1) + else selectedSlugs.value.push(slug) +} + +function toggleSelectAll() { + if (allSelected.value) { + selectedSlugs.value = [] + } else { + selectedSlugs.value = filteredPages.value.map(p => p.slug) + } +} + +function exitBatchMode() { + batchMode.value = false + selectedSlugs.value = [] +} + +async function handleBatchDelete() { + if (selectedSlugs.value.length === 0 || !store.currentKB) return + const confirmed = confirm(t('wiki.confirmBatchDelete', { count: selectedSlugs.value.length })) + if (!confirmed) return + try { + await wikiApi.batchDeletePages(store.currentKB.id, selectedSlugs.value) + exitBatchMode() + await store.fetchPages(store.currentKB.id) + } catch (e: any) { + alert(e?.message || 'Batch delete failed') + } +} + const tabs = computed(() => [ { key: 'raw', label: t('wiki.rawMaterials') }, { key: 'pages', label: t('wiki.pages') }, @@ -234,6 +308,21 @@ onMounted(() => { .kb-item-name { font-size: 14px; font-weight: 700; color: var(--mc-text-primary); margin-bottom: 4px; } .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); font-weight: 600; } +.page-item-body { flex: 1; min-width: 0; } + +/* Batch mode */ +.sidebar-title-row { display: flex; justify-content: space-between; align-items: center; } +.batch-toggle { padding: 4px 8px; border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-elevated); color: var(--mc-text-secondary); cursor: pointer; font-size: 11px; display: flex; align-items: center; gap: 4px; } +.batch-toggle:hover { background: var(--mc-bg-sunken); } +.batch-toggle.active { color: var(--mc-primary); border-color: var(--mc-primary); } +.batch-bar { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; background: var(--mc-bg-muted); border-radius: 10px; } +.batch-check-all { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--mc-text-secondary); cursor: pointer; } +.batch-check-all input { cursor: pointer; } +.batch-delete-btn { padding: 4px 12px; border: none; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; background: var(--el-color-danger-light-9, #fef0f0); color: var(--el-color-danger, #f56c6c); } +.batch-delete-btn:hover:not(:disabled) { background: var(--el-color-danger-light-7, #fab6b6); } +.batch-delete-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.page-checkbox { flex-shrink: 0; cursor: pointer; margin-right: 8px; } +.page-item { display: flex; align-items: center; } .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); }