fix(wiki): backend hardening + batch delete UI

This commit is contained in:
matevip 2026-04-13 15:37:12 +08:00
parent e4679796b8
commit d5e3502829
16 changed files with 434 additions and 23 deletions

View File

@ -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 在解析原生消息时构建此列表

View File

@ -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();
}
}
}

View File

@ -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);
}

View File

@ -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)

View File

@ -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()); }
}
}

View File

@ -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) {

View File

@ -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()); }

View File

@ -110,6 +110,14 @@ public class TtsService {
});
}
/**
* 检查 TTS 功能是否全局启用 ChannelMessageRouter 等外部组件调用
*/
public boolean isTtsEnabled() {
SystemSettingsDTO config = systemSettingService.getSettings();
return Boolean.TRUE.equals(config.getTtsEnabled());
}
/**
* 获取当前配置是否开启自动 TTS
*/

View File

@ -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,

View File

@ -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();

View File

@ -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;
}

View File

@ -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()) {

View File

@ -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()

View File

@ -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',

View File

@ -1144,6 +1144,9 @@ export default {
selectPage: '从左侧选择一个页面查看',
pageKicker: '知识页面',
confirmDelete: '确认删除页面「{title}」?此操作不可撤销。',
confirmBatchDelete: '确认删除 {count} 个页面?此操作不可撤销。',
batchSelect: '批量选择',
selectAll: '全选',
kbName: '名称',
kbNamePlaceholder: '输入知识库名称',
kbDescription: '描述',

View File

@ -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 }} &middot; {{ 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 }} &middot; {{ 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); }