mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
fix(wiki): backend hardening + batch delete UI
This commit is contained in:
parent
e4679796b8
commit
d5e3502829
@ -42,6 +42,15 @@ public class ChannelMessage {
|
||||
/** 消息类型:text / image / file */
|
||||
private String contentType;
|
||||
|
||||
/**
|
||||
* 用户输入方式:text / voice / image / mixed
|
||||
* <p>
|
||||
* 各渠道 Adapter 在解析入站消息时设置。
|
||||
* Router 据此决定是否触发 TTS 语音回复、是否注入语音场景提示词。
|
||||
*/
|
||||
@Builder.Default
|
||||
private String inputMode = "text";
|
||||
|
||||
/**
|
||||
* 结构化消息内容(多模态)。
|
||||
* 各渠道 Adapter 在解析原生消息时构建此列表,
|
||||
|
||||
@ -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<MessageContentPart> 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<MessageContentPart> 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<MessageContentPart> parts) {
|
||||
private String buildPromptFromParts(String fallbackContent, List<MessageContentPart> 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 并通过渠道发送音频文件。
|
||||
* <p>
|
||||
* 设计原则(借鉴 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 <EFBFBD><EFBFBD>留完整 chatId(<EFBFBD><EFBFBD>送消息需要完整 ID)
|
||||
channelMessage.setReplyToken(chatId);
|
||||
onMessage(channelMessage);
|
||||
}
|
||||
|
||||
@ -394,6 +394,7 @@ public class TelegramChannelAdapter extends AbstractChannelAdapter {
|
||||
List<MessageContentPart> 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)
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送音频部分:读取字节 → 上传 → 发送
|
||||
* <p>
|
||||
* 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()); }
|
||||
}
|
||||
}
|
||||
|
||||
@ -562,6 +562,23 @@ public class ILinkClient {
|
||||
sendMessage(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送语音消息(以文件形式发送 MP3,用户可点击播放)
|
||||
* <p>
|
||||
* 注意: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) {
|
||||
|
||||
@ -264,6 +264,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
||||
// 解析消息内容
|
||||
List<MessageContentPart> contentParts = new ArrayList<>();
|
||||
List<String> textParts = new ArrayList<>();
|
||||
boolean hasVoice = false;
|
||||
|
||||
List<Map<String, Object>> itemList = (List<Map<String, Object>>) 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<String, Object> voiceItem = (Map<String, Object>) item.getOrDefault("voice_item", Map.of());
|
||||
Map<String, Object> voiceTextItem = (Map<String, Object>) 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()); }
|
||||
|
||||
@ -110,6 +110,14 @@ public class TtsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 TTS 功能是否全局启用(供 ChannelMessageRouter 等外部组件调用)
|
||||
*/
|
||||
public boolean isTtsEnabled() {
|
||||
SystemSettingsDTO config = systemSettingService.getSettings();
|
||||
return Boolean.TRUE.equals(config.getTtsEnabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置是否开启自动 TTS
|
||||
*/
|
||||
|
||||
@ -296,6 +296,7 @@ public class WikiController {
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "批量删除 Wiki 页面")
|
||||
@DeleteMapping("/knowledge-bases/{kbId}/pages/batch")
|
||||
public R<Integer> batchDeletePages(@PathVariable Long kbId,
|
||||
|
||||
@ -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("<wiki-relevant>\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("</wiki-relevant>");
|
||||
return sb.toString();
|
||||
|
||||
@ -39,6 +39,34 @@ public class WikiPageService {
|
||||
private final ConcurrentHashMap<Long, CachedSummaries> summaryCache = new ConcurrentHashMap<>();
|
||||
private static final long SUMMARY_CACHE_TTL_MS = 5 * 60_000; // 5 分钟
|
||||
|
||||
/** Agent 引用计数器(内存,不持久化,重启归零) */
|
||||
private final ConcurrentHashMap<String, java.util.concurrent.atomic.AtomicInteger> 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<ReferenceEntry> 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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<WikiRawMaterialEntity>()
|
||||
.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<WikiRawMaterialEntity>()
|
||||
.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()) {
|
||||
|
||||
@ -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<WikiPageEntity> 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()
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1144,6 +1144,9 @@ export default {
|
||||
selectPage: '从左侧选择一个页面查看',
|
||||
pageKicker: '知识页面',
|
||||
confirmDelete: '确认删除页面「{title}」?此操作不可撤销。',
|
||||
confirmBatchDelete: '确认删除 {count} 个页面?此操作不可撤销。',
|
||||
batchSelect: '批量选择',
|
||||
selectAll: '全选',
|
||||
kbName: '名称',
|
||||
kbNamePlaceholder: '输入知识库名称',
|
||||
kbDescription: '描述',
|
||||
|
||||
@ -43,24 +43,57 @@
|
||||
|
||||
<!-- Pages List when KB selected -->
|
||||
<div v-if="store.currentKB" class="sidebar-section sidebar-section--pages">
|
||||
<h3 class="sidebar-title">
|
||||
{{ t('wiki.pages') }}
|
||||
<span class="text-xs text-gray-400">({{ store.pages.length }})</span>
|
||||
</h3>
|
||||
<div class="sidebar-title-row">
|
||||
<h3 class="sidebar-title">
|
||||
{{ t('wiki.pages') }}
|
||||
<span class="text-xs text-gray-400">({{ store.pages.length }})</span>
|
||||
</h3>
|
||||
<button v-if="!batchMode" class="batch-toggle" @click="batchMode = true" :title="t('wiki.batchSelect')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
</button>
|
||||
<button v-else class="batch-toggle active" @click="exitBatchMode">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="pageSearch"
|
||||
type="text"
|
||||
:placeholder="t('wiki.searchPages')"
|
||||
class="sidebar-search"
|
||||
/>
|
||||
|
||||
<!-- Batch actions bar -->
|
||||
<div v-if="batchMode" class="batch-bar">
|
||||
<label class="batch-check-all">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll" />
|
||||
<span>{{ t('wiki.selectAll') }}</span>
|
||||
</label>
|
||||
<button
|
||||
class="batch-delete-btn"
|
||||
:disabled="selectedSlugs.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
{{ t('common.delete') }} ({{ selectedSlugs.length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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)"
|
||||
class="page-item" :class="{ active: !batchMode && store.currentPage?.slug === page.slug }"
|
||||
@click="batchMode ? toggleSelect(page.slug) : openPage(page.slug)"
|
||||
>
|
||||
<div class="page-item-title">{{ page.title }}</div>
|
||||
<div class="page-item-meta">v{{ page.version }} · {{ page.lastUpdatedBy }}</div>
|
||||
<input
|
||||
v-if="batchMode"
|
||||
type="checkbox"
|
||||
:checked="selectedSlugs.includes(page.slug)"
|
||||
class="page-checkbox"
|
||||
@click.stop="toggleSelect(page.slug)"
|
||||
/>
|
||||
<div class="page-item-body">
|
||||
<div class="page-item-title">{{ page.title }}</div>
|
||||
<div class="page-item-meta">v{{ page.version }} · {{ page.lastUpdatedBy }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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<string[]>([])
|
||||
|
||||
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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user