diff --git a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java index b1977fd..d450043 100644 --- a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java +++ b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java @@ -6,7 +6,10 @@ import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions; import com.wok.supportbot.entity.AiModelConfig; import com.wok.supportbot.service.AiModelConfigService; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.model.tool.ToolCallingManager; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatOptions; @@ -14,8 +17,12 @@ import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; /** * ChatModel 工厂 @@ -36,7 +43,7 @@ public class ChatModelFactory { private ToolCallingManager toolCallingManager; /** - * ChatModel 缓存:key = "provider:apiKey:modelName:baseUrl" + * ChatModel 缓存:key = "configId:provider:keyHint:modelName"(不含明文 API Key) */ private final ConcurrentHashMap chatModelCache = new ConcurrentHashMap<>(); @@ -81,11 +88,12 @@ public class ChatModelFactory { /** * 获取或创建 ChatModel(带缓存) - * 缓存 key = provider:apiKey:modelName:baseUrl,配置不变则复用实例 + * 缓存 key = configId:provider:keyHint:modelName,避免明文 API Key 出现在缓存键中 */ private ChatModel getOrCreateChatModel(AiModelConfig config) { String baseUrl = resolveBaseUrl(config); - String cacheKey = config.getProvider() + ":" + config.getApiKey() + ":" + config.getModelName() + ":" + baseUrl; + String keyHint = config.getApiKey() != null ? String.valueOf(config.getApiKey().hashCode()) : "null"; + String cacheKey = config.getId() + ":" + config.getProvider() + ":" + keyHint + ":" + config.getModelName(); return chatModelCache.computeIfAbsent(cacheKey, k -> createChatModel(config, baseUrl)); } @@ -132,6 +140,8 @@ public class ChatModelFactory { if (config.getMaxTokens() != null) { optionsBuilder.withMaxToken(config.getMaxTokens()); } + // F3: 应用高级参数(topP、topK、stopSequences) + applyDashScopeExtraConfig(config, optionsBuilder); return DashScopeChatModel.builder() .dashScopeApi(api) @@ -162,6 +172,8 @@ public class ChatModelFactory { if (config.getMaxTokens() != null) { optionsBuilder.maxTokens(config.getMaxTokens()); } + // F3: 应用高级参数(topP、frequencyPenalty、presencePenalty、stopSequences) + applyExtraConfig(config, optionsBuilder); return OpenAiChatModel.builder() .openAiApi(api) @@ -219,4 +231,146 @@ public class ChatModelFactory { + "并为 CHAT 类型选择一个对话模型(如 doubao-pro、doubao-lite 等)"); } } + + // ==================== F1: 连接测试 ==================== + + /** 连接测试超时时间(秒) */ + private static final int TEST_CONNECTION_TIMEOUT_SECONDS = 10; + + /** + * 测试指定配置的连接可用性 + * 创建临时 ChatModel(不走缓存),发送一条简单 prompt 验证 API Key 和模型是否可用 + * 带超时控制,防止网络异常时长时间阻塞 + * + * @param config 待测试的配置(需含完整 API Key) + * @return 测试结果:success / latencyMs / errorMessage + */ + public Map testConnection(AiModelConfig config) { + try { + CompletableFuture> future = CompletableFuture.supplyAsync(() -> { + Map result = new LinkedHashMap<>(); + long start = System.currentTimeMillis(); + try { + String baseUrl = resolveBaseUrl(config); + ChatModel tempModel = createChatModel(config, baseUrl); + // 发送最简短的 prompt 验证连接 + Prompt prompt = new Prompt(new UserMessage("hi")); + ChatResponse response = tempModel.call(prompt); + long latency = System.currentTimeMillis() - start; + result.put("success", true); + result.put("latencyMs", latency); + // 返回模型实际响应内容摘要(取前 50 字符) + String content = response.getResult() != null && response.getResult().getOutput() != null + ? response.getResult().getOutput().getText() : ""; + result.put("response", content != null && content.length() > 50 ? content.substring(0, 50) + "..." : content); + } catch (Exception e) { + long latency = System.currentTimeMillis() - start; + result.put("success", false); + result.put("latencyMs", latency); + result.put("errorMessage", e.getMessage()); + } + return result; + }); + return future.get(TEST_CONNECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + Map result = new LinkedHashMap<>(); + result.put("success", false); + result.put("latencyMs", TEST_CONNECTION_TIMEOUT_SECONDS * 1000L); + result.put("errorMessage", "连接测试超时(" + TEST_CONNECTION_TIMEOUT_SECONDS + "s)"); + return result; + } catch (Exception e) { + Map result = new LinkedHashMap<>(); + result.put("success", false); + result.put("latencyMs", 0L); + result.put("errorMessage", e.getMessage()); + return result; + } + } + + // ==================== F3: 高级参数读取 ==================== + + /** + * 从 extraConfig 中读取高级生成参数并设置到 OpenAiChatOptions.Builder + * 支持的参数:topP、frequencyPenalty、presencePenalty、stopSequences + */ + private void applyExtraConfig(AiModelConfig config, OpenAiChatOptions.Builder optionsBuilder) { + Map extra = config.getExtraConfig(); + if (extra == null || extra.isEmpty()) return; + + // topP(核采样概率,0~1) + Object topP = extra.get("topP"); + if (topP instanceof Number) { + optionsBuilder.topP(((Number) topP).doubleValue()); + } + // frequencyPenalty(频率惩罚,-2~2) + Object freqPenalty = extra.get("frequencyPenalty"); + if (freqPenalty instanceof Number) { + optionsBuilder.frequencyPenalty(((Number) freqPenalty).doubleValue()); + } + // presencePenalty(存在惩罚,-2~2) + Object presPenalty = extra.get("presencePenalty"); + if (presPenalty instanceof Number) { + optionsBuilder.presencePenalty(((Number) presPenalty).doubleValue()); + } + // stopSequences(停止词序列,最多 4 个) + Object stopSeq = extra.get("stopSequences"); + if (stopSeq instanceof String stopStr && !stopStr.isBlank()) { + List stops = List.of(stopStr.split(",")); + optionsBuilder.stop(stops.stream().map(String::trim).filter(s -> !s.isEmpty()).limit(4).toList()); + } + } + + /** + * 从 extraConfig 中读取高级参数并设置到 DashScopeChatOptions.Builder + */ + private void applyDashScopeExtraConfig(AiModelConfig config, DashScopeChatOptions.DashscopeChatOptionsBuilder optionsBuilder) { + Map extra = config.getExtraConfig(); + if (extra == null || extra.isEmpty()) return; + + // topP + Object topP = extra.get("topP"); + if (topP instanceof Number) { + optionsBuilder.withTopP(((Number) topP).doubleValue()); + } + // topK + Object topK = extra.get("topK"); + if (topK instanceof Number) { + optionsBuilder.withTopK(((Number) topK).intValue()); + } + // stopSequences + Object stopSeq = extra.get("stopSequences"); + if (stopSeq instanceof String stopStr && !stopStr.isBlank()) { + List stops = java.util.Arrays.stream(stopStr.split(",")) + .map(String::trim).filter(s -> !s.isEmpty()).limit(4) + .map(s -> (Object) s).toList(); + optionsBuilder.withStop(stops); + } + } + + // ==================== F6: Fallback 故障自动切换 ==================== + + /** 共享熔断器:连续 3 次失败触发,5 分钟自动恢复 */ + private final SimpleCircuitBreaker circuitBreaker = + new SimpleCircuitBreaker(3, 5 * 60 * 1000L, ""); + + /** + * 检查指定配置是否处于熔断状态 + */ + public boolean isCircuitOpen(Long configId) { + return circuitBreaker.isOpen(configId); + } + + /** + * 记录一次调用失败,连续失败达到阈值时触发熔断 + */ + public void recordFailure(Long configId) { + circuitBreaker.recordFailure(configId); + } + + /** + * 记录一次调用成功,重置失败计数和熔断状态 + */ + public void recordSuccess(Long configId) { + circuitBreaker.recordSuccess(configId); + } } diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 4950ba1..daaf3b6 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -52,6 +52,10 @@ public class DatabaseInitConfig { fixTagsDefaultValue(); // 自动添加 content_hash 列(二期新增) addContentHashColumn(); + // P1-2.1: 自动添加 enabled 列(文档启用/禁用) + addDocumentEnabledColumn(); + // P1-2.5: 自动添加 extra_config 列(per-doc 分块参数) + addDocumentExtraConfigColumn(); } boolean roleTableExists = checkTableExists("customer_service_role"); @@ -236,6 +240,9 @@ public class DatabaseInitConfig { chunk_count INTEGER DEFAULT 0 NOT NULL, status VARCHAR(20) DEFAULT 'PROCESSING' NOT NULL, error_message TEXT, + content_hash VARCHAR(64), + enabled BOOLEAN DEFAULT TRUE NOT NULL, + extra_config JSONB DEFAULT '{}', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL @@ -247,6 +254,8 @@ public class DatabaseInitConfig { jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_document (category_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_enabled ON knowledge_document (enabled)"); } private void createCustomerServiceRoleTable() { @@ -454,6 +463,41 @@ public class DatabaseInitConfig { } } + /** + * P1-2.1: 自动添加 enabled 列(文档启用/禁用功能) + * 幂等:已有列则跳过 + */ + private void addDocumentEnabledColumn() { + try { + String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'enabled'"; + Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 knowledge_document.enabled 列"); + jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN enabled BOOLEAN DEFAULT TRUE NOT NULL"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_enabled ON knowledge_document (enabled)"); + } + } catch (Exception e) { + log.warn("添加 knowledge_document.enabled 列时出错", e); + } + } + + /** + * P1-2.5: 自动添加 extra_config 列(per-doc 分块参数存储) + * 幂等:已有列则跳过 + */ + private void addDocumentExtraConfigColumn() { + try { + String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'extra_config'"; + Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 knowledge_document.extra_config 列"); + jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN extra_config JSONB DEFAULT '{}'"); + } + } catch (Exception e) { + log.warn("添加 knowledge_document.extra_config 列时出错", e); + } + } + // ==================== P0-004: 内容安全过滤 ==================== private void createSensitiveWordTable() { @@ -900,6 +944,8 @@ public class DatabaseInitConfig { executeComment("COLUMN knowledge_document.status", "处理状态: PROCESSING / READY / FAILED"); executeComment("COLUMN knowledge_document.error_message", "处理失败时的错误信息"); executeComment("COLUMN knowledge_document.content_hash", "内容 SHA-256 哈希值(用于文档去重)"); + executeComment("COLUMN knowledge_document.enabled", "是否启用: TRUE=参与RAG检索, FALSE=禁用(不参与检索但保留数据)"); + executeComment("COLUMN knowledge_document.extra_config", "分块参数配置(JSONB),存储 per-doc 的 chunkSize/overlap 等"); executeComment("COLUMN knowledge_document.create_time", "创建时间"); executeComment("COLUMN knowledge_document.update_time", "更新时间"); executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); diff --git a/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java b/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java index 91efd75..43c4498 100644 --- a/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java +++ b/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java @@ -8,6 +8,7 @@ import com.wok.supportbot.service.AiModelConfigService; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.MetadataMode; import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.openai.OpenAiEmbeddingModel; import org.springframework.ai.openai.OpenAiEmbeddingOptions; import org.springframework.ai.openai.api.OpenAiApi; @@ -16,6 +17,8 @@ import org.springframework.retry.backoff.ExponentialBackOffPolicy; import org.springframework.retry.support.RetryTemplate; import org.springframework.stereotype.Component; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -33,7 +36,7 @@ public class EmbeddingModelFactory { private AiModelConfigService configService; /** - * EmbeddingModel 缓存:key = "provider:apiKey:modelName:baseUrl" + * EmbeddingModel 缓存:key = "configId:provider:keyHint:modelName"(不含明文 API Key) */ private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); @@ -82,11 +85,12 @@ public class EmbeddingModelFactory { /** * 获取或创建 EmbeddingModel(带缓存) - * 缓存 key = provider:apiKey:modelName:baseUrl,配置不变则复用实例 + * 缓存 key = configId:provider:keyHint:modelName,避免明文 API Key 出现在缓存键中 */ private EmbeddingModel getOrCreateEmbeddingModel(AiModelConfig config) { String baseUrl = resolveBaseUrl(config); - String cacheKey = config.getProvider() + ":" + config.getApiKey() + ":" + config.getModelName() + ":" + baseUrl; + String keyHint = config.getApiKey() != null ? String.valueOf(config.getApiKey().hashCode()) : "null"; + String cacheKey = config.getId() + ":" + config.getProvider() + ":" + keyHint + ":" + config.getModelName(); return cache.computeIfAbsent(cacheKey, k -> createEmbeddingModel(config, baseUrl)); } @@ -216,4 +220,57 @@ public class EmbeddingModelFactory { cache.clear(); log.info("EmbeddingModel 缓存已清除"); } + + // ==================== F1: 连接测试 ==================== + + /** + * 测试指定配置的连接可用性 + * 创建临时 EmbeddingModel(不走缓存),调用 embed("test") 验证 API Key 和模型是否可用 + * + * @param config 待测试的配置(需含完整 API Key) + * @return 测试结果:success / latencyMs / dimensions / errorMessage + */ + public Map testConnection(AiModelConfig config) { + Map result = new LinkedHashMap<>(); + long start = System.currentTimeMillis(); + try { + String baseUrl = resolveBaseUrl(config); + EmbeddingModel tempModel = createEmbeddingModel(config, baseUrl); + // 发送最简短的文本验证连接 + EmbeddingResponse response = tempModel.call(new org.springframework.ai.embedding.EmbeddingRequest( + List.of("test"), null)); + long latency = System.currentTimeMillis() - start; + result.put("success", true); + result.put("latencyMs", latency); + // 返回实际向量维度 + if (response != null && response.getResults() != null && !response.getResults().isEmpty()) { + float[] output = response.getResults().get(0).getOutput(); + result.put("dimensions", output != null ? output.length : 0); + } + } catch (Exception e) { + long latency = System.currentTimeMillis() - start; + result.put("success", false); + result.put("latencyMs", latency); + result.put("errorMessage", e.getMessage()); + } + return result; + } + + // ==================== F6: Fallback 故障自动切换 ==================== + + /** 共享熔断器:连续 3 次失败触发,5 分钟自动恢复 */ + private final SimpleCircuitBreaker circuitBreaker = + new SimpleCircuitBreaker(3, 5 * 60 * 1000L, "Embedding "); + + public boolean isCircuitOpen(Long configId) { + return circuitBreaker.isOpen(configId); + } + + public void recordFailure(Long configId) { + circuitBreaker.recordFailure(configId); + } + + public void recordSuccess(Long configId) { + circuitBreaker.recordSuccess(configId); + } } diff --git a/src/main/java/com/wok/supportbot/config/ModelHealthService.java b/src/main/java/com/wok/supportbot/config/ModelHealthService.java new file mode 100644 index 0000000..45dd1fb --- /dev/null +++ b/src/main/java/com/wok/supportbot/config/ModelHealthService.java @@ -0,0 +1,227 @@ +package com.wok.supportbot.config; + +import com.wok.supportbot.entity.AiModelConfig; +import com.wok.supportbot.service.AiModelConfigService; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PreDestroy; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** + * F4: 模型健康状态服务 + * 定时探活所有活跃配置,缓存健康状态供前端看板展示。 + * - 每 5 分钟自动探活所有 isActive=true 的配置 + * - 探活不影响正常业务(使用轻量级调用) + * - 单配置探活超时 5s,不影响其他配置检测 + * - 新激活配置立即触发一次探活 + */ +@Component +@Slf4j +public class ModelHealthService { + + @Autowired + private AiModelConfigService configService; + + @Autowired + private ChatModelFactory chatModelFactory; + + @Autowired + private EmbeddingModelFactory embeddingModelFactory; + + /** 健康状态缓存:configId → HealthStatus */ + private final ConcurrentHashMap healthCache = new ConcurrentHashMap<>(); + + /** 探活线程池(限制并发数,避免同时发起大量 API 调用) */ + private final ExecutorService probeExecutor = Executors.newFixedThreadPool(5); + + /** 单配置探活超时时间(秒) */ + private static final int PROBE_TIMEOUT_SECONDS = 5; + + /** + * 应用销毁时关闭线程池,防止资源泄漏 + */ + @PreDestroy + public void destroy() { + probeExecutor.shutdown(); + try { + if (!probeExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + probeExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + probeExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + log.info("探活线程池已关闭"); + } + + /** + * 定时探活:启动后延迟 10 秒执行首次,之后每 5 分钟执行一次 + */ + @Scheduled(initialDelay = 10000, fixedRate = 300000) + public void scheduledProbe() { + log.debug("开始定时探活..."); + probeAll(); + } + + /** + * 探测所有活跃配置的健康状态 + * 先提交所有探活任务(并行),再统一等待结果,避免串行阻塞 + */ + private void probeAll() { + String[] appTypes = {"CHAT", "EMBEDDING", "RAG_REWRITE", "RERANK"}; + // 第一阶段:收集所有待探活的配置和对应的 Future + List>>> pendingTasks = new ArrayList<>(); + + for (String appType : appTypes) { + try { + List configs = configService.getAllActiveConfigs(appType); + for (AiModelConfig config : configs) { + Future> future = probeExecutor.submit(() -> probeConfig(config)); + pendingTasks.add(Map.entry(config.getId(), future)); + } + } catch (Exception e) { + log.warn("收集应用类型 [{}] 探活任务时出错:{}", appType, e.getMessage()); + } + } + + // 第二阶段:统一等待所有结果 + for (Map.Entry>> entry : pendingTasks) { + Long configId = entry.getKey(); + Future> future = entry.getValue(); + try { + Map result = future.get(PROBE_TIMEOUT_SECONDS + 1, TimeUnit.SECONDS); + updateHealthStatus(configId, result); + } catch (Exception e) { + // 超时或其他异常,标记为 OFFLINE + future.cancel(true); + updateHealthStatus(configId, Map.of( + "success", false, + "errorMessage", "探活超时(" + PROBE_TIMEOUT_SECONDS + "s)")); + log.warn("配置 [{}] 探活超时或失败", configId); + } + } + } + + /** + * 对单个配置执行探活 + */ + private Map probeConfig(AiModelConfig config) { + try { + String appType = config.getAppType(); + if ("EMBEDDING".equals(appType)) { + return embeddingModelFactory.testConnection(config); + } else if ("RERANK".equals(appType)) { + // RERANK 暂无通用测试方式,标记为 UNKNOWN + return Map.of("success", true, "latencyMs", 0); + } else { + // CHAT / RAG_REWRITE + return chatModelFactory.testConnection(config); + } + } catch (Exception e) { + return Map.of("success", false, "errorMessage", e.getMessage() != null ? e.getMessage() : "未知错误"); + } + } + + /** + * 更新健康状态缓存 + */ + private void updateHealthStatus(Long configId, Map result) { + HealthStatus status = new HealthStatus(); + status.setStatus(Boolean.TRUE.equals(result.get("success")) ? "ONLINE" : "OFFLINE"); + Object latency = result.get("latencyMs"); + status.setLatencyMs(latency instanceof Number ? ((Number) latency).longValue() : null); + status.setLastCheckTime(new Date()); + Object errMsg = result.get("errorMessage"); + status.setErrorMessage(errMsg != null ? errMsg.toString() : null); + Object dims = result.get("dimensions"); + status.setDimensions(dims instanceof Number ? ((Number) dims).intValue() : null); + healthCache.put(configId, status); + } + + /** + * 手动触发单个配置的健康检查 + */ + public Map checkHealth(Long id) { + AiModelConfig config = configService.getConfigWithFullKey(id); + if (config == null) { + return Map.of("status", "UNKNOWN", "errorMessage", "配置不存在"); + } + Map result = probeConfig(config); + updateHealthStatus(id, result); + + Map response = new LinkedHashMap<>(result); + response.put("status", Boolean.TRUE.equals(result.get("success")) ? "ONLINE" : "OFFLINE"); + response.put("lastCheckTime", new Date()); + return response; + } + + /** + * 立即触发指定配置的探活(新激活时调用) + */ + public void triggerImmediateProbe(Long configId) { + probeExecutor.submit(() -> { + try { + AiModelConfig config = configService.getConfigWithFullKey(configId); + if (config != null) { + Map result = probeConfig(config); + updateHealthStatus(configId, result); + } + } catch (Exception e) { + log.warn("立即探活配置 [{}] 失败:{}", configId, e.getMessage()); + } + }); + } + + /** + * 获取所有配置的健康状态(供前端看板使用) + */ + public Map getAllHealthStatus() { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : healthCache.entrySet()) { + Map statusMap = new LinkedHashMap<>(); + HealthStatus hs = entry.getValue(); + statusMap.put("status", hs.getStatus()); + statusMap.put("latencyMs", hs.getLatencyMs()); + statusMap.put("lastCheckTime", hs.getLastCheckTime()); + statusMap.put("errorMessage", hs.getErrorMessage()); + statusMap.put("dimensions", hs.getDimensions()); + result.put(entry.getKey().toString(), statusMap); + } + + // 添加汇总统计 + long online = healthCache.values().stream().filter(s -> "ONLINE".equals(s.getStatus())).count(); + long offline = healthCache.values().stream().filter(s -> "OFFLINE".equals(s.getStatus())).count(); + Map summary = new LinkedHashMap<>(); + summary.put("online", online); + summary.put("offline", offline); + summary.put("total", (long) healthCache.size()); + result.put("_summary", summary); + + return result; + } + + /** + * 健康状态数据结构 + */ + @Data + private static class HealthStatus { + private String status; // ONLINE / OFFLINE / UNKNOWN + private Long latencyMs; // 响应延迟(毫秒) + private Date lastCheckTime; // 最后检测时间 + private String errorMessage; // 错误信息 + private Integer dimensions; // 向量维度(仅 EMBEDDING) + } +} diff --git a/src/main/java/com/wok/supportbot/config/SimpleCircuitBreaker.java b/src/main/java/com/wok/supportbot/config/SimpleCircuitBreaker.java new file mode 100644 index 0000000..5577c3c --- /dev/null +++ b/src/main/java/com/wok/supportbot/config/SimpleCircuitBreaker.java @@ -0,0 +1,76 @@ +package com.wok.supportbot.config; + +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 简易熔断器 + * 跟踪每个 configId 的连续失败次数,达到阈值后触发熔断,超过恢复时间自动半开。 + * 供 ChatModelFactory / EmbeddingModelFactory 共享使用,消除重复代码。 + */ +@Slf4j +public class SimpleCircuitBreaker { + + /** 失败计数器:configId → 连续失败次数 */ + private final ConcurrentHashMap failureCounters = new ConcurrentHashMap<>(); + /** 熔断状态:configId → 熔断开始时间戳 */ + private final ConcurrentHashMap circuitBreakerOpenTime = new ConcurrentHashMap<>(); + + /** 熔断阈值:连续 N 次失败触发熔断 */ + private final int threshold; + /** 熔断恢复时间(毫秒) */ + private final long recoveryMs; + /** 日志标签(区分 Chat / Embedding) */ + private final String label; + + /** + * @param threshold 连续失败多少次触发熔断 + * @param recoveryMs 熔断后多久自动恢复(毫秒) + * @param label 日志前缀标签 + */ + public SimpleCircuitBreaker(int threshold, long recoveryMs, String label) { + this.threshold = threshold; + this.recoveryMs = recoveryMs; + this.label = label; + } + + /** + * 检查指定配置是否处于熔断状态 + * 超过恢复时间自动关闭熔断并重置计数器 + */ + public boolean isOpen(Long configId) { + Long openTime = circuitBreakerOpenTime.get(configId); + if (openTime == null) return false; + // 超过恢复时间,自动关闭熔断 + if (System.currentTimeMillis() - openTime > recoveryMs) { + circuitBreakerOpenTime.remove(configId); + failureCounters.remove(configId); + log.info("{}配置 [{}] 熔断已恢复", label, configId); + return false; + } + return true; + } + + /** + * 记录一次调用失败,连续失败达到阈值时触发熔断 + */ + public void recordFailure(Long configId) { + AtomicInteger counter = failureCounters.computeIfAbsent(configId, k -> new AtomicInteger(0)); + int failures = counter.incrementAndGet(); + if (failures >= threshold) { + circuitBreakerOpenTime.put(configId, System.currentTimeMillis()); + log.warn("{}配置 [{}] 连续失败 {} 次,触发熔断({}分钟后自动恢复)", + label, configId, failures, recoveryMs / 60000); + } + } + + /** + * 记录一次调用成功,重置失败计数和熔断状态 + */ + public void recordSuccess(Long configId) { + failureCounters.remove(configId); + circuitBreakerOpenTime.remove(configId); + } +} diff --git a/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java b/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java index c83bb7f..a1e5bf2 100644 --- a/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java +++ b/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java @@ -3,6 +3,7 @@ package com.wok.supportbot.controller; import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.config.ChatModelFactory; import com.wok.supportbot.config.EmbeddingModelFactory; +import com.wok.supportbot.config.ModelHealthService; import com.wok.supportbot.entity.AiModelConfig; import com.wok.supportbot.service.AiModelConfigService; import org.springframework.beans.factory.annotation.Autowired; @@ -10,6 +11,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import java.util.List; import java.util.Map; /** @@ -19,6 +21,10 @@ import java.util.Map; @RestController public class AiModelConfigController { + /** 复用的 JSON 转换器,避免每次导入都新建实例 */ + private static final com.fasterxml.jackson.databind.ObjectMapper OBJECT_MAPPER = + new com.fasterxml.jackson.databind.ObjectMapper(); + @Autowired private AiModelConfigService aiModelConfigService; @@ -31,6 +37,9 @@ public class AiModelConfigController { @Autowired private AssistantApp assistantApp; + @Autowired + private ModelHealthService modelHealthService; + // ==================== 分页列表 ==================== /** @@ -277,6 +286,213 @@ public class AiModelConfigController { } } + // ==================== F1: 连接测试 ==================== + + /** + * 测试指定配置的连接可用性 + * 根据配置构建临时 ChatModel / EmbeddingModel,发送简单请求验证 API Key 和模型是否可访问 + * + * @param id 配置ID + * @return 测试结果:{success, latencyMs, errorMessage?, dimensions?} + */ + @PostMapping("/model-config/{id}/test") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> testConnection(@PathVariable("id") Long id) { + try { + AiModelConfig config = aiModelConfigService.getConfigWithFullKey(id); + if (config == null) { + return ResponseEntity.status(404).body(Map.of( + "success", false, "message", "配置不存在")); + } + + Map result; + String appType = config.getAppType(); + + if ("EMBEDDING".equals(appType)) { + result = embeddingModelFactory.testConnection(config); + } else if ("RERANK".equals(appType)) { + // RERANK 类型暂无通用测试方式 + result = Map.of("success", false, "latencyMs", 0, + "errorMessage", "RERANK 类型暂不支持连接测试"); + } else { + // CHAT / RAG_REWRITE 均使用 ChatModel 测试 + result = chatModelFactory.testConnection(config); + } + return ResponseEntity.ok(result); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, "message", "测试失败:" + e.getMessage())); + } + } + + // ==================== F2: 配置复制 ==================== + + /** + * 复制指定配置(快速克隆) + * 复制所有字段,name 加 " - 副本" 后缀,isActive 强制 false + * + * @param id 源配置ID + * @return 新创建的副本配置 + */ + @PostMapping("/model-config/{id}/duplicate") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> duplicateConfig(@PathVariable("id") Long id) { + try { + AiModelConfig duplicated = aiModelConfigService.duplicateConfig(id); + refreshCache(); + return ResponseEntity.ok(Map.of( + "success", true, + "data", duplicated, + "message", "配置复制成功" + )); + } catch (RuntimeException e) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, "message", e.getMessage())); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, "message", "复制失败:" + e.getMessage())); + } + } + + // ==================== F4: 健康状态 ==================== + + /** + * 批量获取所有配置的健康状态 + * + * @return 各配置的健康状态:{configId: {status, latencyMs, lastCheckTime, errorMessage}} + */ + @GetMapping("/model-config/health") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> getHealthStatus() { + try { + Map healthData = modelHealthService.getAllHealthStatus(); + return ResponseEntity.ok(Map.of( + "success", true, + "data", healthData + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, "message", "查询失败:" + e.getMessage())); + } + } + + /** + * 手动触发单个配置的健康检查 + */ + @PostMapping("/model-config/{id}/health-check") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> manualHealthCheck(@PathVariable("id") Long id) { + try { + Map result = modelHealthService.checkHealth(id); + return ResponseEntity.ok(Map.of("success", true, "data", result)); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, "message", "检查失败:" + e.getMessage())); + } + } + + // ==================== F6: 优先级管理 ==================== + + /** + * 更新配置优先级(用于 Fallback 链排序) + */ + @PutMapping("/model-config/{id}/priority") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> updatePriority( + @PathVariable("id") Long id, + @RequestBody Map body) { + try { + Integer priority = body.get("priority"); + if (priority == null) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, "message", "priority 参数不能为空")); + } + aiModelConfigService.updatePriority(id, priority); + refreshCache(); + return ResponseEntity.ok(Map.of("success", true, "message", "优先级更新成功")); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, "message", "更新失败:" + e.getMessage())); + } + } + + /** + * 停用配置(从 Fallback 链中移除,但不删除) + */ + @PutMapping("/model-config/{id}/deactivate") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> deactivateConfig(@PathVariable("id") Long id) { + try { + aiModelConfigService.deactivateConfig(id); + refreshCache(); + return ResponseEntity.ok(Map.of("success", true, "message", "配置已停用")); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, "message", "停用失败:" + e.getMessage())); + } + } + + // ==================== F7: 配置导入/导出 ==================== + + /** + * 导出所有配置为 JSON + * + * @param maskApiKey 是否脱敏 API Key(默认 true) + */ + @GetMapping("/model-config/export") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> exportConfigs( + @RequestParam(defaultValue = "true") boolean maskApiKey) { + try { + List configs = aiModelConfigService.exportConfigs(maskApiKey); + return ResponseEntity.ok(Map.of( + "success", true, + "data", configs, + "total", configs.size() + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, "message", "导出失败:" + e.getMessage())); + } + } + + /** + * 批量导入配置 + * + * @param body 请求体:{configs: [...], onConflict: "skip" | "overwrite"} + */ + @PostMapping("/model-config/import") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> importConfigs(@RequestBody Map body) { + try { + @SuppressWarnings("unchecked") + List> rawConfigs = (List>) body.get("configs"); + if (rawConfigs == null || rawConfigs.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, "message", "导入数据不能为空")); + } + + String onConflict = body.getOrDefault("onConflict", "skip").toString(); + + // 将 Map 转换为 AiModelConfig 实体 + List configs = new java.util.ArrayList<>(); + for (Map raw : rawConfigs) { + configs.add(OBJECT_MAPPER.convertValue(raw, AiModelConfig.class)); + } + + Map result = aiModelConfigService.importConfigs(configs, onConflict); + refreshCache(); + return ResponseEntity.ok(Map.of( + "success", true, + "data", result, + "message", "导入完成" + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, "message", "导入失败:" + e.getMessage())); + } + } + // ==================== 缓存刷新 ==================== /** diff --git a/src/main/java/com/wok/supportbot/controller/ConversationController.java b/src/main/java/com/wok/supportbot/controller/ConversationController.java index 1220efe..fd797e9 100644 --- a/src/main/java/com/wok/supportbot/controller/ConversationController.java +++ b/src/main/java/com/wok/supportbot/controller/ConversationController.java @@ -1,13 +1,10 @@ package com.wok.supportbot.controller; import com.wok.supportbot.service.ConversationService; -import com.wok.supportbot.service.SysUserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -23,9 +20,6 @@ public class ConversationController { @Autowired private ConversationService conversationService; - @Autowired(required = false) - private SysUserService sysUserService; - // ==================== 会话列表 ==================== /** @@ -44,9 +38,7 @@ public class ConversationController { @RequestParam(required = false) String accountId, @RequestParam(required = false) Long roleId) { try { - // 获取当前登录用户ID(用于数据隔离,SDK调用时为null) - Long userId = getCurrentUserId(); - Map result = conversationService.listConversations(page, size, keyword, accountId, roleId, userId); + Map result = conversationService.listConversations(page, size, keyword, accountId, roleId); Map data = new java.util.HashMap<>(); data.put("success", true); data.put("data", result.get("records")); @@ -212,22 +204,4 @@ public class ConversationController { } } - /** - * 从 SecurityContext 获取当前登录用户的系统用户ID - * 未登录时返回 null(SDK 调用场景) - */ - private Long getCurrentUserId() { - try { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) { - String username = auth.getName(); - if (sysUserService != null && username != null) { - var user = sysUserService.getUserByUsername(username); - if (user != null) return user.getId(); - } - } - } catch (Exception ignored) {} - return null; - } - } diff --git a/src/main/java/com/wok/supportbot/controller/DocumentController.java b/src/main/java/com/wok/supportbot/controller/DocumentController.java index c65fe57..f073c20 100644 --- a/src/main/java/com/wok/supportbot/controller/DocumentController.java +++ b/src/main/java/com/wok/supportbot/controller/DocumentController.java @@ -80,10 +80,12 @@ public class DocumentController { @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, - @RequestParam(required = false) List tags) { + @RequestParam(required = false) List tags, + @RequestParam(required = false) Integer chunkSize, + @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, tags); + KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "文件上传成功,正在后台处理", @@ -112,9 +114,11 @@ public class DocumentController { @RequestBody String content, @RequestParam String title, @RequestParam(required = false) Long categoryId, - @RequestParam(required = false) List tags) { + @RequestParam(required = false) List tags, + @RequestParam(required = false) Integer chunkSize, + @RequestParam(required = false) Integer overlap) { try { - KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags); + KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "文本内容上传成功,正在后台处理", @@ -137,10 +141,12 @@ public class DocumentController { @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, - @RequestParam(required = false) List tags) { + @RequestParam(required = false) List tags, + @RequestParam(required = false) Integer chunkSize, + @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, tags); + KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "Markdown文件上传成功,正在后台处理", @@ -163,10 +169,12 @@ public class DocumentController { @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, - @RequestParam(required = false) List tags) { + @RequestParam(required = false) List tags, + @RequestParam(required = false) Integer chunkSize, + @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, tags); + KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件上传成功,正在后台处理", @@ -190,10 +198,12 @@ public class DocumentController { @RequestParam("fields") List fields, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, - @RequestParam(required = false) List tags) { + @RequestParam(required = false) List tags, + @RequestParam(required = false) Integer chunkSize, + @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, tags); + KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件(按字段)上传成功,正在后台处理", @@ -218,10 +228,12 @@ public class DocumentController { @RequestParam("pointer") String pointer, @RequestParam(required = false) String title, @RequestParam(required = false) Long categoryId, - @RequestParam(required = false) List tags) { + @RequestParam(required = false) List tags, + @RequestParam(required = false) Integer chunkSize, + @RequestParam(required = false) Integer overlap) { try { validateUploadFile(file); - KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, tags); + KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, tags, chunkSize, overlap); return ResponseEntity.ok(Map.of( "success", true, "message", "JSON文件(按指针)上传成功,正在后台处理", @@ -239,13 +251,14 @@ public class DocumentController { // ==================== 文档管理 ==================== /** - * 查询文档列表(分页 + 过滤 + 关键词搜索) + * 查询文档列表(分页 + 过滤 + 关键词搜索 + 标签筛选) * * @param page 页码(默认1) * @param size 每页大小(默认10) * @param categoryId 分类ID过滤(可选) * @param status 状态过滤(PROCESSING/READY/FAILED,可选) * @param keyword 关键词搜索(模糊匹配标题和文件名,可选) + * @param tag 标签筛选(精确匹配,可选) * @return 分页文档列表 */ @GetMapping("/document/list") @@ -255,9 +268,10 @@ public class DocumentController { @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String status, - @RequestParam(required = false) String keyword) { + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String tag) { try { - Map result = documentService.listDocuments(page, size, categoryId, status, keyword); + Map result = documentService.listDocuments(page, size, categoryId, status, keyword, tag); Map data = new HashMap<>(); data.put("success", true); data.put("data", result.get("records")); @@ -465,6 +479,96 @@ public class DocumentController { } } + /** + * P1-2.1: 切换文档启用/禁用状态 + * 禁用后不参与 RAG 检索但保留数据,启用后立即可被检索 + */ + @PutMapping("/document/{id}/toggle") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> toggleDocument(@PathVariable Long id) { + try { + KnowledgeDocument doc = documentService.toggleDocument(id); + String statusText = Boolean.TRUE.equals(doc.getEnabled()) ? "已启用" : "已禁用"; + return ResponseEntity.ok(Map.of( + "success", true, + "message", "文档" + statusText, + "data", doc + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "切换状态失败:" + e.getMessage() + )); + } + } + + /** + * P1-2.1: 批量启用/禁用文档 + * body: {ids: [Long], enabled: Boolean} + */ + @PostMapping("/document/batch/toggle") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> batchToggleDocuments(@RequestBody Map body) { + try { + List ids = extractIds(body); + Boolean enabled = body.get("enabled") != null ? (Boolean) body.get("enabled") : null; + if (ids.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "请提供文档ID列表" + )); + } + if (enabled == null) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "请指定目标状态(enabled: true/false)" + )); + } + Map result = documentService.batchToggleDocuments(ids, enabled); + String action = enabled ? "启用" : "禁用"; + return ResponseEntity.ok(Map.of( + "success", true, + "message", String.format("批量%s完成:成功 %d 个,失败 %d 个", + action, result.get("successCount"), result.get("failCount")), + "data", result + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "批量切换状态失败:" + e.getMessage() + )); + } + } + + /** + * P1-2.2: 批量移动文档分类 + * body: {ids: [Long], categoryId: Long} + */ + @PostMapping("/document/batch/move") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> batchMoveDocuments(@RequestBody Map body) { + try { + List ids = extractIds(body); + Long categoryId = body.get("categoryId") != null ? ((Number) body.get("categoryId")).longValue() : 0L; + if (ids.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "请提供文档ID列表" + )); + } + int updatedCount = documentService.batchMoveDocuments(ids, categoryId); + return ResponseEntity.ok(Map.of( + "success", true, + "message", String.format("已将 %d 个文档移动到目标分类", updatedCount) + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "批量移动失败:" + e.getMessage() + )); + } + } + // ==================== 语义搜索 ==================== /** @@ -532,6 +636,83 @@ public class DocumentController { .toList(); } + // ==================== 标签管理 ==================== + + /** + * P1-2.3: 获取标签列表(从所有文档的 tags 聚合去重,含使用次数) + */ + @GetMapping("/tag/list") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> getTagList() { + try { + List> tags = documentService.getTagList(); + return ResponseEntity.ok(Map.of( + "success", true, + "data", tags, + "total", tags.size() + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "获取标签列表失败:" + e.getMessage() + )); + } + } + + // ==================== 分块管理 ==================== + + /** + * P1-2.5: 更新单个分块内容(重新向量化该分块) + */ + @PutMapping("/document/{docId}/chunk/{chunkIndex}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> updateChunk( + @PathVariable Long docId, + @PathVariable int chunkIndex, + @RequestBody Map body) { + try { + String content = (String) body.get("content"); + if (content == null || content.isBlank()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "分块内容不能为空" + )); + } + documentService.updateChunk(docId, chunkIndex, content); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "分块内容已更新并重新向量化" + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "更新分块失败:" + e.getMessage() + )); + } + } + + /** + * P1-2.5: 删除单个分块(同步从 vector_store 移除向量) + */ + @DeleteMapping("/document/{docId}/chunk/{chunkIndex}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") + public ResponseEntity> deleteChunk( + @PathVariable Long docId, + @PathVariable int chunkIndex) { + try { + documentService.deleteChunk(docId, chunkIndex); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "分块已删除" + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "删除分块失败:" + e.getMessage() + )); + } + } + // ==================== 统计 ==================== /** diff --git a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java index 342b43f..a889a93 100644 --- a/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java +++ b/src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java @@ -99,6 +99,18 @@ public class KnowledgeDocument implements Serializable { @TableField("content_hash") private String contentHash; + /** + * 是否启用 - true:启用(参与RAG检索), false:禁用(不参与检索但保留数据) + */ + @TableField("enabled") + private Boolean enabled; + + /** + * 分块参数配置(JSONB),存储 per-doc 的 chunkSize/overlap 等 + */ + @TableField(value = "extra_config", typeHandler = PostgresJsonTypeHandler.class) + private Map extraConfig; + /** * 创建时间 */ diff --git a/src/main/java/com/wok/supportbot/rag/HybridSearchService.java b/src/main/java/com/wok/supportbot/rag/HybridSearchService.java index 857d733..d7fc7ae 100644 --- a/src/main/java/com/wok/supportbot/rag/HybridSearchService.java +++ b/src/main/java/com/wok/supportbot/rag/HybridSearchService.java @@ -64,7 +64,7 @@ public class HybridSearchService { /** * 纯向量语义检索 - * 复用 PgVectorStore 的 similaritySearch,支持分类过滤和相似度阈值。 + * 复用 PgVectorStore 的 similaritySearch,支持分类过滤、启用状态过滤和相似度阈值。 */ private List vectorSearch(String query, int topK, double threshold, List categoryIds) { SearchRequest.Builder builder = SearchRequest.builder() @@ -72,13 +72,18 @@ public class HybridSearchService { .topK(topK) .similarityThreshold(threshold); - // 分类过滤 + FilterExpressionBuilder fb = new FilterExpressionBuilder(); + // P1-2.1: 始终过滤禁用的文档 + var enabledFilter = fb.eq("enabled", "true"); + if (categoryIds != null && !categoryIds.isEmpty()) { - FilterExpressionBuilder fb = new FilterExpressionBuilder(); List values = categoryIds.stream() .map(id -> (Object) String.valueOf(id)) .toList(); - builder.filterExpression(fb.in("categoryId", values).build()); + var categoryFilter = fb.in("categoryId", values); + builder.filterExpression(fb.and(enabledFilter, categoryFilter).build()); + } else { + builder.filterExpression(enabledFilter.build()); } List docs = pgVectorVectorStore.similaritySearch(builder.build()); @@ -91,6 +96,7 @@ public class HybridSearchService { /** * 全文关键词检索 * 使用 PostgreSQL tsvector 全文索引,按 ts_rank 排序。 + * P1-2.1: 始终过滤禁用的文档(metadata->>'enabled' = 'true') */ private List keywordSearch(String query, int topK, List categoryIds) { String categoryFilter = buildCategoryFilter(categoryIds); @@ -100,6 +106,7 @@ public class HybridSearchService { FROM vector_store WHERE content_tsvector @@ plainto_tsquery('simple', ?) AND is_delete = false + AND metadata->>'enabled' = 'true' %s ORDER BY rank DESC LIMIT ? diff --git a/src/main/java/com/wok/supportbot/service/AiModelConfigService.java b/src/main/java/com/wok/supportbot/service/AiModelConfigService.java index c08ecc4..7a7c91e 100644 --- a/src/main/java/com/wok/supportbot/service/AiModelConfigService.java +++ b/src/main/java/com/wok/supportbot/service/AiModelConfigService.java @@ -10,6 +10,8 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.databind.ObjectMapper; + import java.util.*; /** @@ -26,6 +28,9 @@ public class AiModelConfigService { @Autowired private JdbcTemplate jdbcTemplate; + /** 复用的 JSON 序列化器,避免每次调用 persistExtraConfig 都新建实例 */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + // ==================== 分页列表 ==================== /** @@ -211,7 +216,10 @@ public class AiModelConfigService { } aiModelConfigMapper.updateById(config); // JSONB 字段 MyBatis Plus typeHandler 可能不触发,用 JdbcTemplate 显式写入保证可靠 - persistExtraConfig(id, config.getExtraConfig()); + // 仅当前端传入了 extraConfig 时才覆盖,null 表示未修改,跳过以避免清空已有值 + if (config.getExtraConfig() != null) { + persistExtraConfig(id, config.getExtraConfig()); + } log.info("更新 AI 模型配置: id={}", id); AiModelConfig updated = aiModelConfigMapper.selectById(id); // 返回前脱敏,避免明文 api_key 暴露给前端(与列表/详情接口保持一致) @@ -224,7 +232,8 @@ public class AiModelConfigService { // ==================== 激活配置 ==================== /** - * 激活指定配置(同 app_type 互斥) + * 激活指定配置(F6: 允许同 app_type 多个活跃配置) + * 激活时自动设置 priority 为当前最高值 + 1,使其成为主模型 * 校验:Embedding 模型不能作为 CHAT / RAG_REWRITE 类型激活 * * @param id 配置ID @@ -239,17 +248,26 @@ public class AiModelConfigService { // 校验:Embedding 模型不能用于非 EMBEDDING 类型 validateModelTypeMatch(config); - // 先禁用同类型的所有配置 - deactivateByAppType(config.getAppType()); + // F6: 不再互斥,而是设置 priority 为当前最高 + 1 + Integer maxPriority = getMaxPriority(config.getAppType()); + int newPriority = (maxPriority != null ? maxPriority : 0) + 1; - // 再激活目标配置 LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(AiModelConfig::getId, id) - .set(AiModelConfig::getIsActive, true); + .set(AiModelConfig::getIsActive, true) + .set(AiModelConfig::getPriority, newPriority); aiModelConfigMapper.update(null, updateWrapper); - log.info("激活 AI 模型配置: id={}, appType={}, modelName={}", - id, config.getAppType(), config.getModelName()); + log.info("激活 AI 模型配置: id={}, appType={}, modelName={}, priority={}", + id, config.getAppType(), config.getModelName(), newPriority); + } + + /** + * 获取指定应用类型的最高优先级 + */ + private Integer getMaxPriority(String appType) { + String sql = "SELECT MAX(priority) FROM ai_model_config WHERE app_type = ? AND is_delete = false"; + return jdbcTemplate.queryForObject(sql, Integer.class, appType); } // ==================== 删除配置 ==================== @@ -337,8 +355,7 @@ public class AiModelConfigService { if (extraConfig == null || extraConfig.isEmpty()) { json = "{}"; } else { - com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper(); - json = om.writeValueAsString(extraConfig); + json = OBJECT_MAPPER.writeValueAsString(extraConfig); } jdbcTemplate.update( "UPDATE ai_model_config SET extra_config = ?::jsonb WHERE id = ?", @@ -348,4 +365,212 @@ public class AiModelConfigService { log.warn("持久化 extraConfig 失败: id={}, error={}", id, e.getMessage()); } } + + // ==================== F1: 连接测试辅助 ==================== + + /** + * 获取配置详情(含完整 API Key,仅供内部测试连接使用) + * + * @param id 配置ID + * @return 配置对象(含完整 API Key) + */ + public AiModelConfig getConfigWithFullKey(Long id) { + AiModelConfig config = aiModelConfigMapper.selectById(id); + if (config != null && config.getApiKey() != null) { + config.setApiKey(config.getApiKey().trim()); + } + return config; + } + + // ==================== F2: 配置复制 ==================== + + /** + * 复制指定配置 + * 复制所有字段,name 加 " - 副本" 后缀,isActive 强制 false + * + * @param id 源配置ID + * @return 新创建的副本配置 + */ + @Transactional(rollbackFor = Exception.class) + public AiModelConfig duplicateConfig(Long id) { + AiModelConfig source = aiModelConfigMapper.selectById(id); + if (source == null) { + throw new RuntimeException("源配置不存在"); + } + + AiModelConfig copy = AiModelConfig.builder() + .name(source.getName() + " - 副本") + .appType(source.getAppType()) + .provider(source.getProvider()) + .apiKey(source.getApiKey()) + .modelName(source.getModelName()) + .temperature(source.getTemperature()) + .maxTokens(source.getMaxTokens()) + .baseUrl(source.getBaseUrl()) + .extraConfig(source.getExtraConfig() != null ? new LinkedHashMap<>(source.getExtraConfig()) : null) + .isActive(false) + .priority(source.getPriority() != null ? source.getPriority() : 0) + .description(source.getDescription()) + .build(); + + aiModelConfigMapper.insert(copy); + // JSONB 字段需要显式持久化 + persistExtraConfig(copy.getId(), copy.getExtraConfig()); + + log.info("复制 AI 模型配置: sourceId={}, newName={}, newId={}", id, copy.getName(), copy.getId()); + return copy; + } + + // ==================== F6: Fallback 多活跃配置支持 ==================== + + /** + * 获取指定应用类型的所有活跃配置,按 priority 降序排列 + * 用于 Fallback 链:优先级最高的为主模型,其余为备用 + * + * @param appType 应用类型 + * @return 活跃配置列表(含完整 API Key),按 priority DESC 排序 + */ + public List getAllActiveConfigs(String appType) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AiModelConfig::getAppType, appType) + .eq(AiModelConfig::getIsActive, true) + .orderByDesc(AiModelConfig::getPriority); + List configs = aiModelConfigMapper.selectList(wrapper); + // trim API Key + configs.forEach(c -> { + if (c.getApiKey() != null) c.setApiKey(c.getApiKey().trim()); + }); + return configs; + } + + /** + * 更新配置的优先级(用于前端拖拽排序调整 Fallback 链) + * + * @param id 配置ID + * @param priority 新优先级值 + */ + public void updatePriority(Long id, Integer priority) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(AiModelConfig::getId, id) + .set(AiModelConfig::getPriority, priority); + aiModelConfigMapper.update(null, updateWrapper); + log.info("更新配置优先级: id={}, priority={}", id, priority); + } + + /** + * 停用指定配置(从 Fallback 链中移除) + */ + public void deactivateConfig(Long id) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(AiModelConfig::getId, id) + .set(AiModelConfig::getIsActive, false); + aiModelConfigMapper.update(null, updateWrapper); + log.info("停用 AI 模型配置: id={}", id); + } + + // ==================== F7: 配置导入/导出 ==================== + + /** + * 导出所有未删除配置为列表 + * + * @param maskApiKey 是否脱敏 API Key + * @return 配置列表 + */ + public List exportConfigs(boolean maskApiKey) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.orderByDesc(AiModelConfig::getPriority) + .orderByDesc(AiModelConfig::getCreateTime); + List configs = aiModelConfigMapper.selectList(wrapper); + + if (maskApiKey) { + configs.forEach(c -> c.setApiKey(maskApiKey(c.getApiKey()))); + } + // 清除系统字段,导出纯净数据 + configs.forEach(c -> { + c.setId(null); + c.setCreateTime(null); + c.setUpdateTime(null); + c.setDelete(false); + }); + return configs; + } + + /** + * 批量导入配置 + * + * @param configs 待导入的配置列表 + * @param onConflict 冲突策略:skip(跳过同名配置)/ overwrite(覆盖同名配置) + * @return 导入结果统计 + */ + @Transactional(rollbackFor = Exception.class) + public Map importConfigs(List configs, String onConflict) { + int added = 0, overwritten = 0, skipped = 0; + List errors = new ArrayList<>(); + + for (AiModelConfig config : configs) { + try { + // 校验必填字段 + if (config.getName() == null || config.getName().trim().isEmpty()) { + errors.add("跳过:缺少配置名称"); + skipped++; + continue; + } + if (config.getAppType() == null || config.getAppType().trim().isEmpty()) { + errors.add("跳过 [" + config.getName() + "]:缺少应用类型"); + skipped++; + continue; + } + if (config.getModelName() == null || config.getModelName().trim().isEmpty()) { + errors.add("跳过 [" + config.getName() + "]:缺少模型名称"); + skipped++; + continue; + } + + // 检查同名配置 + LambdaQueryWrapper nameQuery = new LambdaQueryWrapper<>(); + nameQuery.eq(AiModelConfig::getName, config.getName()); + AiModelConfig existing = aiModelConfigMapper.selectOne(nameQuery); + + if (existing != null) { + if ("overwrite".equals(onConflict)) { + // 覆盖:更新已有配置,强制清除系统字段防止被导入数据覆盖 + config.setId(existing.getId()); + config.setCreateTime(null); + config.setUpdateTime(null); + config.setDelete(false); + if (config.getApiKey() != null) config.setApiKey(config.getApiKey().trim()); + aiModelConfigMapper.updateById(config); + persistExtraConfig(existing.getId(), config.getExtraConfig()); + overwritten++; + } else { + skipped++; + } + } else { + // 新增:强制清除系统字段 + config.setId(null); + config.setCreateTime(null); + config.setUpdateTime(null); + config.setDelete(false); + config.setIsActive(false); + if (config.getApiKey() != null) config.setApiKey(config.getApiKey().trim()); + aiModelConfigMapper.insert(config); + persistExtraConfig(config.getId(), config.getExtraConfig()); + added++; + } + } catch (Exception e) { + String msg = "导入 [" + (config.getName() != null ? config.getName() : "未知") + "] 失败:" + e.getMessage(); + errors.add(msg); + skipped++; + } + } + + Map result = new LinkedHashMap<>(); + result.put("added", added); + result.put("overwritten", overwritten); + result.put("skipped", skipped); + result.put("errors", errors); + log.info("导入配置完成: added={}, overwritten={}, skipped={}, errors={}", + added, overwritten, skipped, errors.size()); + return result; + } } diff --git a/src/main/java/com/wok/supportbot/service/ConversationService.java b/src/main/java/com/wok/supportbot/service/ConversationService.java index c7538fc..abb86f9 100644 --- a/src/main/java/com/wok/supportbot/service/ConversationService.java +++ b/src/main/java/com/wok/supportbot/service/ConversationService.java @@ -39,19 +39,15 @@ public class ConversationService { * @return 分页会话列表 */ public Map listConversations(int page, int size, String keyword) { - return listConversations(page, size, keyword, null, null, null); - } - - public Map listConversations(int page, int size, String keyword, String accountId, Long roleId) { - return listConversations(page, size, keyword, accountId, roleId, null); + return listConversations(page, size, keyword, null, null); } /** - * 获取会话列表(分页,支持用户数据隔离) - * - * @param userId 系统用户ID,null 表示不过滤(SDK 调用场景) + * 获取会话列表(分页) + * 会话归属通过 conversation_session 表的 accountId / roleId 关联, + * 管理员可通过上述字段按需筛选。 */ - public Map listConversations(int page, int size, String keyword, String accountId, Long roleId, Long userId) { + public Map listConversations(int page, int size, String keyword, String accountId, Long roleId) { StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false "); List params = new ArrayList<>(); @@ -76,11 +72,10 @@ public class ConversationService { whereClause.append(" AND cs.role_id = ? "); params.add(roleId); } - // 用户数据隔离:只返回属于当前用户的会话 - if (userId != null) { - whereClause.append(" AND cm1.user_id = ? "); - params.add(userId); - } + // 注意:不再按 chat_message.user_id 过滤。 + // 消息由 SDK/外部用户产生,user_id 通常为 NULL; + // 会话归属已通过 conversation_session 表的 accountId / roleId 关联, + // 管理员可通过上述字段按需筛选。 String countSql = """ SELECT COUNT(DISTINCT cm1.conversation_id) diff --git a/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java b/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java index 24ad357..4c8320a 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java @@ -51,11 +51,14 @@ public class DocumentProcessingService { * @param title 文档标题 * @param categoryId 分类ID * @param tags 标签列表 + * @param chunkSize 分块大小(可选,覆盖全局配置) + * @param overlap 重叠大小(可选,覆盖全局配置) */ @Async @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public void processDocumentAsync(Long docId, List documents, String sourceName, - String title, Long categoryId, List tags) { + String title, Long categoryId, List tags, + Integer chunkSize, Integer overlap) { // 等待主事务提交,确保文档记录可见(最多重试 5 次,每次 200ms) KnowledgeDocument doc = waitForDocument(docId); if (doc == null) { @@ -64,8 +67,8 @@ public class DocumentProcessingService { } try { - // 1. 分块处理 - List splitDocuments = myTokenTextSplitter.splitDocuments(documents); + // 1. 分块处理(使用 per-doc 参数或全局配置) + List splitDocuments = myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap); // 2. 为每个分块设置 metadata for (int i = 0; i < splitDocuments.size(); i++) { @@ -81,6 +84,8 @@ public class DocumentProcessingService { if (tags != null && !tags.isEmpty()) { meta.put("tags", tags); } + // P1-2.1: 标记启用状态,用于 RAG 检索过滤 + meta.put("enabled", String.valueOf(Boolean.TRUE.equals(doc.getEnabled()))); splitDocuments.set(i, new Document(d.getId(), d.getText(), meta)); } @@ -107,6 +112,7 @@ public class DocumentProcessingService { /** * 异步重新处理文档(重新分块 + 向量化) + * 使用文档存储的 per-doc 分块参数(extraConfig),如无则使用全局配置 * * @param docId 文档ID * @param documents 解析后的文档列表 @@ -129,8 +135,18 @@ public class DocumentProcessingService { pgVectorVectorStore.delete(oldIds); } - // 重新分块 - List splitDocuments = myTokenTextSplitter.splitDocuments(documents); + // 从 extraConfig 读取 per-doc 分块参数 + Integer chunkSize = null; + Integer overlap = null; + if (doc.getExtraConfig() != null) { + Object cs = doc.getExtraConfig().get("chunkSize"); + Object ol = doc.getExtraConfig().get("overlap"); + if (cs instanceof Number) chunkSize = ((Number) cs).intValue(); + if (ol instanceof Number) overlap = ((Number) ol).intValue(); + } + + // 重新分块(使用 per-doc 参数或全局配置) + List splitDocuments = myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap); for (int i = 0; i < splitDocuments.size(); i++) { Document d = splitDocuments.get(i); @@ -145,6 +161,8 @@ public class DocumentProcessingService { if (doc.getTags() != null && doc.getTags().containsKey("tags")) { meta.put("tags", doc.getTags().get("tags")); } + // P1-2.1: 标记启用状态 + meta.put("enabled", String.valueOf(Boolean.TRUE.equals(doc.getEnabled()))); splitDocuments.set(i, new Document(d.getId(), d.getText(), meta)); } diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index d3d6c4e..7ceba21 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -24,6 +24,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import com.fasterxml.jackson.databind.ObjectMapper; + import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -86,12 +88,15 @@ public class DocumentService { * @param content 原文内容(截断预览) * @param categoryId 分类ID * @param tags 标签列表 + * @param chunkSize 分块大小(可选,覆盖全局配置) + * @param overlap 重叠大小(可选,覆盖全局配置) * @return 创建完成的文档记录(status=PROCESSING) */ @Transactional(rollbackFor = Exception.class) public KnowledgeDocument uploadDocument(List documents, String title, String sourceName, String fileType, Long fileSize, String content, - Long categoryId, List tags) { + Long categoryId, List tags, + Integer chunkSize, Integer overlap) { // 0. 内容去重检查 String contentHash = computeContentHash(content); if (contentHash != null) { @@ -101,6 +106,11 @@ public class DocumentService { } } + // 存储 per-doc 分块参数到 extraConfig(便于重新处理时复用) + Map extraConfig = new HashMap<>(); + if (chunkSize != null) extraConfig.put("chunkSize", chunkSize); + if (overlap != null) extraConfig.put("overlap", overlap); + // 1. 创建文档记录(状态 PROCESSING) KnowledgeDocument docRecord = KnowledgeDocument.builder() .title(title != null ? title : sourceName) @@ -111,6 +121,8 @@ public class DocumentService { .categoryId(categoryId != null ? categoryId : 0L) .tags(tags != null ? Map.of("tags", tags) : null) .contentHash(contentHash) + .enabled(true) + .extraConfig(extraConfig.isEmpty() ? null : extraConfig) .status("PROCESSING") .chunkCount(0) .build(); @@ -118,7 +130,8 @@ public class DocumentService { // 2. 触发异步处理(分块 → 关键词 → 向量化 → 更新状态) documentProcessingService.processDocumentAsync( - docRecord.getId(), documents, sourceName, title, categoryId, tags); + docRecord.getId(), documents, sourceName, title, categoryId, tags, + chunkSize, overlap); log.info("文档已创建,后台处理中: id={}, title={}", docRecord.getId(), docRecord.getTitle()); return docRecord; @@ -127,7 +140,8 @@ public class DocumentService { /** * 解析文件并上传 */ - public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List tags) { + public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List tags, + Integer chunkSize, Integer overlap) { List documents = tikaDocumentReader.read(file); String fileType = getFileExtension(file.getOriginalFilename()); return uploadDocument(documents, @@ -137,22 +151,24 @@ public class DocumentService { file.getSize(), documents.get(0).getText(), categoryId, - tags); + tags, chunkSize, overlap); } /** * 解析字符串并上传 */ - public KnowledgeDocument uploadString(String content, String title, Long categoryId, List tags) { + public KnowledgeDocument uploadString(String content, String title, Long categoryId, List tags, + Integer chunkSize, Integer overlap) { List documents = simpleStringDocumentReader.read(content); return uploadDocument(documents, title, title, "txt", - (long) content.length(), content, categoryId, tags); + (long) content.length(), content, categoryId, tags, chunkSize, overlap); } /** * 解析 Markdown 文件并上传 */ - public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List tags) { + public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List tags, + Integer chunkSize, Integer overlap) { List documents = markdownDocumentLoader.loadMarkdownFromFile(file); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); return uploadDocument(documents, @@ -162,13 +178,14 @@ public class DocumentService { file.getSize(), content, categoryId, - tags); + tags, chunkSize, overlap); } /** * 解析 JSON 文件(基本方式)并上传 */ - public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List tags) { + public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List tags, + Integer chunkSize, Integer overlap) { List documents = jsonDocumentLoader.loadBasicJson(file); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); return uploadDocument(documents, @@ -178,13 +195,15 @@ public class DocumentService { file.getSize(), content, categoryId, - tags); + tags, chunkSize, overlap); } /** * 解析 JSON 文件(按字段)并上传 */ - public KnowledgeDocument uploadJsonFields(MultipartFile file, List fields, String title, Long categoryId, List tags) { + public KnowledgeDocument uploadJsonFields(MultipartFile file, List fields, String title, + Long categoryId, List tags, + Integer chunkSize, Integer overlap) { List documents = jsonDocumentLoader.loadJsonByFields(file, fields.toArray(new String[0])); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); return uploadDocument(documents, @@ -194,13 +213,15 @@ public class DocumentService { file.getSize(), content, categoryId, - tags); + tags, chunkSize, overlap); } /** * 解析 JSON 文件(按指针)并上传 */ - public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title, Long categoryId, List tags) { + public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title, + Long categoryId, List tags, + Integer chunkSize, Integer overlap) { List documents = jsonDocumentLoader.loadJsonByPointer(file, pointer); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); return uploadDocument(documents, @@ -210,15 +231,15 @@ public class DocumentService { file.getSize(), content, categoryId, - tags); + tags, chunkSize, overlap); } // ==================== 文档管理 ==================== /** - * 分页查询文档列表(手动分页,支持关键词搜索) + * 分页查询文档列表(手动分页,支持关键词搜索 + 标签筛选) */ - public Map listDocuments(int page, int size, Long categoryId, String status, String keyword) { + public Map listDocuments(int page, int size, Long categoryId, String status, String keyword, String tag) { // 参数安全校验 if (page < 1) page = 1; if (size < 1 || size > 100) size = 10; @@ -237,6 +258,16 @@ public class DocumentService { if (kw != null) { countWrapper.and(w -> w.like("title", kw).or().like("source_name", kw)); } + // P1-2.3: 标签筛选(JSONB 包含查询,使用 ObjectMapper 构建合法 JSON) + if (tag != null && !tag.trim().isEmpty()) { + try { + ObjectMapper om = new ObjectMapper(); + String jsonArray = om.writeValueAsString(List.of(tag.trim())); + countWrapper.apply("tags->'tags' @> {0}::jsonb", jsonArray); + } catch (Exception e) { + log.warn("构建标签筛选 JSON 失败: tag={}", tag, e); + } + } // 先查询总数(不加 ORDER BY) Long total = documentMapper.selectCount(countWrapper); @@ -252,6 +283,15 @@ public class DocumentService { if (kw != null) { listWrapper.and(w -> w.like("title", kw).or().like("source_name", kw)); } + if (tag != null && !tag.trim().isEmpty()) { + try { + ObjectMapper om = new ObjectMapper(); + String jsonArray = om.writeValueAsString(List.of(tag.trim())); + listWrapper.apply("tags->'tags' @> {0}::jsonb", jsonArray); + } catch (Exception e) { + log.warn("构建标签筛选 JSON 失败: tag={}", tag, e); + } + } listWrapper.orderByDesc("create_time"); listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size); List records = documentMapper.selectList(listWrapper); @@ -453,6 +493,197 @@ public class DocumentService { log.info("更新文档元信息: id={}, title={}", id, doc.getTitle()); } + // ==================== 启用/禁用 ==================== + + /** + * P1-2.1: 切换文档启用/禁用状态 + * 禁用后不参与 RAG 检索但保留数据,同步更新 vector_store metadata + * 使用原子翻转 SQL 避免并发竞态 + */ + public KnowledgeDocument toggleDocument(Long id) { + // 原子翻转:直接在 DB 层 NOT enabled,避免先 SELECT 再 UPDATE 的竞态问题 + int updated = jdbcTemplate.update( + "UPDATE knowledge_document SET enabled = NOT enabled WHERE id = ? AND is_delete = false", + id); + if (updated == 0) { + throw new RuntimeException("文档不存在或已被删除"); + } + + // 查询最新状态 + KnowledgeDocument doc = documentMapper.selectById(id); + if (doc == null) { + throw new RuntimeException("文档不存在"); + } + + // 同步更新 vector_store 中的 enabled metadata + syncVectorEnabledMetadata(String.valueOf(id), Boolean.TRUE.equals(doc.getEnabled())); + log.info("文档状态切换: id={}, enabled={}", id, doc.getEnabled()); + return doc; + } + + /** + * P1-2.1: 批量启用/禁用文档 + */ + public Map batchToggleDocuments(List ids, boolean enabled) { + int successCount = 0; + int failCount = 0; + for (Long id : ids) { + try { + KnowledgeDocument doc = documentMapper.selectById(id); + if (doc == null) { + failCount++; + continue; + } + doc.setEnabled(enabled); + documentMapper.updateById(doc); + syncVectorEnabledMetadata(String.valueOf(id), enabled); + successCount++; + } catch (Exception e) { + failCount++; + log.warn("批量切换文档状态失败: id={}", id, e); + } + } + Map result = new HashMap<>(); + result.put("successCount", successCount); + result.put("failCount", failCount); + return result; + } + + /** + * 同步更新 vector_store 中指定文档所有分块的 enabled metadata + * 使用 JdbcTemplate 直接 UPDATE JSONB 字段 + */ + private void syncVectorEnabledMetadata(String documentId, boolean enabled) { + String sql = "UPDATE vector_store SET metadata = jsonb_set(metadata, '{enabled}', ?) " + + "WHERE metadata->>'documentId' = ?"; + // 写入 JSONB 布尔值(true/false),而非字符串 "true"/"false" + jdbcTemplate.update(sql, enabled ? "true" : "false", documentId); + } + + // ==================== 批量移动分类 ==================== + + /** + * P1-2.2: 批量移动文档到目标分类 + * 仅更新 category_id,不影响向量数据 + */ + @Transactional(rollbackFor = Exception.class) + public int batchMoveDocuments(List ids, Long categoryId) { + Long targetCategoryId = categoryId != null ? categoryId : 0L; + int updated = 0; + for (Long id : ids) { + KnowledgeDocument doc = documentMapper.selectById(id); + if (doc != null) { + doc.setCategoryId(targetCategoryId); + documentMapper.updateById(doc); + updated++; + } + } + log.info("批量移动文档分类: ids={}, categoryId={}, 实际更新={}", ids, targetCategoryId, updated); + return updated; + } + + // ==================== 标签管理 ==================== + + /** + * P1-2.3: 获取标签列表(从所有文档的 tags JSONB 聚合去重,含使用次数) + * + * @return [{tag: "标签名", count: 使用次数}] + */ + public List> getTagList() { + String sql = "SELECT tag_value AS tag, COUNT(*) AS count " + + "FROM knowledge_document, jsonb_array_elements(tags->'tags') AS tag_value " + + "WHERE is_delete = false AND tags IS NOT NULL AND tags->'tags' IS NOT NULL " + + "GROUP BY tag_value ORDER BY count DESC, tag_value ASC"; + try { + return jdbcTemplate.queryForList(sql); + } catch (Exception e) { + log.warn("获取标签列表失败", e); + return List.of(); + } + } + + // ==================== 分块管理 ==================== + + /** + * P1-2.5: 更新单个分块内容(同步更新 vector_store 并重新向量化) + */ + @Transactional(rollbackFor = Exception.class) + public void updateChunk(Long docId, int chunkIndex, String newContent) { + KnowledgeDocument doc = documentMapper.selectById(docId); + if (doc == null) { + throw new RuntimeException("文档不存在"); + } + + String docIdStr = String.valueOf(docId); + // 查找对应的向量记录 ID + String selectSql = "SELECT id::text FROM vector_store " + + "WHERE (metadata->>'documentId' = ? OR metadata->>'document_id' = ?) " + + "AND metadata->>'chunkIndex' = ? " + + "LIMIT 1"; + List vectorIds = jdbcTemplate.queryForList(selectSql, String.class, + docIdStr, docIdStr, String.valueOf(chunkIndex)); + + if (vectorIds.isEmpty()) { + throw new RuntimeException("未找到对应的分块向量记录"); + } + + String vectorId = vectorIds.get(0); + + // 删除旧向量 + pgVectorVectorStore.delete(List.of(vectorId)); + + // 构建新文档并重新向量化 + Map meta = new HashMap<>(); + meta.put("documentId", docIdStr); + meta.put("chunkIndex", chunkIndex); + meta.put("sourceName", doc.getSourceName()); + meta.put("title", doc.getTitle()); + if (doc.getCategoryId() != null && doc.getCategoryId() > 0) { + meta.put("categoryId", String.valueOf(doc.getCategoryId())); + } + if (doc.getTags() != null && doc.getTags().containsKey("tags")) { + meta.put("tags", doc.getTags().get("tags")); + } + // 标记启用状态,与文档当前状态保持一致 + meta.put("enabled", String.valueOf(Boolean.TRUE.equals(doc.getEnabled()))); + + Document newDoc = new Document(vectorId, newContent, meta); + // 关键词提取 + List enriched = myKeywordEnricher.enrichDocuments(List.of(newDoc)); + pgVectorVectorStore.add(enriched); + + log.info("更新分块: docId={}, chunkIndex={}, vectorId={}", docId, chunkIndex, vectorId); + } + + /** + * P1-2.5: 删除单个分块(同步从 vector_store 移除向量) + */ + @Transactional(rollbackFor = Exception.class) + public void deleteChunk(Long docId, int chunkIndex) { + KnowledgeDocument doc = documentMapper.selectById(docId); + if (doc == null) { + throw new RuntimeException("文档不存在"); + } + + String docIdStr = String.valueOf(docId); + // 查找对应的向量记录 ID + String selectSql = "SELECT id::text FROM vector_store " + + "WHERE (metadata->>'documentId' = ? OR metadata->>'document_id' = ?) " + + "AND metadata->>'chunkIndex' = ?"; + List vectorIds = jdbcTemplate.queryForList(selectSql, String.class, + docIdStr, docIdStr, String.valueOf(chunkIndex)); + + if (!vectorIds.isEmpty()) { + pgVectorVectorStore.delete(vectorIds); + } + + // 更新文档的分块计数 + doc.setChunkCount(Math.max(0, (doc.getChunkCount() != null ? doc.getChunkCount() : 1) - vectorIds.size())); + documentMapper.updateById(doc); + + log.info("删除分块: docId={}, chunkIndex={}, 删除向量数={}", docId, chunkIndex, vectorIds.size()); + } + // ==================== 语义搜索 ==================== /** @@ -472,13 +703,20 @@ public class DocumentService { .topK(topK) .similarityThreshold(similarityThreshold); + FilterExpressionBuilder builder = new FilterExpressionBuilder(); + + // P1-2.1: 始终过滤禁用的文档,只检索启用的 + var enabledFilter = builder.eq("enabled", "true"); + List categoryIdStrings = normalizeCategoryIds(categoryIds); if (!categoryIdStrings.isEmpty()) { - FilterExpressionBuilder builder = new FilterExpressionBuilder(); List values = categoryIdStrings.stream() .map(value -> (Object) value) .collect(Collectors.toList()); - searchBuilder.filterExpression(builder.in("categoryId", values).build()); + var categoryFilter = builder.in("categoryId", values); + searchBuilder.filterExpression(builder.and(enabledFilter, categoryFilter).build()); + } else { + searchBuilder.filterExpression(enabledFilter.build()); } List results; @@ -496,6 +734,7 @@ public class DocumentService { .build()) .stream() .filter(doc -> categoryIdStrings.contains(getStringFromMetadata(doc.getMetadata(), "categoryId"))) + .filter(doc -> "true".equals(getStringFromMetadata(doc.getMetadata(), "enabled"))) .limit(topK) .collect(Collectors.toList()); } diff --git a/src/main/resources/static/components/DocDetail.js b/src/main/resources/static/components/DocDetail.js index 8975c5b..03bc347 100644 --- a/src/main/resources/static/components/DocDetail.js +++ b/src/main/resources/static/components/DocDetail.js @@ -1,14 +1,18 @@ /** * 📄 文档详情弹窗 + * P1-2.5: 分块编辑/删除 + * P1-2.6: Markdown 渲染预览 */ -import { computed } from 'vue' +import { computed, ref } from 'vue' import { store } from '../js/store.js' -import { formatBytes, formatDate } from '../js/utils.js' +import { updateChunk, deleteChunk } from '../js/api.js' +import { formatBytes, formatDate, renderMarkdown } from '../js/utils.js' +import { toast } from '../js/utils.js' export default { template: `
- +

分块详情({{ store.detailModal.chunks.length }} 个)

{{ store.detailModal.chunksError }}
-
-
#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }}
-
{{ chunk.content || '' }}
+
+
+ #{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }} + + + + + +
+ +
+ +
+ + +
+
+ +
{{ chunk.content || '' }}
@@ -46,6 +72,19 @@ export default { : 'status-failed' }) + // P1-2.6: 判断是否为 Markdown 类型 + const isMarkdown = computed(() => { + const fileType = store.detailModal.doc?.fileType + return fileType === 'md' || fileType === 'markdown' + }) + + // P1-2.6: Markdown 渲染后的内容 + const renderedContent = computed(() => { + const content = store.detailModal.doc?.content + if (!content) return '

无内容

' + return renderMarkdown(content) + }) + function getKeywords(chunk) { let meta = chunk.metadata if (typeof meta === 'object' && meta && meta.value) meta = meta.value @@ -57,6 +96,91 @@ export default { } } - return { store, statusClass, getKeywords, formatBytes, formatDate } + /** + * P0-3: 从 chunk metadata 中获取真实的 chunkIndex + * 避免使用 v-for 数组索引(删除/编辑后索引会偏移) + */ + function getRealChunkIndex(chunk, fallbackIdx) { + let meta = chunk.metadata + if (typeof meta === 'object' && meta && meta.value) meta = meta.value + try { + const m = typeof meta === 'string' ? JSON.parse(meta) : meta + if (m && m.chunkIndex !== undefined && m.chunkIndex !== null) { + const idx = parseInt(m.chunkIndex, 10) + if (!isNaN(idx)) return idx + } + } catch (e) { + // 解析失败,使用 fallback + } + return fallbackIdx + } + + // P1-2.5: 分块编辑状态 + const editIdx = ref(-1) + const editContent = ref('') + const editSaving = ref(false) + + function startEdit(idx, chunk) { + editIdx.value = idx + editContent.value = chunk.content || '' + } + + function cancelEdit() { + editIdx.value = -1 + editContent.value = '' + } + + async function saveEdit(idx) { + const docId = store.detailModal.doc?.id + if (!docId) return + // P0-3: 使用真实 chunkIndex 而非数组索引 + const chunk = store.detailModal.chunks[idx] + const realIdx = getRealChunkIndex(chunk, idx) + editSaving.value = true + try { + const json = await updateChunk(docId, realIdx, editContent.value) + if (json.success) { + toast(json.message || '分块已更新', 'success') + editIdx.value = -1 + // 重新加载分块 + await store.openDetail(docId) + } else { + toast(json.message || '更新失败', 'error') + } + } catch (e) { + toast('更新分块失败:' + e.message, 'error') + } finally { + editSaving.value = false + } + } + + // P1-2.5: 删除分块 + async function removeChunk(idx) { + const docId = store.detailModal.doc?.id + if (!docId) return + // P0-3: 使用真实 chunkIndex 而非数组索引 + const chunk = store.detailModal.chunks[idx] + const realIdx = getRealChunkIndex(chunk, idx) + if (!confirm(`确定删除第 ${realIdx + 1} 个分块?对应的向量也将被移除`)) return + try { + const json = await deleteChunk(docId, realIdx) + if (json.success) { + toast(json.message || '分块已删除', 'success') + // 重新加载分块和文档详情 + await store.openDetail(docId) + } else { + toast(json.message || '删除失败', 'error') + } + } catch (e) { + toast('删除分块失败:' + e.message, 'error') + } + } + + return { + store, statusClass, isMarkdown, renderedContent, getKeywords, getRealChunkIndex, + formatBytes, formatDate, renderMarkdown, + editIdx, editContent, editSaving, + startEdit, cancelEdit, saveEdit, removeChunk + } } } diff --git a/src/main/resources/static/components/DocList.js b/src/main/resources/static/components/DocList.js index 7197958..316e96a 100644 --- a/src/main/resources/static/components/DocList.js +++ b/src/main/resources/static/components/DocList.js @@ -3,10 +3,13 @@ * P0-1: 关键词搜索(标题/文件名模糊匹配) * P0-2: 文件大小列 + 分类名称列 * P0-4: 处理中状态自动轮询刷新 + * P1-2.1: 文档启用/禁用(开关按钮 + 批量操作) + * P1-2.2: 批量移动分类 + * P1-2.3: 标签筛选 + 标签 badge 展示 */ import { ref, computed, onUnmounted } from 'vue' import { store } from '../js/store.js' -import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments } from '../js/api.js' +import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments } from '../js/api.js' import { toast, formatDate, formatBytes } from '../js/utils.js' export default { @@ -25,6 +28,11 @@ export default { + + @@ -35,6 +43,18 @@ export default { 已选 {{ selectedIds.size }} 项 + + + + +
+ + +
@@ -49,6 +69,7 @@ export default { 大小 分类 状态 + 启用 分块数 创建时间 操作 @@ -56,18 +77,31 @@ export default { - 暂无文档 + 暂无文档 - + {{ d.title }}
{{ d.sourceName || '' }} + +
+ {{ tag }} +
{{ d.fileType }} {{ formatSize(d.fileSize) }} {{ store.getCategoryName(d.categoryId) }} {{ statusLabel(d.status) }} + + + + {{ d.chunkCount || 0 }} {{ formatDate(d.createTime) }} @@ -99,7 +133,9 @@ export default { const filterCategory = ref('') const filterStatus = ref('') const keyword = ref('') + const filterTag = ref('') const selectedIds = ref(new Set()) + const moveCategoryId = ref('') // 防抖搜索 let debounceTimer = null @@ -162,6 +198,22 @@ export default { return formatBytes(bytes) } + /** P1-2.3: 从文档的 tags JSONB 中提取标签数组 */ + function getDocTags(doc) { + if (!doc.tags) return [] + const t = doc.tags + if (t.tags && Array.isArray(t.tags)) return t.tags + if (Array.isArray(t)) return t + return [] + } + + /** P1-2.1: 禁用文档行灰色标记 */ + function getRowStyle(doc) { + const base = selectedIds.value.has(doc.id) ? 'background:#f3f4f6;' : '' + if (doc.enabled === false) return base + 'opacity:0.5;' + return base + } + async function load(p = 1) { page.value = p // 切换页面时清空选择 @@ -170,7 +222,8 @@ export default { const json = await listDocuments(p, 10, filterCategory.value || undefined, filterStatus.value || undefined, - keyword.value.trim() || undefined) + keyword.value.trim() || undefined, + filterTag.value || undefined) if (json.success) { documents.value = json.data || [] total.value = json.total || 0 @@ -212,6 +265,24 @@ export default { store.openDetail(id) } + // P1-2.1: 切换单个文档启用/禁用 + async function toggle(doc) { + try { + const json = await toggleDocument(doc.id) + if (json.success) { + doc.enabled = json.data.enabled + toast(json.message, 'success') + } else { + toast(json.message || '操作失败', 'error') + // 恢复开关状态 + load(page.value) + } + } catch (e) { + toast('操作失败:' + e.message, 'error') + load(page.value) + } + } + async function remove(id) { if (!confirm('确定删除此文档?关联的向量也将被删除')) return try { @@ -235,7 +306,6 @@ export default { if (json.success) { toast(json.message || '已提交重新处理', 'success') load(page.value) - // 触发轮询 if (!pollTimer) startPolling() } else { toast(json.message || '重新处理失败', 'error') @@ -272,7 +342,6 @@ export default { toast(json.message, 'success') selectedIds.value = new Set() load(page.value) - // 触发轮询 if (!pollTimer) startPolling() } else { toast(json.message || '批量重新处理失败', 'error') @@ -282,12 +351,75 @@ export default { } } + // P1-2.1: 批量启用 + async function batchEnable() { + const ids = Array.from(selectedIds.value) + if (!confirm(`确定启用选中的 ${ids.length} 个文档?`)) return + try { + const json = await batchToggleDocuments(ids, true) + if (json.success) { + toast(json.message, 'success') + selectedIds.value = new Set() + load(page.value) + } else { + toast(json.message || '批量启用失败', 'error') + } + } catch (e) { + toast('批量启用失败:' + e.message, 'error') + } + } + + // P1-2.1: 批量禁用 + async function batchDisable() { + const ids = Array.from(selectedIds.value) + if (!confirm(`确定禁用选中的 ${ids.length} 个文档?禁用后将不参与 RAG 检索`)) return + try { + const json = await batchToggleDocuments(ids, false) + if (json.success) { + toast(json.message, 'success') + selectedIds.value = new Set() + load(page.value) + } else { + toast(json.message || '批量禁用失败', 'error') + } + } catch (e) { + toast('批量禁用失败:' + e.message, 'error') + } + } + + // P1-2.2: 批量移动分类 + async function batchMove() { + const ids = Array.from(selectedIds.value) + const catId = moveCategoryId.value + if (!catId && catId !== '0') { + toast('请选择目标分类', 'error') + return + } + const catName = catId === '0' ? '未分类' : store.getCategoryName(catId) + if (!confirm(`确定将选中的 ${ids.length} 个文档移动到「${catName}」?`)) return + try { + const json = await batchMoveDocuments(ids, catId === '0' ? 0 : parseInt(catId)) + if (json.success) { + toast(json.message, 'success') + selectedIds.value = new Set() + moveCategoryId.value = '' + load(page.value) + store.loadStats() + } else { + toast(json.message || '批量移动失败', 'error') + } + } catch (e) { + toast('批量移动失败:' + e.message, 'error') + } + } + return { - documents, page, pages, total, filterCategory, filterStatus, keyword, - selectedIds, store, statusClass, statusLabel, isAllSelected, hasProcessing, - formatSize, debouncedLoad, + documents, page, pages, total, filterCategory, filterStatus, keyword, filterTag, + selectedIds, moveCategoryId, store, statusClass, statusLabel, isAllSelected, hasProcessing, + formatSize, debouncedLoad, getDocTags, getRowStyle, load, toggleSelect, toggleSelectAll, clearSelection, - viewDetail, remove, reprocess, batchRemove, batchReprocess, formatDate + viewDetail, toggle, remove, reprocess, batchRemove, batchReprocess, + batchEnable, batchDisable, batchMove, formatDate } } } diff --git a/src/main/resources/static/components/DocUpload.js b/src/main/resources/static/components/DocUpload.js index 6ee6dec..19a28e5 100644 --- a/src/main/resources/static/components/DocUpload.js +++ b/src/main/resources/static/components/DocUpload.js @@ -2,7 +2,7 @@ * 📤 文档上传面板 * 支持 6 种上传格式 + 前端校验 + 上传进度 + 分块配置 */ -import { ref, reactive } from 'vue' +import { ref, reactive, computed } from 'vue' import { store } from '../js/store.js' import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js' import { toast, formatBytes, authHeaders, API_BASE } from '../js/utils.js' @@ -30,7 +30,28 @@ export default { - + +
+
+ + {{ tag }}× + + +
+ +
+
+ {{ s.tag }} ({{ s.count }}) +
+
+
@@ -160,6 +181,49 @@ export default { const jsonBInput = ref(null) const jsonFInput = ref(null) const jsonPInput = ref(null) + const tagInputRef = ref(null) + + // P1-2.3: 标签输入状态 + const tagList = ref([]) + const tagInputValue = ref('') + const tagInputFocused = ref(false) + + const filteredSuggestions = computed(() => { + const input = tagInputValue.value.trim().toLowerCase() + if (!input) return (store.tags || []).slice(0, 10) + return (store.tags || []) + .filter(t => t.tag.toLowerCase().includes(input) && !tagList.value.includes(t.tag)) + .slice(0, 8) + }) + + function addTag() { + const val = tagInputValue.value.trim().replace(/,/g, '') + if (val && !tagList.value.includes(val)) { + tagList.value.push(val) + } + tagInputValue.value = '' + } + + function removeTag(idx) { + tagList.value.splice(idx, 1) + } + + function selectSuggestion(tag) { + if (!tagList.value.includes(tag)) { + tagList.value.push(tag) + } + tagInputValue.value = '' + tagInputFocused.value = false + } + + function onTagInput() { + // 输入时自动显示建议列表 + } + + function onTagBlur() { + // 延迟关闭建议列表,让 mousedown 事件先触发 + setTimeout(() => { tagInputFocused.value = false }, 200) + } const fileData = reactive({ file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null @@ -265,7 +329,8 @@ export default { async function doUpload(type) { const catId = uploadCategory.value - const tagsStr = uploadTags.value.trim() + // P1-2.3: 使用标签列表替代逗号分隔输入 + const tagsStr = tagList.value.length > 0 ? tagList.value.join(',') : uploadTags.value.trim() uploadProgress.value = -1 // 文本内容上传 @@ -320,6 +385,9 @@ export default { formData.append('file', file) if (catId) formData.append('categoryId', catId) if (tagsStr) formData.append('tags', tagsStr) + // P1-2.4: 传递 per-doc 分块参数 + if (chunkSizeOverride.value) formData.append('chunkSize', chunkSizeOverride.value) + if (overlapOverride.value) formData.append('overlap', overlapOverride.value) let json const onProgress = files.length === 1 ? (p) => { uploadProgress.value = p } : null @@ -391,6 +459,8 @@ export default { showAdvanced, chunkSizeOverride, overlapOverride, uploadProgress, fileInput, mdInput, jsonBInput, jsonFInput, jsonPInput, + tagInputRef, tagList, tagInputValue, tagInputFocused, + filteredSuggestions, addTag, removeTag, selectSuggestion, onTagInput, onTagBlur, fileData, fileInfo, results, validationErrors, store, getButtonState, handleFileSelect, handleDrop, doUpload, formatBytes diff --git a/src/main/resources/static/components/ModelConfigManager.js b/src/main/resources/static/components/ModelConfigManager.js index ab967bf..97c3600 100644 --- a/src/main/resources/static/components/ModelConfigManager.js +++ b/src/main/resources/static/components/ModelConfigManager.js @@ -1,8 +1,10 @@ /** * ⚙️ AI 模型配置管理组件 - * 展示模型配置列表、新增/编辑/激活/删除配置 + * 展示模型配置列表、新增/编辑/激活/删除/测试/复制/导入导出配置 + * F1: 连接测试 | F2: 配置复制 | F3: 高级参数面板 | F4: 健康看板 + * F5: 模型能力元信息 | F6: Fallback 链 | F7: 导入导出 */ -import { ref, onMounted, watch } from 'vue' +import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import * as api from '../js/api.js' import { toast, formatDate } from '../js/utils.js' @@ -11,7 +13,8 @@ const APP_TYPE_OPTIONS = [ { value: '', label: '全部类型' }, { value: 'CHAT', label: '智能客服对话' }, { value: 'EMBEDDING', label: '文本向量化' }, - { value: 'RAG_REWRITE', label: 'RAG查询重写' } + { value: 'RAG_REWRITE', label: 'RAG查询重写' }, + { value: 'RERANK', label: '重排序' } ] // 提供商选项 @@ -25,20 +28,29 @@ const PROVIDER_OPTIONS = [ { value: 'other', label: '其他' } ] -// 提供商默认配置(切换时自动填充) -// chatModels / embeddingModels 按 app_type 分别展示,models 作为向后兼容兜底 +// F5: 提供商默认配置(含模型能力元信息) const PROVIDER_DEFAULTS = { dashscope: { baseUrl: '', - chatModels: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-long'], - embeddingModels: ['text-embedding-v2'], + chatModels: [ + { name: 'qwen-turbo', maxContextTokens: 131072, maxOutputTokens: 8192, supportsVision: false, supportsFunctionCall: true }, + { name: 'qwen-plus', maxContextTokens: 131072, maxOutputTokens: 8192, supportsVision: false, supportsFunctionCall: true }, + { name: 'qwen-max', maxContextTokens: 32768, maxOutputTokens: 8192, supportsVision: false, supportsFunctionCall: true }, + { name: 'qwen-long', maxContextTokens: 10000000, maxOutputTokens: 6000, supportsVision: false, supportsFunctionCall: false } + ], + embeddingModels: [ + { name: 'text-embedding-v2', maxContextTokens: 8192, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false } + ], defaultChatModel: 'qwen-turbo', defaultEmbeddingModel: 'text-embedding-v2', tip: '通义千问团队版/私有化部署需填写专属 Base URL,公共版可留空' }, deepseek: { baseUrl: 'https://api.deepseek.com', - chatModels: ['deepseek-chat', 'deepseek-reasoner'], + chatModels: [ + { name: 'deepseek-chat', maxContextTokens: 65536, maxOutputTokens: 8192, supportsVision: false, supportsFunctionCall: true }, + { name: 'deepseek-reasoner', maxContextTokens: 65536, maxOutputTokens: 8192, supportsVision: false, supportsFunctionCall: false } + ], embeddingModels: [], defaultChatModel: 'deepseek-chat', defaultEmbeddingModel: '', @@ -46,15 +58,29 @@ const PROVIDER_DEFAULTS = { }, volcengine: { baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', - chatModels: ['doubao-1-5-pro-32k', 'doubao-1-5-lite-32k', 'doubao-pro-32k', 'doubao-1-5-thinking-pro', 'doubao-seed-1.6'], - embeddingModels: ['doubao-embedding-text-240515', 'doubao-embedding-large', 'doubao-embedding-vision-251215'], + chatModels: [ + { name: 'doubao-1-5-pro-32k', maxContextTokens: 32768, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, + { name: 'doubao-1-5-lite-32k', maxContextTokens: 32768, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, + { name: 'doubao-pro-32k', maxContextTokens: 32768, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, + { name: 'doubao-1-5-thinking-pro', maxContextTokens: 131072, maxOutputTokens: 16384, supportsVision: false, supportsFunctionCall: true }, + { name: 'doubao-seed-1.6', maxContextTokens: 131072, maxOutputTokens: 16384, supportsVision: true, supportsFunctionCall: true } + ], + embeddingModels: [ + { name: 'doubao-embedding-text-240515', maxContextTokens: 8192, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false }, + { name: 'doubao-embedding-large', maxContextTokens: 8192, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false }, + { name: 'doubao-embedding-vision-251215', maxContextTokens: 8192, maxOutputTokens: 0, supportsVision: true, supportsFunctionCall: false } + ], defaultChatModel: 'doubao-1-5-pro-32k', defaultEmbeddingModel: 'doubao-embedding-text-240515', tip: '豆包对话推荐 doubao-1-5-pro-32k;向量化推荐 doubao-embedding-text-240515(2048维)。模型名称可在火山引擎 ARK 控制台获取' }, moonshot: { baseUrl: 'https://api.moonshot.cn/v1', - chatModels: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'], + chatModels: [ + { name: 'moonshot-v1-8k', maxContextTokens: 8192, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, + { name: 'moonshot-v1-32k', maxContextTokens: 32768, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, + { name: 'moonshot-v1-128k', maxContextTokens: 131072, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true } + ], embeddingModels: [], defaultChatModel: 'moonshot-v1-8k', defaultEmbeddingModel: '', @@ -62,7 +88,12 @@ const PROVIDER_DEFAULTS = { }, zhipu: { baseUrl: 'https://open.bigmodel.cn/api/paas/v4', - chatModels: ['glm-4-plus', 'glm-4-flash', 'glm-4-long', 'glm-4'], + chatModels: [ + { name: 'glm-4-plus', maxContextTokens: 128000, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, + { name: 'glm-4-flash', maxContextTokens: 128000, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, + { name: 'glm-4-long', maxContextTokens: 1000000, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true }, + { name: 'glm-4', maxContextTokens: 128000, maxOutputTokens: 4096, supportsVision: true, supportsFunctionCall: true } + ], embeddingModels: [], defaultChatModel: 'glm-4-flash', defaultEmbeddingModel: '', @@ -70,8 +101,16 @@ const PROVIDER_DEFAULTS = { }, openai: { baseUrl: 'https://api.openai.com', - chatModels: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'], - embeddingModels: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'], + chatModels: [ + { name: 'gpt-4o', maxContextTokens: 128000, maxOutputTokens: 16384, supportsVision: true, supportsFunctionCall: true }, + { name: 'gpt-4o-mini', maxContextTokens: 128000, maxOutputTokens: 16384, supportsVision: true, supportsFunctionCall: true }, + { name: 'gpt-3.5-turbo', maxContextTokens: 16385, maxOutputTokens: 4096, supportsVision: false, supportsFunctionCall: true } + ], + embeddingModels: [ + { name: 'text-embedding-3-small', maxContextTokens: 8191, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false }, + { name: 'text-embedding-3-large', maxContextTokens: 8191, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false }, + { name: 'text-embedding-ada-002', maxContextTokens: 8191, maxOutputTokens: 0, supportsVision: false, supportsFunctionCall: false } + ], defaultChatModel: 'gpt-4o-mini', defaultEmbeddingModel: 'text-embedding-3-small', tip: '' @@ -86,20 +125,75 @@ const PROVIDER_DEFAULTS = { } } +// 格式化工具:Token 数显示 +function formatTokens(n) { + if (!n) return '未知' + if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M' + if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'K' + return String(n) +} + export default { template: `

⚙️ AI 大模型配置管理

- -
+ +
+
+
{{ healthSummary.online }}
+
🟢 在线
+
+
+
{{ healthSummary.offline }}
+
🔴 离线
+
+
+
{{ healthSummary.total }}
+
📊 总计
+
+
+ + +
+ + +
+ +
+
🔗 Fallback 链(按优先级排列,主模型在顶部)
+
+
{{ getAppTypeLabel(chain.appType) }}
+
+ #{{ idx + 1 }} + + · + {{ item.name }} + {{ item.model_name }} + P={{ item.priority }} + + + {{ healthMap[item.id].status === 'ONLINE' ? '🟢' : '🔴' }} + {{ healthMap[item.id].latencyMs }}ms + + 🟡 +
+
+
+
@@ -111,7 +205,7 @@ export default { - + @@ -127,22 +221,48 @@ export default { - + + - @@ -159,6 +279,18 @@ export default { + + +
+
{{ testResult.success ? '✅ 连接成功' : '❌ 连接失败' }}
+
+ 延迟:{{ testResult.latencyMs }}ms + · 维度:{{ testResult.dimensions }} +
+
{{ testResult.errorMessage }}
+
@@ -208,12 +340,26 @@ export default {
+ - + + +
+ 📐 {{ formatTokens(selectedModelMeta.maxContextTokens) }} 上下文 · {{ formatTokens(selectedModelMeta.maxOutputTokens) }} 最大输出 + · 👁 视觉 + · 🔧 函数调用 +
@@ -224,8 +370,15 @@ export default {
- - + + +
@@ -243,10 +396,42 @@ export default {
⚠️ 修改维度后需重建向量表:DROP TABLE IF EXISTS vector_store CASCADE,然后重启服务并重新上传知识库文档。 - 常用维度:千问 text-embedding-v2=1024 | 豆包 doubao-embedding-text=2048 | OpenAI text-embedding-3-small=1536
+ +
+
+ + ⚡ 高级生成参数 + (已配置) + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
@@ -255,7 +440,7 @@ export default {
@@ -267,12 +452,58 @@ export default {
-
+
+ +
+ + + `, setup() { const configs = ref([]) @@ -283,11 +514,32 @@ export default { const showApiKey = ref(false) const customModelName = ref('') const providerTip = ref('') + const advancedParamsOpen = ref(false) + + // F4: 健康状态 + const healthMap = ref({}) + const healthSummary = ref(null) + let healthTimer = null - // 计算当前提供商的模型列表和默认值 + // F5: 当前提供商的模型列表和默认值 const currentProviderModels = ref([]) const currentProviderDefaultModel = ref('') const providerBaseUrlPlaceholder = ref('') + const selectedModelMeta = ref(null) + + // F1: 测试状态 + const testLoading = ref({}) + const testResult = ref({ visible: false, success: false, latencyMs: 0 }) + let testResultTimer = null + + // F6: Fallback 链 + const fallbackChains = ref([]) + + // F7: 导入导出 + const showImportDialog = ref(false) + const importPreview = ref(null) + const importConflict = ref('skip') + const importResult = ref(null) // 编辑弹窗状态 const editModal = ref({ @@ -308,44 +560,74 @@ export default { max_tokens: 2000, base_url: '', embeddingDimensions: 1024, + // F3: 高级参数 + topP: null, + topK: null, + frequencyPenalty: null, + presencePenalty: null, + stopSequences: '', + // 基础字段 priority: 0, is_active: false, description: '' } } - // 提供商切换时自动填充(按 app_type 区分对话/向量模型) + // F3: 检查是否配置了高级参数 + const hasAdvancedParams = computed(() => { + const f = editModal.value.form + return f.topP != null || f.topK != null || f.frequencyPenalty != null || + f.presencePenalty != null || (f.stopSequences && f.stopSequences.trim()) + }) + + // 提供商切换时自动填充 function onProviderChange() { const provider = editModal.value.form.provider const defaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.other const isEmbedding = editModal.value.form.app_type === 'EMBEDDING' - // 仅在新建模式下自动填充 Base URL,编辑模式保留用户已配置的值 if (editModal.value.mode !== 'edit') { editModal.value.form.base_url = defaults.baseUrl } - // 按 app_type 选择模型列表和默认模型 const models = isEmbedding ? (defaults.embeddingModels || []) : (defaults.chatModels || []) const defaultModel = isEmbedding ? (defaults.defaultEmbeddingModel || '') : (defaults.defaultChatModel || '') - // 仅新建模式且模型名为空时自动填充默认模型 if (editModal.value.mode === 'add' && !editModal.value.form.model_name) { editModal.value.form.model_name = defaultModel } + // F5: 使用对象格式的模型列表 currentProviderModels.value = models currentProviderDefaultModel.value = defaultModel providerTip.value = defaults.tip || '' providerBaseUrlPlaceholder.value = defaults.baseUrl || '私有化部署时可填写自定义地址' customModelName.value = '' + onModelChange() } - // 监听 app_type 变化,刷新模型列表 - watch(() => editModal.value.form.app_type, () => { - if (editModal.value.visible) { - onProviderChange() + // F5: 模型切换时更新能力元信息 + function onModelChange() { + const modelName = editModal.value.form.model_name + if (modelName === '__custom__') { + selectedModelMeta.value = null + return } + const found = currentProviderModels.value.find(m => m.name === modelName) + selectedModelMeta.value = found || null + } + + // F5: 获取列表中某个配置对应模型的能力元信息 + function getModelMeta(config) { + const provider = config.provider + const defaults = PROVIDER_DEFAULTS[provider] + if (!defaults) return null + const allModels = [...(defaults.chatModels || []), ...(defaults.embeddingModels || [])] + return allModels.find(m => m.name === config.model_name) || null + } + + watch(() => editModal.value.form.app_type, () => { + if (editModal.value.visible) onProviderChange() }) // ==================== 数据加载 ==================== @@ -358,6 +640,10 @@ export default { configs.value = json.data || [] total.value = json.total || 0 totalPages.value = json.pages || 1 + // 加载后刷新健康状态 + loadHealthStatus() + // 构建 Fallback 链 + buildFallbackChains() } else { toast(json.message || '查询失败', 'error') } @@ -366,6 +652,75 @@ export default { } } + // ==================== F4: 健康状态 ==================== + + async function loadHealthStatus() { + try { + const json = await api.getHealthStatus() + if (json.success && json.data) { + const data = { ...json.data } + healthSummary.value = data._summary || null + delete data._summary + healthMap.value = data + } + } catch (e) { + // 静默失败 + } + } + + function getHealthTooltip(configId) { + const h = healthMap.value[configId] + if (!h) return '未检测' + let tip = h.status === 'ONLINE' ? '在线' : '离线' + if (h.latencyMs) tip += ` · 延迟 ${h.latencyMs}ms` + if (h.lastCheckTime) tip += ` · 最后检测 ${new Date(h.lastCheckTime).toLocaleTimeString()}` + if (h.errorMessage) tip += ` · ${h.errorMessage}` + return tip + } + + // ==================== F6: Fallback 链 ==================== + + function buildFallbackChains() { + const chains = [] + const appTypes = ['CHAT', 'EMBEDDING', 'RAG_REWRITE', 'RERANK'] + for (const appType of appTypes) { + const activeConfigs = configs.value + .filter(c => c.app_type === appType && c.is_active) + .sort((a, b) => (b.priority || 0) - (a.priority || 0)) + if (activeConfigs.length > 0) { + chains.push({ appType, configs: activeConfigs }) + } + } + fallbackChains.value = chains + } + + // F6: 拖拽排序 + let dragItem = null + let dragChain = null + + function onDragStart(e, item, chain) { + dragItem = item + dragChain = chain + e.dataTransfer.effectAllowed = 'move' + } + + async function onDrop(e, targetItem, chain) { + if (!dragItem || dragItem.id === targetItem.id) return + // 交换优先级 + const fromPriority = dragItem.priority || 0 + const toPriority = targetItem.priority || 0 + try { + await api.updateModelConfigPriority(dragItem.id, toPriority) + await api.updateModelConfigPriority(targetItem.id, fromPriority) + toast('优先级已调整', 'success') + load(currentPage.value) + } catch (err) { + toast('调整失败:' + err.message, 'error') + } + dragItem = null + dragChain = null + } + // ==================== 弹窗操作 ==================== function openAddModal() { @@ -376,11 +731,11 @@ export default { form: createEmptyForm() } showApiKey.value = false + advancedParamsOpen.value = false onProviderChange() } function openEditModal(config) { - // 从 extraConfig 中读取 embeddingDimensions let extraConfig = {} if (config.extraConfig) { try { @@ -401,12 +756,20 @@ export default { max_tokens: config.max_tokens, base_url: config.base_url || '', embeddingDimensions: extraConfig.dimensions || 1024, + // F3: 高级参数 + topP: extraConfig.topP ?? null, + topK: extraConfig.topK ?? null, + frequencyPenalty: extraConfig.frequencyPenalty ?? null, + presencePenalty: extraConfig.presencePenalty ?? null, + stopSequences: extraConfig.stopSequences || '', + // 基础字段 priority: config.priority || 0, is_active: config.is_active || false, description: config.description || '' } } showApiKey.value = false + advancedParamsOpen.value = false onProviderChange() } @@ -416,10 +779,6 @@ export default { // ==================== 保存配置 ==================== - /** - * 将前端表单字段名(snake_case)转换为后端 Java 实体字段名(camelCase) - * 因为 Jackson 默认使用 camelCase 反序列化,前端发送 snake_case 会导致字段无法映射 - */ function toCamelCase(form) { const data = { name: form.name, @@ -434,11 +793,20 @@ export default { isActive: form.is_active, description: form.description } - // EMBEDDING 类型:将向量维度写入 extraConfig + // 构建 extraConfig + const extraConfig = {} if (form.app_type === 'EMBEDDING') { - data.extraConfig = { - dimensions: form.embeddingDimensions || 1024 - } + extraConfig.dimensions = form.embeddingDimensions || 1024 + } + // F3: 高级参数写入 extraConfig + if (form.topP != null) extraConfig.topP = form.topP + if (form.topK != null) extraConfig.topK = form.topK + if (form.frequencyPenalty != null) extraConfig.frequencyPenalty = form.frequencyPenalty + if (form.presencePenalty != null) extraConfig.presencePenalty = form.presencePenalty + if (form.stopSequences && form.stopSequences.trim()) extraConfig.stopSequences = form.stopSequences.trim() + + if (Object.keys(extraConfig).length > 0) { + data.extraConfig = extraConfig } return data } @@ -446,7 +814,6 @@ export default { async function saveConfig() { const form = editModal.value.form - // 如果选了"自定义输入",取自定义名称 if (form.model_name === '__custom__') { if (!customModelName.value.trim()) { toast('请输入自定义模型名称', 'error') @@ -474,12 +841,10 @@ export default { try { let json - // 转换为 camelCase 后再发送,确保 Jackson 正确反序列化 const camelData = toCamelCase(form) if (editModal.value.mode === 'add') { json = await api.createModelConfig(camelData) } else { - // 编辑模式:API Key 留空则不发送(不覆盖原值) if (!camelData.apiKey || !camelData.apiKey.trim()) { delete camelData.apiKey } @@ -487,7 +852,7 @@ export default { } if (json.success) { - toast(editModal.value.mode === 'add' ? '配置创建成功,已切换生效' : '配置更新成功,已切换生效', 'success') + toast(editModal.value.mode === 'add' ? '配置创建成功' : '配置更新成功', 'success') closeEditModal() load(currentPage.value) } else { @@ -498,10 +863,67 @@ export default { } } - // ==================== 激活配置 ==================== + // ==================== F1: 测试连接 ==================== + + async function testConnection(config) { + testLoading.value = { ...testLoading.value, [config.id]: true } + try { + const json = await api.testModelConfig(config.id) + showTestResult(json) + } catch (e) { + showTestResult({ success: false, errorMessage: e.message, latencyMs: 0 }) + } finally { + testLoading.value = { ...testLoading.value, [config.id]: false } + } + } + + async function testConnectionById(id) { + testLoading.value = { ...testLoading.value, [id]: true } + try { + const json = await api.testModelConfig(id) + showTestResult(json) + } catch (e) { + showTestResult({ success: false, errorMessage: e.message, latencyMs: 0 }) + } finally { + testLoading.value = { ...testLoading.value, [id]: false } + } + } + + function showTestResult(result) { + // 清除上一次的定时器,防止多次点击导致定时器泄漏 + if (testResultTimer) clearTimeout(testResultTimer) + testResult.value = { + visible: true, + success: result.success, + latencyMs: result.latencyMs || 0, + dimensions: result.dimensions || null, + errorMessage: result.errorMessage || result.message || null + } + // 5 秒后自动隐藏 + testResultTimer = setTimeout(() => { testResult.value.visible = false }, 5000) + } + + // ==================== F2: 配置复制 ==================== + + async function duplicateConfig(config) { + if (!confirm('确定复制配置「' + (config.name || config.id) + '」?')) return + try { + const json = await api.duplicateModelConfig(config.id) + if (json.success) { + toast('配置复制成功', 'success') + load(currentPage.value) + } else { + toast(json.message || '复制失败', 'error') + } + } catch (e) { + toast('复制失败:' + e.message, 'error') + } + } + + // ==================== 激活/停用 ==================== async function activate(id) { - if (!confirm('确定激活此配置?同类型的其他配置将被自动停用,新配置将立即生效。')) return + if (!confirm('确定激活此配置?新配置将立即生效。')) return try { const json = await api.activateModelConfig(id) if (json.success) { @@ -515,6 +937,21 @@ export default { } } + async function deactivateConfig(id) { + if (!confirm('确定停用此配置?将从 Fallback 链中移除。')) return + try { + const json = await api.deactivateModelConfig(id) + if (json.success) { + toast('配置已停用', 'success') + load(currentPage.value) + } else { + toast(json.message || '停用失败', 'error') + } + } catch (e) { + toast('停用失败:' + e.message, 'error') + } + } + // ==================== 删除配置 ==================== async function remove(id, name) { @@ -532,15 +969,75 @@ export default { } } + // ==================== F7: 导入/导出 ==================== + + async function exportConfigs() { + try { + const json = await api.exportModelConfigs(true) + if (json.success) { + const blob = new Blob([JSON.stringify(json.data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'model-configs-' + new Date().toISOString().slice(0, 10) + '.json' + a.click() + URL.revokeObjectURL(url) + toast('导出成功:' + json.total + ' 条配置', 'success') + } else { + toast(json.message || '导出失败', 'error') + } + } catch (e) { + toast('导出失败:' + e.message, 'error') + } + } + + function onImportFileSelect(e) { + const file = e.target.files[0] + if (!file) return + const reader = new FileReader() + reader.onload = (ev) => { + try { + let data = JSON.parse(ev.target.result) + // 兼容:如果是 {data: [...]} 格式,取出 data 数组 + if (!Array.isArray(data) && data.data) data = data.data + if (!Array.isArray(data)) { + toast('JSON 格式不正确,需要数组格式', 'error') + return + } + importPreview.value = { configs: data } + importResult.value = null + } catch (err) { + toast('文件解析失败:' + err.message, 'error') + } + } + reader.readAsText(file) + } + + async function doImport() { + if (!importPreview.value) return + try { + const json = await api.importModelConfigs(importPreview.value.configs, importConflict.value) + if (json.success) { + importResult.value = json.data + toast('导入完成', 'success') + load(currentPage.value) + } else { + toast(json.message || '导入失败', 'error') + } + } catch (e) { + toast('导入失败:' + e.message, 'error') + } + } + // ==================== 工具函数 ==================== function getAppTypeLabel(appType) { - const map = { CHAT: '智能客服对话', EMBEDDING: '文本向量化', RAG_REWRITE: 'RAG查询重写' } + const map = { CHAT: '智能客服对话', EMBEDDING: '文本向量化', RAG_REWRITE: 'RAG查询重写', RERANK: '重排序' } return map[appType] || appType } function getAppTypeBadgeClass(appType) { - const map = { CHAT: 'badge-get', EMBEDDING: '', RAG_REWRITE: '' } + const map = { CHAT: 'badge-get', EMBEDDING: '', RAG_REWRITE: '', RERANK: '' } return map[appType] || '' } @@ -549,13 +1046,38 @@ export default { return found ? found.label : provider } - // 初始加载 - onMounted(() => { load() }) + // ==================== 生命周期 ==================== + + onMounted(() => { + load() + // F4: 30s 自动轮询刷新健康状态 + healthTimer = setInterval(loadHealthStatus, 30000) + }) + + onUnmounted(() => { + if (healthTimer) clearInterval(healthTimer) + if (testResultTimer) clearTimeout(testResultTimer) + }) return { configs, currentPage, totalPages, total, filterAppType, showApiKey, editModal, appTypeOptions: APP_TYPE_OPTIONS, providerOptions: PROVIDER_OPTIONS, - providerTip, currentProviderModels, currentProviderDefaultModel, providerBaseUrlPlaceholder, customModelName, + providerTip, currentProviderModels, currentProviderDefaultModel, providerBaseUrlPlaceholder, + customModelName, advancedParamsOpen, hasAdvancedParams, + // F1 + testLoading, testResult, testConnection, testConnectionById, + // F2 + duplicateConfig, + // F4 + healthMap, healthSummary, getHealthTooltip, + // F5 + selectedModelMeta, getModelMeta, onModelChange, formatTokens, + // F6 + fallbackChains, onDragStart, onDrop, deactivateConfig, + // F7 + showImportDialog, importPreview, importConflict, importResult, + onImportFileSelect, doImport, exportConfigs, + // 基础 load, openAddModal, openEditModal, closeEditModal, saveConfig, activate, remove, onProviderChange, getAppTypeLabel, getAppTypeBadgeClass, getProviderLabel, formatDate } diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 268ad4c..c6c5fa3 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -421,3 +421,101 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei" .user-bar { gap:8px; } .user-name, .user-role-tag { display:none; } } + +/* ==================== P1: 启用/禁用开关 ==================== */ +.switch-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + cursor: pointer; +} +.switch-toggle input { opacity: 0; width: 0; height: 0; } +.switch-slider { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: #d1d5db; + border-radius: 20px; + transition: 0.2s; +} +.switch-slider::before { + content: ''; + position: absolute; + width: 16px; height: 16px; + left: 2px; bottom: 2px; + background: white; + border-radius: 50%; + transition: 0.2s; +} +.switch-toggle input:checked + .switch-slider { + background: #10b981; +} +.switch-toggle input:checked + .switch-slider::before { + transform: translateX(16px); +} + +/* ==================== P1: 标签输入组件 ==================== */ +.tag-input-wrapper { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card); + min-height: 38px; + align-items: center; + cursor: text; +} +.tag-input-wrapper:focus-within { + border-color: var(--primary); + box-shadow: var(--ring); +} +.tag-input-wrapper .tag-badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: #e0f2fe; + color: #0369a1; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; +} +.tag-input-wrapper .tag-badge .tag-remove { + cursor: pointer; + font-size: 14px; + line-height: 1; + opacity: 0.6; +} +.tag-input-wrapper .tag-badge .tag-remove:hover { opacity: 1; } +.tag-input-wrapper input { + border: none; + outline: none; + flex: 1; + min-width: 80px; + font-size: 13px; + padding: 4px 0; + background: transparent; +} +.tag-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + z-index: 100; + max-height: 150px; + overflow-y: auto; +} +.tag-suggestions .tag-suggestion-item { + padding: 6px 12px; + font-size: 13px; + cursor: pointer; +} +.tag-suggestions .tag-suggestion-item:hover { + background: #f3f4f6; +} diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 53a37a2..4e66195 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -232,13 +232,14 @@ export function deleteAccount(accountId) { // ==================== 文档管理 ==================== /** - * 文档列表(分页 + 过滤 + 关键词搜索) + * 文档列表(分页 + 过滤 + 关键词搜索 + 标签筛选) */ -export function listDocuments(page = 1, size = 10, categoryId, status, keyword) { +export function listDocuments(page = 1, size = 10, categoryId, status, keyword, tag) { let path = `/document/list?page=${page}&size=${size}` if (categoryId) path += `&categoryId=${categoryId}` if (status) path += `&status=${status}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` + if (tag) path += `&tag=${encodeURIComponent(tag)}` return getJSON(path) } @@ -291,6 +292,48 @@ export function batchReprocessDocuments(ids) { return postJSON('/document/batch/reprocess', { ids }) } +/** + * P1-2.1: 切换文档启用/禁用状态 + */ +export function toggleDocument(id) { + return putJSON(`/document/${id}/toggle`) +} + +/** + * P1-2.1: 批量启用/禁用文档 + */ +export function batchToggleDocuments(ids, enabled) { + return postJSON('/document/batch/toggle', { ids, enabled }) +} + +/** + * P1-2.2: 批量移动文档分类 + */ +export function batchMoveDocuments(ids, categoryId) { + return postJSON('/document/batch/move', { ids, categoryId }) +} + +/** + * P1-2.3: 获取标签列表(聚合去重,含使用次数) + */ +export function getTagList() { + return getJSON('/tag/list') +} + +/** + * P1-2.5: 更新单个分块内容 + */ +export function updateChunk(docId, chunkIndex, content) { + return putJSONWithBody(`/document/${docId}/chunk/${chunkIndex}`, { content }) +} + +/** + * P1-2.5: 删除单个分块 + */ +export function deleteChunk(docId, chunkIndex) { + return deleteJSON(`/document/${docId}/chunk/${chunkIndex}`) +} + /** * 语义搜索 */ @@ -531,6 +574,72 @@ export function deleteModelConfig(id) { return deleteJSON(`/model-config/${id}`) } +// ==================== F1: 连接测试 ==================== + +/** + * 测试模型配置连接可用性 + */ +export function testModelConfig(id) { + return postJSON(`/model-config/${id}/test`, {}) +} + +// ==================== F2: 配置复制 ==================== + +/** + * 复制模型配置 + */ +export function duplicateModelConfig(id) { + return postJSON(`/model-config/${id}/duplicate`, {}) +} + +// ==================== F4: 健康状态 ==================== + +/** + * 获取所有配置的健康状态 + */ +export function getHealthStatus() { + return getJSON('/model-config/health') +} + +/** + * 手动触发单个配置的健康检查 + */ +export function manualHealthCheck(id) { + return postJSON(`/model-config/${id}/health-check`, {}) +} + +// ==================== F6: 优先级管理 ==================== + +/** + * 更新配置优先级 + */ +export function updateModelConfigPriority(id, priority) { + return putJSONWithBody(`/model-config/${id}/priority`, { priority }) +} + +/** + * 停用配置(从 Fallback 链中移除) + */ +export function deactivateModelConfig(id) { + return putJSON(`/model-config/${id}/deactivate`) +} + +// ==================== F7: 配置导入/导出 ==================== + +/** + * 导出所有模型配置 + */ +export function exportModelConfigs(maskApiKey = true) { + return getJSON(`/model-config/export?maskApiKey=${maskApiKey}`) +} + +/** + * 批量导入模型配置 + */ +export function importModelConfigs(configs, onConflict = 'skip') { + return postJSON('/model-config/import', { configs, onConflict }) +} + /** * Truncate a conversation from the given user turn onward. */ diff --git a/src/main/resources/static/js/store.js b/src/main/resources/static/js/store.js index 69bef1a..6cbb845 100644 --- a/src/main/resources/static/js/store.js +++ b/src/main/resources/static/js/store.js @@ -115,6 +115,23 @@ export const store = reactive({ } }, + // ==================== 标签数据 ==================== + tags: [], + + /** + * P1-2.3: 加载标签列表(聚合去重,含使用次数) + */ + async loadTags() { + try { + const json = await api.getTagList() + if (json.success) { + this.tags = json.data || [] + } + } catch (e) { + console.error('加载标签列表失败', e) + } + }, + // ==================== 文档详情弹窗 ==================== detailModal: { visible: false, @@ -213,6 +230,7 @@ export const store = reactive({ case 'search-test': this.loadCategories() this.loadStats() + this.loadTags() break case 'category': case 'faq':
提供商 温度 API Key状态F4: 状态 操作
{{ getAppTypeLabel(c.app_type) }} {{ c.model_name }} + {{ c.model_name }} + +
+ {{ formatTokens(getModelMeta(c).maxContextTokens) }} ctx · {{ formatTokens(getModelMeta(c).maxOutputTokens) }} out + · 👁 + · 🔧 +
+
{{ getProviderLabel(c.provider) }} {{ c.temperature != null ? c.temperature : '-' }} {{ c.api_key || '-' }} - - 🟢 活跃 - - - ⚫ 未激活 - +
+ + 🟢 活跃 + + + ⚫ 未激活 + + + + {{ healthMap[c.id].status === 'ONLINE' ? '●' : '●' }} + {{ healthMap[c.id].latencyMs }}ms + + +
- - - + + + + + + + + +