Browse Source

大模型配置管理功能增强

dev-mcp
wanghanlin 4 weeks ago
parent
commit
0414e209cb
  1. 160
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  2. 46
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  3. 63
      src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java
  4. 227
      src/main/java/com/wok/supportbot/config/ModelHealthService.java
  5. 76
      src/main/java/com/wok/supportbot/config/SimpleCircuitBreaker.java
  6. 216
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  7. 28
      src/main/java/com/wok/supportbot/controller/ConversationController.java
  8. 211
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  9. 12
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  10. 15
      src/main/java/com/wok/supportbot/rag/HybridSearchService.java
  11. 243
      src/main/java/com/wok/supportbot/service/AiModelConfigService.java
  12. 23
      src/main/java/com/wok/supportbot/service/ConversationService.java
  13. 28
      src/main/java/com/wok/supportbot/service/DocumentProcessingService.java
  14. 275
      src/main/java/com/wok/supportbot/service/DocumentService.java
  15. 140
      src/main/resources/static/components/DocDetail.js
  16. 152
      src/main/resources/static/components/DocList.js
  17. 76
      src/main/resources/static/components/DocUpload.js
  18. 636
      src/main/resources/static/components/ModelConfigManager.js
  19. 98
      src/main/resources/static/css/main.css
  20. 113
      src/main/resources/static/js/api.js
  21. 18
      src/main/resources/static/js/store.js

160
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.entity.AiModelConfig;
import com.wok.supportbot.service.AiModelConfigService; import com.wok.supportbot.service.AiModelConfigService;
import lombok.extern.slf4j.Slf4j; 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.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.model.tool.ToolCallingManager;
import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions; 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.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
/** /**
* ChatModel 工厂 * ChatModel 工厂
@ -36,7 +43,7 @@ public class ChatModelFactory {
private ToolCallingManager toolCallingManager; private ToolCallingManager toolCallingManager;
/** /**
* ChatModel 缓存key = "provider:apiKey:modelName:baseUrl"
* ChatModel 缓存key = "configId:provider:keyHint:modelName"不含明文 API Key
*/ */
private final ConcurrentHashMap<String, ChatModel> chatModelCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, ChatModel> chatModelCache = new ConcurrentHashMap<>();
@ -81,11 +88,12 @@ public class ChatModelFactory {
/** /**
* 获取或创建 ChatModel带缓存 * 获取或创建 ChatModel带缓存
* 缓存 key = provider:apiKey:modelName:baseUrl配置不变则复用实例
* 缓存 key = configId:provider:keyHint:modelName避免明文 API Key 出现在缓存键中
*/ */
private ChatModel getOrCreateChatModel(AiModelConfig config) { private ChatModel getOrCreateChatModel(AiModelConfig config) {
String baseUrl = resolveBaseUrl(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)); return chatModelCache.computeIfAbsent(cacheKey, k -> createChatModel(config, baseUrl));
} }
@ -132,6 +140,8 @@ public class ChatModelFactory {
if (config.getMaxTokens() != null) { if (config.getMaxTokens() != null) {
optionsBuilder.withMaxToken(config.getMaxTokens()); optionsBuilder.withMaxToken(config.getMaxTokens());
} }
// F3: 应用高级参数topPtopKstopSequences
applyDashScopeExtraConfig(config, optionsBuilder);
return DashScopeChatModel.builder() return DashScopeChatModel.builder()
.dashScopeApi(api) .dashScopeApi(api)
@ -162,6 +172,8 @@ public class ChatModelFactory {
if (config.getMaxTokens() != null) { if (config.getMaxTokens() != null) {
optionsBuilder.maxTokens(config.getMaxTokens()); optionsBuilder.maxTokens(config.getMaxTokens());
} }
// F3: 应用高级参数topPfrequencyPenaltypresencePenaltystopSequences
applyExtraConfig(config, optionsBuilder);
return OpenAiChatModel.builder() return OpenAiChatModel.builder()
.openAiApi(api) .openAiApi(api)
@ -219,4 +231,146 @@ public class ChatModelFactory {
+ "并为 CHAT 类型选择一个对话模型(如 doubao-pro、doubao-lite 等)"); + "并为 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<String, Object> testConnection(AiModelConfig config) {
try {
CompletableFuture<Map<String, Object>> future = CompletableFuture.supplyAsync(() -> {
Map<String, Object> 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<String, Object> 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<String, Object> result = new LinkedHashMap<>();
result.put("success", false);
result.put("latencyMs", 0L);
result.put("errorMessage", e.getMessage());
return result;
}
}
// ==================== F3: 高级参数读取 ====================
/**
* extraConfig 中读取高级生成参数并设置到 OpenAiChatOptions.Builder
* 支持的参数topPfrequencyPenaltypresencePenaltystopSequences
*/
private void applyExtraConfig(AiModelConfig config, OpenAiChatOptions.Builder optionsBuilder) {
Map<String, Object> 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<String> 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<String, Object> 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<Object> 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);
}
} }

46
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -52,6 +52,10 @@ public class DatabaseInitConfig {
fixTagsDefaultValue(); fixTagsDefaultValue();
// 自动添加 content_hash 二期新增 // 自动添加 content_hash 二期新增
addContentHashColumn(); addContentHashColumn();
// P1-2.1: 自动添加 enabled 文档启用/禁用
addDocumentEnabledColumn();
// P1-2.5: 自动添加 extra_config per-doc 分块参数
addDocumentExtraConfigColumn();
} }
boolean roleTableExists = checkTableExists("customer_service_role"); boolean roleTableExists = checkTableExists("customer_service_role");
@ -236,6 +240,9 @@ public class DatabaseInitConfig {
chunk_count INTEGER DEFAULT 0 NOT NULL, chunk_count INTEGER DEFAULT 0 NOT NULL,
status VARCHAR(20) DEFAULT 'PROCESSING' NOT NULL, status VARCHAR(20) DEFAULT 'PROCESSING' NOT NULL,
error_message TEXT, 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, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE 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_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_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_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() { 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: 内容安全过滤 ==================== // ==================== P0-004: 内容安全过滤 ====================
private void createSensitiveWordTable() { private void createSensitiveWordTable() {
@ -900,6 +944,8 @@ public class DatabaseInitConfig {
executeComment("COLUMN knowledge_document.status", "处理状态: PROCESSING / READY / FAILED"); executeComment("COLUMN knowledge_document.status", "处理状态: PROCESSING / READY / FAILED");
executeComment("COLUMN knowledge_document.error_message", "处理失败时的错误信息"); executeComment("COLUMN knowledge_document.error_message", "处理失败时的错误信息");
executeComment("COLUMN knowledge_document.content_hash", "内容 SHA-256 哈希值(用于文档去重)"); 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.create_time", "创建时间");
executeComment("COLUMN knowledge_document.update_time", "更新时间"); executeComment("COLUMN knowledge_document.update_time", "更新时间");
executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");

63
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 lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.MetadataMode; import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.openai.OpenAiEmbeddingModel; import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingOptions; import org.springframework.ai.openai.OpenAiEmbeddingOptions;
import org.springframework.ai.openai.api.OpenAiApi; 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.retry.support.RetryTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@ -33,7 +36,7 @@ public class EmbeddingModelFactory {
private AiModelConfigService configService; private AiModelConfigService configService;
/** /**
* EmbeddingModel 缓存key = "provider:apiKey:modelName:baseUrl"
* EmbeddingModel 缓存key = "configId:provider:keyHint:modelName"不含明文 API Key
*/ */
private final ConcurrentHashMap<String, EmbeddingModel> cache = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, EmbeddingModel> cache = new ConcurrentHashMap<>();
@ -82,11 +85,12 @@ public class EmbeddingModelFactory {
/** /**
* 获取或创建 EmbeddingModel带缓存 * 获取或创建 EmbeddingModel带缓存
* 缓存 key = provider:apiKey:modelName:baseUrl配置不变则复用实例
* 缓存 key = configId:provider:keyHint:modelName避免明文 API Key 出现在缓存键中
*/ */
private EmbeddingModel getOrCreateEmbeddingModel(AiModelConfig config) { private EmbeddingModel getOrCreateEmbeddingModel(AiModelConfig config) {
String baseUrl = resolveBaseUrl(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)); return cache.computeIfAbsent(cacheKey, k -> createEmbeddingModel(config, baseUrl));
} }
@ -216,4 +220,57 @@ public class EmbeddingModelFactory {
cache.clear(); cache.clear();
log.info("EmbeddingModel 缓存已清除"); log.info("EmbeddingModel 缓存已清除");
} }
// ==================== F1: 连接测试 ====================
/**
* 测试指定配置的连接可用性
* 创建临时 EmbeddingModel不走缓存调用 embed("test") 验证 API Key 和模型是否可用
*
* @param config 待测试的配置需含完整 API Key
* @return 测试结果success / latencyMs / dimensions / errorMessage
*/
public Map<String, Object> testConnection(AiModelConfig config) {
Map<String, Object> 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);
}
} }

227
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<Long, HealthStatus> 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<Map.Entry<Long, Future<Map<String, Object>>>> pendingTasks = new ArrayList<>();
for (String appType : appTypes) {
try {
List<AiModelConfig> configs = configService.getAllActiveConfigs(appType);
for (AiModelConfig config : configs) {
Future<Map<String, Object>> future = probeExecutor.submit(() -> probeConfig(config));
pendingTasks.add(Map.entry(config.getId(), future));
}
} catch (Exception e) {
log.warn("收集应用类型 [{}] 探活任务时出错:{}", appType, e.getMessage());
}
}
// 第二阶段统一等待所有结果
for (Map.Entry<Long, Future<Map<String, Object>>> entry : pendingTasks) {
Long configId = entry.getKey();
Future<Map<String, Object>> future = entry.getValue();
try {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> checkHealth(Long id) {
AiModelConfig config = configService.getConfigWithFullKey(id);
if (config == null) {
return Map.of("status", "UNKNOWN", "errorMessage", "配置不存在");
}
Map<String, Object> result = probeConfig(config);
updateHealthStatus(id, result);
Map<String, Object> 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<String, Object> result = probeConfig(config);
updateHealthStatus(configId, result);
}
} catch (Exception e) {
log.warn("立即探活配置 [{}] 失败:{}", configId, e.getMessage());
}
});
}
/**
* 获取所有配置的健康状态供前端看板使用
*/
public Map<String, Object> getAllHealthStatus() {
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<Long, HealthStatus> entry : healthCache.entrySet()) {
Map<String, Object> 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<String, Object> 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
}
}

76
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<Long, AtomicInteger> failureCounters = new ConcurrentHashMap<>();
/** 熔断状态:configId → 熔断开始时间戳 */
private final ConcurrentHashMap<Long, Long> 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);
}
}

216
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.app.AssistantApp;
import com.wok.supportbot.config.ChatModelFactory; import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.config.EmbeddingModelFactory; import com.wok.supportbot.config.EmbeddingModelFactory;
import com.wok.supportbot.config.ModelHealthService;
import com.wok.supportbot.entity.AiModelConfig; import com.wok.supportbot.entity.AiModelConfig;
import com.wok.supportbot.service.AiModelConfigService; import com.wok.supportbot.service.AiModelConfigService;
import org.springframework.beans.factory.annotation.Autowired; 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.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@ -19,6 +21,10 @@ import java.util.Map;
@RestController @RestController
public class AiModelConfigController { public class AiModelConfigController {
/** 复用的 JSON 转换器,避免每次导入都新建实例 */
private static final com.fasterxml.jackson.databind.ObjectMapper OBJECT_MAPPER =
new com.fasterxml.jackson.databind.ObjectMapper();
@Autowired @Autowired
private AiModelConfigService aiModelConfigService; private AiModelConfigService aiModelConfigService;
@ -31,6 +37,9 @@ public class AiModelConfigController {
@Autowired @Autowired
private AssistantApp assistantApp; 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> getHealthStatus() {
try {
Map<String, Object> 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<Map<String, Object>> manualHealthCheck(@PathVariable("id") Long id) {
try {
Map<String, Object> 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<Map<String, Object>> updatePriority(
@PathVariable("id") Long id,
@RequestBody Map<String, Integer> 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<Map<String, Object>> 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<Map<String, Object>> exportConfigs(
@RequestParam(defaultValue = "true") boolean maskApiKey) {
try {
List<AiModelConfig> 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<Map<String, Object>> importConfigs(@RequestBody Map<String, Object> body) {
try {
@SuppressWarnings("unchecked")
List<Map<String, Object>> rawConfigs = (List<Map<String, Object>>) 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<AiModelConfig> configs = new java.util.ArrayList<>();
for (Map<String, Object> raw : rawConfigs) {
configs.add(OBJECT_MAPPER.convertValue(raw, AiModelConfig.class));
}
Map<String, Object> 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()));
}
}
// ==================== 缓存刷新 ==================== // ==================== 缓存刷新 ====================
/** /**

28
src/main/java/com/wok/supportbot/controller/ConversationController.java

@ -1,13 +1,10 @@
package com.wok.supportbot.controller; package com.wok.supportbot.controller;
import com.wok.supportbot.service.ConversationService; import com.wok.supportbot.service.ConversationService;
import com.wok.supportbot.service.SysUserService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; 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 org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@ -23,9 +20,6 @@ public class ConversationController {
@Autowired @Autowired
private ConversationService conversationService; private ConversationService conversationService;
@Autowired(required = false)
private SysUserService sysUserService;
// ==================== 会话列表 ==================== // ==================== 会话列表 ====================
/** /**
@ -44,9 +38,7 @@ public class ConversationController {
@RequestParam(required = false) String accountId, @RequestParam(required = false) String accountId,
@RequestParam(required = false) Long roleId) { @RequestParam(required = false) Long roleId) {
try { try {
// 获取当前登录用户ID用于数据隔离SDK调用时为null
Long userId = getCurrentUserId();
Map<String, Object> result = conversationService.listConversations(page, size, keyword, accountId, roleId, userId);
Map<String, Object> result = conversationService.listConversations(page, size, keyword, accountId, roleId);
Map<String, Object> data = new java.util.HashMap<>(); Map<String, Object> data = new java.util.HashMap<>();
data.put("success", true); data.put("success", true);
data.put("data", result.get("records")); data.put("data", result.get("records"));
@ -212,22 +204,4 @@ public class ConversationController {
} }
} }
/**
* SecurityContext 获取当前登录用户的系统用户ID
* 未登录时返回 nullSDK 调用场景
*/
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;
}
} }

211
src/main/java/com/wok/supportbot/controller/DocumentController.java

@ -80,10 +80,12 @@ public class DocumentController {
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) {
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) {
try { try {
validateUploadFile(file); 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( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "文件上传成功,正在后台处理", "message", "文件上传成功,正在后台处理",
@ -112,9 +114,11 @@ public class DocumentController {
@RequestBody String content, @RequestBody String content,
@RequestParam String title, @RequestParam String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) {
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) {
try { try {
KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags);
KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags, chunkSize, overlap);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "文本内容上传成功,正在后台处理", "message", "文本内容上传成功,正在后台处理",
@ -137,10 +141,12 @@ public class DocumentController {
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) {
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) {
try { try {
validateUploadFile(file); 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( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "Markdown文件上传成功,正在后台处理", "message", "Markdown文件上传成功,正在后台处理",
@ -163,10 +169,12 @@ public class DocumentController {
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) {
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) {
try { try {
validateUploadFile(file); 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( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "JSON文件上传成功,正在后台处理", "message", "JSON文件上传成功,正在后台处理",
@ -190,10 +198,12 @@ public class DocumentController {
@RequestParam("fields") List<String> fields, @RequestParam("fields") List<String> fields,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) {
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) {
try { try {
validateUploadFile(file); 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( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "JSON文件(按字段)上传成功,正在后台处理", "message", "JSON文件(按字段)上传成功,正在后台处理",
@ -218,10 +228,12 @@ public class DocumentController {
@RequestParam("pointer") String pointer, @RequestParam("pointer") String pointer,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) List<String> tags) {
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer chunkSize,
@RequestParam(required = false) Integer overlap) {
try { try {
validateUploadFile(file); 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( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "JSON文件(按指针)上传成功,正在后台处理", "message", "JSON文件(按指针)上传成功,正在后台处理",
@ -239,13 +251,14 @@ public class DocumentController {
// ==================== 文档管理 ==================== // ==================== 文档管理 ====================
/** /**
* 查询文档列表分页 + 过滤 + 关键词搜索
* 查询文档列表分页 + 过滤 + 关键词搜索 + 标签筛选
* *
* @param page 页码默认1 * @param page 页码默认1
* @param size 每页大小默认10 * @param size 每页大小默认10
* @param categoryId 分类ID过滤可选 * @param categoryId 分类ID过滤可选
* @param status 状态过滤PROCESSING/READY/FAILED可选 * @param status 状态过滤PROCESSING/READY/FAILED可选
* @param keyword 关键词搜索模糊匹配标题和文件名可选 * @param keyword 关键词搜索模糊匹配标题和文件名可选
* @param tag 标签筛选精确匹配可选
* @return 分页文档列表 * @return 分页文档列表
*/ */
@GetMapping("/document/list") @GetMapping("/document/list")
@ -255,9 +268,10 @@ public class DocumentController {
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String tag) {
try { try {
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, status, keyword);
Map<String, Object> result = documentService.listDocuments(page, size, categoryId, status, keyword, tag);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("success", true); data.put("success", true);
data.put("data", result.get("records")); 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<Map<String, Object>> 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<Map<String, Object>> batchToggleDocuments(@RequestBody Map<String, Object> body) {
try {
List<Long> 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<String, Object> 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<Map<String, Object>> batchMoveDocuments(@RequestBody Map<String, Object> body) {
try {
List<Long> 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(); .toList();
} }
// ==================== 标签管理 ====================
/**
* P1-2.3: 获取标签列表从所有文档的 tags 聚合去重含使用次数
*/
@GetMapping("/tag/list")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> getTagList() {
try {
List<Map<String, Object>> 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<Map<String, Object>> updateChunk(
@PathVariable Long docId,
@PathVariable int chunkIndex,
@RequestBody Map<String, Object> 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<Map<String, Object>> 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()
));
}
}
// ==================== 统计 ==================== // ==================== 统计 ====================
/** /**

12
src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java

@ -99,6 +99,18 @@ public class KnowledgeDocument implements Serializable {
@TableField("content_hash") @TableField("content_hash")
private String contentHash; 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<String, Object> extraConfig;
/** /**
* 创建时间 * 创建时间
*/ */

15
src/main/java/com/wok/supportbot/rag/HybridSearchService.java

@ -64,7 +64,7 @@ public class HybridSearchService {
/** /**
* 纯向量语义检索 * 纯向量语义检索
* 复用 PgVectorStore similaritySearch支持分类过滤和相似度阈值
* 复用 PgVectorStore similaritySearch支持分类过滤启用状态过滤和相似度阈值
*/ */
private List<SearchResult> vectorSearch(String query, int topK, double threshold, List<Long> categoryIds) { private List<SearchResult> vectorSearch(String query, int topK, double threshold, List<Long> categoryIds) {
SearchRequest.Builder builder = SearchRequest.builder() SearchRequest.Builder builder = SearchRequest.builder()
@ -72,13 +72,18 @@ public class HybridSearchService {
.topK(topK) .topK(topK)
.similarityThreshold(threshold); .similarityThreshold(threshold);
// 分类过滤
if (categoryIds != null && !categoryIds.isEmpty()) {
FilterExpressionBuilder fb = new FilterExpressionBuilder(); FilterExpressionBuilder fb = new FilterExpressionBuilder();
// P1-2.1: 始终过滤禁用的文档
var enabledFilter = fb.eq("enabled", "true");
if (categoryIds != null && !categoryIds.isEmpty()) {
List<Object> values = categoryIds.stream() List<Object> values = categoryIds.stream()
.map(id -> (Object) String.valueOf(id)) .map(id -> (Object) String.valueOf(id))
.toList(); .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<Document> docs = pgVectorVectorStore.similaritySearch(builder.build()); List<Document> docs = pgVectorVectorStore.similaritySearch(builder.build());
@ -91,6 +96,7 @@ public class HybridSearchService {
/** /**
* 全文关键词检索 * 全文关键词检索
* 使用 PostgreSQL tsvector 全文索引 ts_rank 排序 * 使用 PostgreSQL tsvector 全文索引 ts_rank 排序
* P1-2.1: 始终过滤禁用的文档metadata->>'enabled' = 'true'
*/ */
private List<SearchResult> keywordSearch(String query, int topK, List<Long> categoryIds) { private List<SearchResult> keywordSearch(String query, int topK, List<Long> categoryIds) {
String categoryFilter = buildCategoryFilter(categoryIds); String categoryFilter = buildCategoryFilter(categoryIds);
@ -100,6 +106,7 @@ public class HybridSearchService {
FROM vector_store FROM vector_store
WHERE content_tsvector @@ plainto_tsquery('simple', ?) WHERE content_tsvector @@ plainto_tsquery('simple', ?)
AND is_delete = false AND is_delete = false
AND metadata->>'enabled' = 'true'
%s %s
ORDER BY rank DESC ORDER BY rank DESC
LIMIT ? LIMIT ?

243
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*; import java.util.*;
/** /**
@ -26,6 +28,9 @@ public class AiModelConfigService {
@Autowired @Autowired
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
/** 复用的 JSON 序列化器,避免每次调用 persistExtraConfig 都新建实例 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// ==================== 分页列表 ==================== // ==================== 分页列表 ====================
/** /**
@ -211,7 +216,10 @@ public class AiModelConfigService {
} }
aiModelConfigMapper.updateById(config); aiModelConfigMapper.updateById(config);
// JSONB 字段 MyBatis Plus typeHandler 可能不触发 JdbcTemplate 显式写入保证可靠 // JSONB 字段 MyBatis Plus typeHandler 可能不触发 JdbcTemplate 显式写入保证可靠
// 仅当前端传入了 extraConfig 时才覆盖null 表示未修改跳过以避免清空已有值
if (config.getExtraConfig() != null) {
persistExtraConfig(id, config.getExtraConfig()); persistExtraConfig(id, config.getExtraConfig());
}
log.info("更新 AI 模型配置: id={}", id); log.info("更新 AI 模型配置: id={}", id);
AiModelConfig updated = aiModelConfigMapper.selectById(id); AiModelConfig updated = aiModelConfigMapper.selectById(id);
// 返回前脱敏避免明文 api_key 暴露给前端与列表/详情接口保持一致 // 返回前脱敏避免明文 api_key 暴露给前端与列表/详情接口保持一致
@ -224,7 +232,8 @@ public class AiModelConfigService {
// ==================== 激活配置 ==================== // ==================== 激活配置 ====================
/** /**
* 激活指定配置 app_type 互斥
* 激活指定配置F6: 允许同 app_type 多个活跃配置
* 激活时自动设置 priority 为当前最高值 + 1使其成为主模型
* 校验Embedding 模型不能作为 CHAT / RAG_REWRITE 类型激活 * 校验Embedding 模型不能作为 CHAT / RAG_REWRITE 类型激活
* *
* @param id 配置ID * @param id 配置ID
@ -239,17 +248,26 @@ public class AiModelConfigService {
// 校验Embedding 模型不能用于非 EMBEDDING 类型 // 校验Embedding 模型不能用于非 EMBEDDING 类型
validateModelTypeMatch(config); validateModelTypeMatch(config);
// 先禁用同类型的所有配置
deactivateByAppType(config.getAppType());
// F6: 不再互斥而是设置 priority 为当前最高 + 1
Integer maxPriority = getMaxPriority(config.getAppType());
int newPriority = (maxPriority != null ? maxPriority : 0) + 1;
// 再激活目标配置
LambdaUpdateWrapper<AiModelConfig> updateWrapper = new LambdaUpdateWrapper<>(); LambdaUpdateWrapper<AiModelConfig> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(AiModelConfig::getId, id) updateWrapper.eq(AiModelConfig::getId, id)
.set(AiModelConfig::getIsActive, true);
.set(AiModelConfig::getIsActive, true)
.set(AiModelConfig::getPriority, newPriority);
aiModelConfigMapper.update(null, updateWrapper); 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()) { if (extraConfig == null || extraConfig.isEmpty()) {
json = "{}"; json = "{}";
} else { } else {
com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
json = om.writeValueAsString(extraConfig);
json = OBJECT_MAPPER.writeValueAsString(extraConfig);
} }
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE ai_model_config SET extra_config = ?::jsonb WHERE id = ?", "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()); 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<AiModelConfig> getAllActiveConfigs(String appType) {
LambdaQueryWrapper<AiModelConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AiModelConfig::getAppType, appType)
.eq(AiModelConfig::getIsActive, true)
.orderByDesc(AiModelConfig::getPriority);
List<AiModelConfig> 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<AiModelConfig> 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<AiModelConfig> 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<AiModelConfig> exportConfigs(boolean maskApiKey) {
LambdaQueryWrapper<AiModelConfig> wrapper = new LambdaQueryWrapper<>();
wrapper.orderByDesc(AiModelConfig::getPriority)
.orderByDesc(AiModelConfig::getCreateTime);
List<AiModelConfig> 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<String, Object> importConfigs(List<AiModelConfig> configs, String onConflict) {
int added = 0, overwritten = 0, skipped = 0;
List<String> 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<AiModelConfig> 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<String, Object> 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;
}
} }

23
src/main/java/com/wok/supportbot/service/ConversationService.java

@ -39,19 +39,15 @@ public class ConversationService {
* @return 分页会话列表 * @return 分页会话列表
*/ */
public Map<String, Object> listConversations(int page, int size, String keyword) { public Map<String, Object> listConversations(int page, int size, String keyword) {
return listConversations(page, size, keyword, null, null, null);
}
public Map<String, Object> 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 系统用户IDnull 表示不过滤SDK 调用场景
* 获取会话列表分页
* 会话归属通过 conversation_session 表的 accountId / roleId 关联
* 管理员可通过上述字段按需筛选
*/ */
public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId, Long userId) {
public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId) {
StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false "); StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false ");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
@ -76,11 +72,10 @@ public class ConversationService {
whereClause.append(" AND cs.role_id = ? "); whereClause.append(" AND cs.role_id = ? ");
params.add(roleId); 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 = """ String countSql = """
SELECT COUNT(DISTINCT cm1.conversation_id) SELECT COUNT(DISTINCT cm1.conversation_id)

28
src/main/java/com/wok/supportbot/service/DocumentProcessingService.java

@ -51,11 +51,14 @@ public class DocumentProcessingService {
* @param title 文档标题 * @param title 文档标题
* @param categoryId 分类ID * @param categoryId 分类ID
* @param tags 标签列表 * @param tags 标签列表
* @param chunkSize 分块大小可选覆盖全局配置
* @param overlap 重叠大小可选覆盖全局配置
*/ */
@Async @Async
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void processDocumentAsync(Long docId, List<Document> documents, String sourceName, public void processDocumentAsync(Long docId, List<Document> documents, String sourceName,
String title, Long categoryId, List<String> tags) {
String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
// 等待主事务提交确保文档记录可见最多重试 5 每次 200ms // 等待主事务提交确保文档记录可见最多重试 5 每次 200ms
KnowledgeDocument doc = waitForDocument(docId); KnowledgeDocument doc = waitForDocument(docId);
if (doc == null) { if (doc == null) {
@ -64,8 +67,8 @@ public class DocumentProcessingService {
} }
try { try {
// 1. 分块处理
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents);
// 1. 分块处理使用 per-doc 参数或全局配置
List<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap);
// 2. 为每个分块设置 metadata // 2. 为每个分块设置 metadata
for (int i = 0; i < splitDocuments.size(); i++) { for (int i = 0; i < splitDocuments.size(); i++) {
@ -81,6 +84,8 @@ public class DocumentProcessingService {
if (tags != null && !tags.isEmpty()) { if (tags != null && !tags.isEmpty()) {
meta.put("tags", tags); 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)); splitDocuments.set(i, new Document(d.getId(), d.getText(), meta));
} }
@ -107,6 +112,7 @@ public class DocumentProcessingService {
/** /**
* 异步重新处理文档重新分块 + 向量化 * 异步重新处理文档重新分块 + 向量化
* 使用文档存储的 per-doc 分块参数extraConfig如无则使用全局配置
* *
* @param docId 文档ID * @param docId 文档ID
* @param documents 解析后的文档列表 * @param documents 解析后的文档列表
@ -129,8 +135,18 @@ public class DocumentProcessingService {
pgVectorVectorStore.delete(oldIds); pgVectorVectorStore.delete(oldIds);
} }
// 重新分块
List<Document> 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<Document> splitDocuments = myTokenTextSplitter.splitDocuments(documents, chunkSize, overlap);
for (int i = 0; i < splitDocuments.size(); i++) { for (int i = 0; i < splitDocuments.size(); i++) {
Document d = splitDocuments.get(i); Document d = splitDocuments.get(i);
@ -145,6 +161,8 @@ public class DocumentProcessingService {
if (doc.getTags() != null && doc.getTags().containsKey("tags")) { if (doc.getTags() != null && doc.getTags().containsKey("tags")) {
meta.put("tags", doc.getTags().get("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)); splitDocuments.set(i, new Document(d.getId(), d.getText(), meta));
} }

275
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.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
@ -86,12 +88,15 @@ public class DocumentService {
* @param content 原文内容截断预览 * @param content 原文内容截断预览
* @param categoryId 分类ID * @param categoryId 分类ID
* @param tags 标签列表 * @param tags 标签列表
* @param chunkSize 分块大小可选覆盖全局配置
* @param overlap 重叠大小可选覆盖全局配置
* @return 创建完成的文档记录status=PROCESSING * @return 创建完成的文档记录status=PROCESSING
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public KnowledgeDocument uploadDocument(List<Document> documents, String title, String sourceName, public KnowledgeDocument uploadDocument(List<Document> documents, String title, String sourceName,
String fileType, Long fileSize, String content, String fileType, Long fileSize, String content,
Long categoryId, List<String> tags) {
Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
// 0. 内容去重检查 // 0. 内容去重检查
String contentHash = computeContentHash(content); String contentHash = computeContentHash(content);
if (contentHash != null) { if (contentHash != null) {
@ -101,6 +106,11 @@ public class DocumentService {
} }
} }
// 存储 per-doc 分块参数到 extraConfig便于重新处理时复用
Map<String, Object> extraConfig = new HashMap<>();
if (chunkSize != null) extraConfig.put("chunkSize", chunkSize);
if (overlap != null) extraConfig.put("overlap", overlap);
// 1. 创建文档记录状态 PROCESSING // 1. 创建文档记录状态 PROCESSING
KnowledgeDocument docRecord = KnowledgeDocument.builder() KnowledgeDocument docRecord = KnowledgeDocument.builder()
.title(title != null ? title : sourceName) .title(title != null ? title : sourceName)
@ -111,6 +121,8 @@ public class DocumentService {
.categoryId(categoryId != null ? categoryId : 0L) .categoryId(categoryId != null ? categoryId : 0L)
.tags(tags != null ? Map.of("tags", tags) : null) .tags(tags != null ? Map.of("tags", tags) : null)
.contentHash(contentHash) .contentHash(contentHash)
.enabled(true)
.extraConfig(extraConfig.isEmpty() ? null : extraConfig)
.status("PROCESSING") .status("PROCESSING")
.chunkCount(0) .chunkCount(0)
.build(); .build();
@ -118,7 +130,8 @@ public class DocumentService {
// 2. 触发异步处理分块 关键词 向量化 更新状态 // 2. 触发异步处理分块 关键词 向量化 更新状态
documentProcessingService.processDocumentAsync( 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()); log.info("文档已创建,后台处理中: id={}, title={}", docRecord.getId(), docRecord.getTitle());
return docRecord; return docRecord;
@ -127,7 +140,8 @@ public class DocumentService {
/** /**
* 解析文件并上传 * 解析文件并上传
*/ */
public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List<String> tags) {
public KnowledgeDocument uploadFile(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = tikaDocumentReader.read(file); List<Document> documents = tikaDocumentReader.read(file);
String fileType = getFileExtension(file.getOriginalFilename()); String fileType = getFileExtension(file.getOriginalFilename());
return uploadDocument(documents, return uploadDocument(documents,
@ -137,22 +151,24 @@ public class DocumentService {
file.getSize(), file.getSize(),
documents.get(0).getText(), documents.get(0).getText(),
categoryId, categoryId,
tags);
tags, chunkSize, overlap);
} }
/** /**
* 解析字符串并上传 * 解析字符串并上传
*/ */
public KnowledgeDocument uploadString(String content, String title, Long categoryId, List<String> tags) {
public KnowledgeDocument uploadString(String content, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = simpleStringDocumentReader.read(content); List<Document> documents = simpleStringDocumentReader.read(content);
return uploadDocument(documents, title, title, "txt", return uploadDocument(documents, title, title, "txt",
(long) content.length(), content, categoryId, tags);
(long) content.length(), content, categoryId, tags, chunkSize, overlap);
} }
/** /**
* 解析 Markdown 文件并上传 * 解析 Markdown 文件并上传
*/ */
public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List<String> tags) {
public KnowledgeDocument uploadMarkdown(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = markdownDocumentLoader.loadMarkdownFromFile(file); List<Document> documents = markdownDocumentLoader.loadMarkdownFromFile(file);
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents, return uploadDocument(documents,
@ -162,13 +178,14 @@ public class DocumentService {
file.getSize(), file.getSize(),
content, content,
categoryId, categoryId,
tags);
tags, chunkSize, overlap);
} }
/** /**
* 解析 JSON 文件基本方式并上传 * 解析 JSON 文件基本方式并上传
*/ */
public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List<String> tags) {
public KnowledgeDocument uploadJsonBasic(MultipartFile file, String title, Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadBasicJson(file); List<Document> documents = jsonDocumentLoader.loadBasicJson(file);
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents, return uploadDocument(documents,
@ -178,13 +195,15 @@ public class DocumentService {
file.getSize(), file.getSize(),
content, content,
categoryId, categoryId,
tags);
tags, chunkSize, overlap);
} }
/** /**
* 解析 JSON 文件按字段并上传 * 解析 JSON 文件按字段并上传
*/ */
public KnowledgeDocument uploadJsonFields(MultipartFile file, List<String> fields, String title, Long categoryId, List<String> tags) {
public KnowledgeDocument uploadJsonFields(MultipartFile file, List<String> fields, String title,
Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadJsonByFields(file, fields.toArray(new String[0])); List<Document> documents = jsonDocumentLoader.loadJsonByFields(file, fields.toArray(new String[0]));
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents, return uploadDocument(documents,
@ -194,13 +213,15 @@ public class DocumentService {
file.getSize(), file.getSize(),
content, content,
categoryId, categoryId,
tags);
tags, chunkSize, overlap);
} }
/** /**
* 解析 JSON 文件按指针并上传 * 解析 JSON 文件按指针并上传
*/ */
public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title, Long categoryId, List<String> tags) {
public KnowledgeDocument uploadJsonPointer(MultipartFile file, String pointer, String title,
Long categoryId, List<String> tags,
Integer chunkSize, Integer overlap) {
List<Document> documents = jsonDocumentLoader.loadJsonByPointer(file, pointer); List<Document> documents = jsonDocumentLoader.loadJsonByPointer(file, pointer);
String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n")); String content = documents.stream().map(Document::getText).collect(Collectors.joining("\n"));
return uploadDocument(documents, return uploadDocument(documents,
@ -210,15 +231,15 @@ public class DocumentService {
file.getSize(), file.getSize(),
content, content,
categoryId, categoryId,
tags);
tags, chunkSize, overlap);
} }
// ==================== 文档管理 ==================== // ==================== 文档管理 ====================
/** /**
* 分页查询文档列表手动分页支持关键词搜索
* 分页查询文档列表手动分页支持关键词搜索 + 标签筛选
*/ */
public Map<String, Object> listDocuments(int page, int size, Long categoryId, String status, String keyword) {
public Map<String, Object> listDocuments(int page, int size, Long categoryId, String status, String keyword, String tag) {
// 参数安全校验 // 参数安全校验
if (page < 1) page = 1; if (page < 1) page = 1;
if (size < 1 || size > 100) size = 10; if (size < 1 || size > 100) size = 10;
@ -237,6 +258,16 @@ public class DocumentService {
if (kw != null) { if (kw != null) {
countWrapper.and(w -> w.like("title", kw).or().like("source_name", kw)); 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 // 先查询总数不加 ORDER BY
Long total = documentMapper.selectCount(countWrapper); Long total = documentMapper.selectCount(countWrapper);
@ -252,6 +283,15 @@ public class DocumentService {
if (kw != null) { if (kw != null) {
listWrapper.and(w -> w.like("title", kw).or().like("source_name", kw)); 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.orderByDesc("create_time");
listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size); listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size);
List<KnowledgeDocument> records = documentMapper.selectList(listWrapper); List<KnowledgeDocument> records = documentMapper.selectList(listWrapper);
@ -453,6 +493,197 @@ public class DocumentService {
log.info("更新文档元信息: id={}, title={}", id, doc.getTitle()); 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<String, Object> batchToggleDocuments(List<Long> 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<String, Object> 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<Long> 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<Map<String, Object>> 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<String> 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<String, Object> 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<Document> 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<String> 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) .topK(topK)
.similarityThreshold(similarityThreshold); .similarityThreshold(similarityThreshold);
FilterExpressionBuilder builder = new FilterExpressionBuilder();
// P1-2.1: 始终过滤禁用的文档只检索启用的
var enabledFilter = builder.eq("enabled", "true");
List<String> categoryIdStrings = normalizeCategoryIds(categoryIds); List<String> categoryIdStrings = normalizeCategoryIds(categoryIds);
if (!categoryIdStrings.isEmpty()) { if (!categoryIdStrings.isEmpty()) {
FilterExpressionBuilder builder = new FilterExpressionBuilder();
List<Object> values = categoryIdStrings.stream() List<Object> values = categoryIdStrings.stream()
.map(value -> (Object) value) .map(value -> (Object) value)
.collect(Collectors.toList()); .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<Document> results; List<Document> results;
@ -496,6 +734,7 @@ public class DocumentService {
.build()) .build())
.stream() .stream()
.filter(doc -> categoryIdStrings.contains(getStringFromMetadata(doc.getMetadata(), "categoryId"))) .filter(doc -> categoryIdStrings.contains(getStringFromMetadata(doc.getMetadata(), "categoryId")))
.filter(doc -> "true".equals(getStringFromMetadata(doc.getMetadata(), "enabled")))
.limit(topK) .limit(topK)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

140
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 { 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 { export default {
template: ` template: `
<div :class="['modal-overlay', store.detailModal.visible ? 'active' : '']" @click.self="store.closeDetail()"> <div :class="['modal-overlay', store.detailModal.visible ? 'active' : '']" @click.self="store.closeDetail()">
<div class="modal-box">
<div class="modal-box" style="max-width:900px;">
<button class="modal-close" @click="store.closeDetail()">&times;</button> <button class="modal-close" @click="store.closeDetail()">&times;</button>
<h2>文档详情</h2> <h2>文档详情</h2>
@ -24,15 +28,37 @@ export default {
<div class="result-item"><div class="label">分类</div><div class="value">{{ store.getCategoryName(store.detailModal.doc.categoryId) }}</div></div> <div class="result-item"><div class="label">分类</div><div class="value">{{ store.getCategoryName(store.detailModal.doc.categoryId) }}</div></div>
<div class="result-item"><div class="label">创建时间</div><div class="value">{{ formatDate(store.detailModal.doc.createTime) }}</div></div> <div class="result-item"><div class="label">创建时间</div><div class="value">{{ formatDate(store.detailModal.doc.createTime) }}</div></div>
</div> </div>
<!-- P1-2.6: 原文预览Markdown 渲染 -->
<h3>原文内容</h3> <h3>原文内容</h3>
<div style="background:#fafafa;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:200px;overflow-y:auto;">{{ store.detailModal.doc.content || '-' }}</div>
<div v-if="isMarkdown" style="background:#fafafa;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:300px;overflow-y:auto;" v-html="renderedContent"></div>
<div v-else style="background:#fafafa;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:300px;overflow-y:auto;white-space:pre-wrap;">{{ store.detailModal.doc.content || '-' }}</div>
</div> </div>
<h3>分块详情{{ store.detailModal.chunks.length }} </h3> <h3>分块详情{{ store.detailModal.chunks.length }} </h3>
<div v-if="store.detailModal.chunksError" style="margin:8px 0 12px;padding:10px 12px;border:1px solid #f59e0b;background:#fffbeb;color:#92400e;border-radius:8px;font-size:13px;">{{ store.detailModal.chunksError }}</div> <div v-if="store.detailModal.chunksError" style="margin:8px 0 12px;padding:10px 12px;border:1px solid #f59e0b;background:#fffbeb;color:#92400e;border-radius:8px;font-size:13px;">{{ store.detailModal.chunksError }}</div>
<div v-for="(chunk, i) in store.detailModal.chunks" :key="i" class="search-result">
<div class="meta">#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }}</div>
<div class="content">{{ chunk.content || '' }}</div>
<div v-for="(chunk, i) in store.detailModal.chunks" :key="i" class="search-result" style="position:relative;">
<div class="meta">
#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }}
<!-- P1-2.5: 分块操作按钮 -->
<span style="float:right;display:inline-flex;gap:4px;">
<button class="btn btn-sm btn-outline" @click="startEdit(i, chunk)" :disabled="editIdx === i">编辑</button>
<button class="btn btn-sm btn-danger" @click="removeChunk(i)">删除</button>
</span>
</div>
<!-- P1-2.5: 编辑模式 -->
<div v-if="editIdx === i" style="margin-top:8px;">
<textarea class="textarea" v-model="editContent" rows="6" style="width:100%;font-size:13px;"></textarea>
<div style="margin-top:6px;display:flex;gap:8px;">
<button class="btn btn-sm btn-primary" @click="saveEdit(i)" :disabled="editSaving">
{{ editSaving ? '保存中...' : '保存并重新向量化' }}
</button>
<button class="btn btn-sm btn-outline" @click="cancelEdit()">取消</button>
</div>
</div>
<!-- 正常展示模式 -->
<div v-else class="content">{{ chunk.content || '' }}</div>
</div> </div>
</template> </template>
</div> </div>
@ -46,6 +72,19 @@ export default {
: 'status-failed' : '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 '<p style="color:var(--sub);">无内容</p>'
return renderMarkdown(content)
})
function getKeywords(chunk) { function getKeywords(chunk) {
let meta = chunk.metadata let meta = chunk.metadata
if (typeof meta === 'object' && meta && meta.value) meta = meta.value 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
}
} }
} }

152
src/main/resources/static/components/DocList.js

@ -3,10 +3,13 @@
* P0-1: 关键词搜索标题/文件名模糊匹配 * P0-1: 关键词搜索标题/文件名模糊匹配
* P0-2: 文件大小列 + 分类名称列 * P0-2: 文件大小列 + 分类名称列
* P0-4: 处理中状态自动轮询刷新 * P0-4: 处理中状态自动轮询刷新
* P1-2.1: 文档启用/禁用开关按钮 + 批量操作
* P1-2.2: 批量移动分类
* P1-2.3: 标签筛选 + 标签 badge 展示
*/ */
import { ref, computed, onUnmounted } from 'vue' import { ref, computed, onUnmounted } from 'vue'
import { store } from '../js/store.js' 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' import { toast, formatDate, formatBytes } from '../js/utils.js'
export default { export default {
@ -25,6 +28,11 @@ export default {
<option value="PROCESSING">处理中</option> <option value="PROCESSING">处理中</option>
<option value="FAILED">失败</option> <option value="FAILED">失败</option>
</select> </select>
<!-- P1-2.3: 标签筛选 -->
<select class="select" v-model="filterTag" @change="load()" style="max-width:150px;">
<option value="">全部标签</option>
<option v-for="t in store.tags" :key="t.tag" :value="t.tag">{{ t.tag }} ({{ t.count }})</option>
</select>
<button class="btn btn-outline btn-sm" @click="load()">刷新</button> <button class="btn btn-outline btn-sm" @click="load()">刷新</button>
<!-- 处理中提示 --> <!-- 处理中提示 -->
@ -35,6 +43,18 @@ export default {
<span style="font-size:12px;color:var(--text);font-weight:600;">已选 {{ selectedIds.size }} </span> <span style="font-size:12px;color:var(--text);font-weight:600;">已选 {{ selectedIds.size }} </span>
<button class="btn btn-danger btn-sm" @click="batchRemove">批量删除</button> <button class="btn btn-danger btn-sm" @click="batchRemove">批量删除</button>
<button class="btn btn-warn btn-sm" @click="batchReprocess">批量重新处理</button> <button class="btn btn-warn btn-sm" @click="batchReprocess">批量重新处理</button>
<!-- P1-2.1: 批量启用/禁用 -->
<button class="btn btn-sm" style="background:#059669;color:#fff;" @click="batchEnable">批量启用</button>
<button class="btn btn-sm" style="background:#6b7280;color:#fff;" @click="batchDisable">批量禁用</button>
<!-- P1-2.2: 批量移动分类 -->
<div style="display:inline-flex;align-items:center;gap:4px;">
<select class="select" v-model="moveCategoryId" style="min-width:120px;padding:4px 8px;font-size:12px;">
<option value="">移动到分类...</option>
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
<option value="0">未分类</option>
</select>
<button class="btn btn-sm btn-outline" @click="batchMove" :disabled="!moveCategoryId && moveCategoryId !== '0'">移动</button>
</div>
<button class="btn btn-outline btn-sm" @click="clearSelection">取消选择</button> <button class="btn btn-outline btn-sm" @click="clearSelection">取消选择</button>
</template> </template>
</div> </div>
@ -49,6 +69,7 @@ export default {
<th>大小</th> <th>大小</th>
<th>分类</th> <th>分类</th>
<th>状态</th> <th>状态</th>
<th>启用</th>
<th>分块数</th> <th>分块数</th>
<th>创建时间</th> <th>创建时间</th>
<th>操作</th> <th>操作</th>
@ -56,18 +77,31 @@ export default {
</thead> </thead>
<tbody> <tbody>
<tr v-if="documents.length === 0"> <tr v-if="documents.length === 0">
<td colspan="9" style="text-align:center;color:var(--sub);">暂无文档</td>
<td colspan="10" style="text-align:center;color:var(--sub);">暂无文档</td>
</tr> </tr>
<tr v-for="d in documents" :key="d.id" :style="selectedIds.has(d.id) ? 'background:#f3f4f6;' : ''">
<tr v-for="d in documents" :key="d.id"
:style="getRowStyle(d)">
<td><input type="checkbox" :checked="selectedIds.has(d.id)" @change="toggleSelect(d.id)" style="cursor:pointer;"></td> <td><input type="checkbox" :checked="selectedIds.has(d.id)" @change="toggleSelect(d.id)" style="cursor:pointer;"></td>
<td> <td>
<strong>{{ d.title }}</strong><br> <strong>{{ d.title }}</strong><br>
<span style="font-size:11px;color:var(--sub);">{{ d.sourceName || '' }}</span> <span style="font-size:11px;color:var(--sub);">{{ d.sourceName || '' }}</span>
<!-- P1-2.3: 标签 badge -->
<div v-if="getDocTags(d).length > 0" style="margin-top:2px;">
<span v-for="tag in getDocTags(d)" :key="tag"
style="display:inline-block;font-size:10px;background:#e0f2fe;color:#0369a1;padding:1px 6px;border-radius:4px;margin-right:3px;">{{ tag }}</span>
</div>
</td> </td>
<td><span class="category-tag">{{ d.fileType }}</span></td> <td><span class="category-tag">{{ d.fileType }}</span></td>
<td style="white-space:nowrap;">{{ formatSize(d.fileSize) }}</td> <td style="white-space:nowrap;">{{ formatSize(d.fileSize) }}</td>
<td>{{ store.getCategoryName(d.categoryId) }}</td> <td>{{ store.getCategoryName(d.categoryId) }}</td>
<td><span :class="statusClass(d.status)">{{ statusLabel(d.status) }}</span></td> <td><span :class="statusClass(d.status)">{{ statusLabel(d.status) }}</span></td>
<td>
<!-- P1-2.1: 启用/禁用开关 -->
<label class="switch-toggle" :title="d.enabled !== false ? '已启用(点击禁用)' : '已禁用(点击启用)'">
<input type="checkbox" :checked="d.enabled !== false" @change="toggle(d)">
<span class="switch-slider"></span>
</label>
</td>
<td>{{ d.chunkCount || 0 }}</td> <td>{{ d.chunkCount || 0 }}</td>
<td>{{ formatDate(d.createTime) }}</td> <td>{{ formatDate(d.createTime) }}</td>
<td> <td>
@ -99,7 +133,9 @@ export default {
const filterCategory = ref('') const filterCategory = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const keyword = ref('') const keyword = ref('')
const filterTag = ref('')
const selectedIds = ref(new Set()) const selectedIds = ref(new Set())
const moveCategoryId = ref('')
// 防抖搜索 // 防抖搜索
let debounceTimer = null let debounceTimer = null
@ -162,6 +198,22 @@ export default {
return formatBytes(bytes) 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) { async function load(p = 1) {
page.value = p page.value = p
// 切换页面时清空选择 // 切换页面时清空选择
@ -170,7 +222,8 @@ export default {
const json = await listDocuments(p, 10, const json = await listDocuments(p, 10,
filterCategory.value || undefined, filterCategory.value || undefined,
filterStatus.value || undefined, filterStatus.value || undefined,
keyword.value.trim() || undefined)
keyword.value.trim() || undefined,
filterTag.value || undefined)
if (json.success) { if (json.success) {
documents.value = json.data || [] documents.value = json.data || []
total.value = json.total || 0 total.value = json.total || 0
@ -212,6 +265,24 @@ export default {
store.openDetail(id) 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) { async function remove(id) {
if (!confirm('确定删除此文档?关联的向量也将被删除')) return if (!confirm('确定删除此文档?关联的向量也将被删除')) return
try { try {
@ -235,7 +306,6 @@ export default {
if (json.success) { if (json.success) {
toast(json.message || '已提交重新处理', 'success') toast(json.message || '已提交重新处理', 'success')
load(page.value) load(page.value)
// 触发轮询
if (!pollTimer) startPolling() if (!pollTimer) startPolling()
} else { } else {
toast(json.message || '重新处理失败', 'error') toast(json.message || '重新处理失败', 'error')
@ -272,7 +342,6 @@ export default {
toast(json.message, 'success') toast(json.message, 'success')
selectedIds.value = new Set() selectedIds.value = new Set()
load(page.value) load(page.value)
// 触发轮询
if (!pollTimer) startPolling() if (!pollTimer) startPolling()
} else { } else {
toast(json.message || '批量重新处理失败', 'error') 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 { 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, load, toggleSelect, toggleSelectAll, clearSelection,
viewDetail, remove, reprocess, batchRemove, batchReprocess, formatDate
viewDetail, toggle, remove, reprocess, batchRemove, batchReprocess,
batchEnable, batchDisable, batchMove, formatDate
} }
} }
} }

76
src/main/resources/static/components/DocUpload.js

@ -2,7 +2,7 @@
* 📤 文档上传面板 * 📤 文档上传面板
* 支持 6 种上传格式 + 前端校验 + 上传进度 + 分块配置 * 支持 6 种上传格式 + 前端校验 + 上传进度 + 分块配置
*/ */
import { ref, reactive } from 'vue'
import { ref, reactive, computed } from 'vue'
import { store } from '../js/store.js' import { store } from '../js/store.js'
import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js' import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js'
import { toast, formatBytes, authHeaders, API_BASE } from '../js/utils.js' import { toast, formatBytes, authHeaders, API_BASE } from '../js/utils.js'
@ -30,7 +30,28 @@ export default {
<option value="">选择分类可选</option> <option value="">选择分类可选</option>
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option> <option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select> </select>
<input class="input" v-model="uploadTags" placeholder="标签,逗号分隔(可选)">
<!-- P1-2.3: 标签输入组件支持自动补全 -->
<div style="flex:1;position:relative;">
<div class="tag-input-wrapper" @click="tagInputFocused = true; $refs.tagInputRef && $refs.tagInputRef.focus()">
<span v-for="(tag, idx) in tagList" :key="idx" class="tag-badge">
{{ tag }}<span class="tag-remove" @click.stop="removeTag(idx)">&times;</span>
</span>
<input ref="tagInputRef" v-model="tagInputValue" placeholder="输入标签,回车添加"
@keydown.enter.prevent="addTag()"
@keydown.comma.prevent="addTag()"
@keydown.backspace="tagInputValue === '' && tagList.length > 0 ? removeTag(tagList.length - 1) : null"
@input="onTagInput()"
@focus="tagInputFocused = true"
@blur="onTagBlur()">
</div>
<!-- 自动补全下拉 -->
<div v-if="tagInputFocused && filteredSuggestions.length > 0" class="tag-suggestions">
<div v-for="s in filteredSuggestions" :key="s.tag" class="tag-suggestion-item"
@mousedown.prevent="selectSuggestion(s.tag)">
{{ s.tag }} <span style="color:var(--sub);font-size:11px;">({{ s.count }})</span>
</div>
</div>
</div>
</div> </div>
<!-- 高级设置折叠面板 --> <!-- 高级设置折叠面板 -->
@ -160,6 +181,49 @@ export default {
const jsonBInput = ref(null) const jsonBInput = ref(null)
const jsonFInput = ref(null) const jsonFInput = ref(null)
const jsonPInput = 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({ const fileData = reactive({
file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null
@ -265,7 +329,8 @@ export default {
async function doUpload(type) { async function doUpload(type) {
const catId = uploadCategory.value 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 uploadProgress.value = -1
// 文本内容上传 // 文本内容上传
@ -320,6 +385,9 @@ export default {
formData.append('file', file) formData.append('file', file)
if (catId) formData.append('categoryId', catId) if (catId) formData.append('categoryId', catId)
if (tagsStr) formData.append('tags', tagsStr) 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 let json
const onProgress = files.length === 1 ? (p) => { uploadProgress.value = p } : null const onProgress = files.length === 1 ? (p) => { uploadProgress.value = p } : null
@ -391,6 +459,8 @@ export default {
showAdvanced, chunkSizeOverride, overlapOverride, showAdvanced, chunkSizeOverride, overlapOverride,
uploadProgress, uploadProgress,
fileInput, mdInput, jsonBInput, jsonFInput, jsonPInput, fileInput, mdInput, jsonBInput, jsonFInput, jsonPInput,
tagInputRef, tagList, tagInputValue, tagInputFocused,
filteredSuggestions, addTag, removeTag, selectSuggestion, onTagInput, onTagBlur,
fileData, fileInfo, results, validationErrors, store, fileData, fileInfo, results, validationErrors, store,
getButtonState, getButtonState,
handleFileSelect, handleDrop, doUpload, formatBytes handleFileSelect, handleDrop, doUpload, formatBytes

636
src/main/resources/static/components/ModelConfigManager.js

@ -1,8 +1,10 @@
/** /**
* AI 模型配置管理组件 * 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 * as api from '../js/api.js'
import { toast, formatDate } from '../js/utils.js' import { toast, formatDate } from '../js/utils.js'
@ -11,7 +13,8 @@ const APP_TYPE_OPTIONS = [
{ value: '', label: '全部类型' }, { value: '', label: '全部类型' },
{ value: 'CHAT', label: '智能客服对话' }, { value: 'CHAT', label: '智能客服对话' },
{ value: 'EMBEDDING', 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: '其他' } { value: 'other', label: '其他' }
] ]
// 提供商默认配置(切换时自动填充)
// chatModels / embeddingModels 按 app_type 分别展示,models 作为向后兼容兜底
// F5: 提供商默认配置(含模型能力元信息)
const PROVIDER_DEFAULTS = { const PROVIDER_DEFAULTS = {
dashscope: { dashscope: {
baseUrl: '', 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', defaultChatModel: 'qwen-turbo',
defaultEmbeddingModel: 'text-embedding-v2', defaultEmbeddingModel: 'text-embedding-v2',
tip: '通义千问团队版/私有化部署需填写专属 Base URL,公共版可留空' tip: '通义千问团队版/私有化部署需填写专属 Base URL,公共版可留空'
}, },
deepseek: { deepseek: {
baseUrl: 'https://api.deepseek.com', 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: [], embeddingModels: [],
defaultChatModel: 'deepseek-chat', defaultChatModel: 'deepseek-chat',
defaultEmbeddingModel: '', defaultEmbeddingModel: '',
@ -46,15 +58,29 @@ const PROVIDER_DEFAULTS = {
}, },
volcengine: { volcengine: {
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', 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', defaultChatModel: 'doubao-1-5-pro-32k',
defaultEmbeddingModel: 'doubao-embedding-text-240515', defaultEmbeddingModel: 'doubao-embedding-text-240515',
tip: '豆包对话推荐 doubao-1-5-pro-32k;向量化推荐 doubao-embedding-text-240515(2048维)。模型名称可在火山引擎 ARK 控制台获取' tip: '豆包对话推荐 doubao-1-5-pro-32k;向量化推荐 doubao-embedding-text-240515(2048维)。模型名称可在火山引擎 ARK 控制台获取'
}, },
moonshot: { moonshot: {
baseUrl: 'https://api.moonshot.cn/v1', 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: [], embeddingModels: [],
defaultChatModel: 'moonshot-v1-8k', defaultChatModel: 'moonshot-v1-8k',
defaultEmbeddingModel: '', defaultEmbeddingModel: '',
@ -62,7 +88,12 @@ const PROVIDER_DEFAULTS = {
}, },
zhipu: { zhipu: {
baseUrl: 'https://open.bigmodel.cn/api/paas/v4', 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: [], embeddingModels: [],
defaultChatModel: 'glm-4-flash', defaultChatModel: 'glm-4-flash',
defaultEmbeddingModel: '', defaultEmbeddingModel: '',
@ -70,8 +101,16 @@ const PROVIDER_DEFAULTS = {
}, },
openai: { openai: {
baseUrl: 'https://api.openai.com', 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', defaultChatModel: 'gpt-4o-mini',
defaultEmbeddingModel: 'text-embedding-3-small', defaultEmbeddingModel: 'text-embedding-3-small',
tip: '' 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 { export default {
template: ` template: `
<div class="card"> <div class="card">
<h2> AI 大模型配置管理</h2> <h2> AI 大模型配置管理</h2>
<!-- 筛选栏 -->
<div class="input-row">
<!-- F4: 健康状态汇总卡片 -->
<div v-if="healthSummary" class="health-summary" style="display:flex;gap:12px;margin:12px 0;flex-wrap:wrap;">
<div style="padding:10px 16px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;min-width:100px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#16a34a;">{{ healthSummary.online }}</div>
<div style="font-size:11px;color:#166534;">🟢 在线</div>
</div>
<div style="padding:10px 16px;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;min-width:100px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#dc2626;">{{ healthSummary.offline }}</div>
<div style="font-size:11px;color:#991b1b;">🔴 离线</div>
</div>
<div style="padding:10px 16px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;min-width:100px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#374151;">{{ healthSummary.total }}</div>
<div style="font-size:11px;color:#6b7280;">📊 总计</div>
</div>
</div>
<!-- 筛选栏 + 操作按钮 -->
<div class="input-row" style="flex-wrap:wrap;">
<select class="input" v-model="filterAppType" @change="load(1)" style="max-width:200px;"> <select class="input" v-model="filterAppType" @change="load(1)" style="max-width:200px;">
<option v-for="opt in appTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option> <option v-for="opt in appTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select> </select>
<button class="btn btn-primary btn-sm" @click="openAddModal"> 新建配置</button> <button class="btn btn-primary btn-sm" @click="openAddModal"> 新建配置</button>
<!-- F7: 导入/导出 -->
<button class="btn btn-outline btn-sm" @click="exportConfigs">📤 导出</button>
<button class="btn btn-outline btn-sm" @click="showImportDialog = true">📥 导入</button>
<button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button> <button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button>
</div> </div>
<!-- F6: Fallback 链可视化仅当有活跃配置时显示 -->
<div v-if="fallbackChains.length > 0" style="margin:12px 0;padding:12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;">
<div style="font-size:13px;font-weight:600;color:#1e40af;margin-bottom:8px;">🔗 Fallback 按优先级排列主模型在顶部</div>
<div v-for="chain in fallbackChains" :key="chain.appType" style="margin-bottom:8px;">
<div style="font-size:12px;color:#1e40af;font-weight:600;">{{ getAppTypeLabel(chain.appType) }}</div>
<div v-for="(item, idx) in chain.configs" :key="item.id"
style="display:flex;align-items:center;gap:6px;padding:4px 8px;margin:2px 0;background:white;border-radius:4px;font-size:12px;cursor:grab;"
draggable="true"
@dragstart="onDragStart($event, item, chain)"
@dragover.prevent
@drop="onDrop($event, item, chain)">
<span style="color:#6b7280;font-weight:600;">#{{ idx + 1 }}</span>
<span v-if="idx < chain.configs.length - 1" style="color:#93c5fd;"></span>
<span v-else style="color:#d1d5db;">·</span>
<strong>{{ item.name }}</strong>
<code style="font-size:11px;background:#f3f4f6;padding:1px 4px;border-radius:3px;">{{ item.model_name }}</code>
<span style="color:#6b7280;">P={{ item.priority }}</span>
<!-- F4: 健康状态指示 -->
<span v-if="healthMap[item.id]" :title="getHealthTooltip(item.id)"
:style="{ color: healthMap[item.id].status === 'ONLINE' ? '#16a34a' : '#dc2626' }">
{{ healthMap[item.id].status === 'ONLINE' ? '🟢' : '🔴' }}
<span v-if="healthMap[item.id].latencyMs" style="font-size:10px;">{{ healthMap[item.id].latencyMs }}ms</span>
</span>
<span v-else style="color:#d1d5db;" title="未检测">🟡</span>
</div>
</div>
</div>
<!-- 配置列表表格 --> <!-- 配置列表表格 -->
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table">
@ -111,7 +205,7 @@ export default {
<th>提供商</th> <th>提供商</th>
<th>温度</th> <th>温度</th>
<th>API Key</th> <th>API Key</th>
<th>状态</th>
<th>F4: 状态</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
@ -127,22 +221,48 @@ export default {
<td> <td>
<span class="badge" :class="getAppTypeBadgeClass(c.app_type)">{{ getAppTypeLabel(c.app_type) }}</span> <span class="badge" :class="getAppTypeBadgeClass(c.app_type)">{{ getAppTypeLabel(c.app_type) }}</span>
</td> </td>
<td><code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.model_name }}</code></td>
<td>
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.model_name }}</code>
<!-- F5: 模型能力标签 -->
<div v-if="getModelMeta(c)" style="font-size:10px;color:#6b7280;margin-top:2px;">
{{ formatTokens(getModelMeta(c).maxContextTokens) }} ctx · {{ formatTokens(getModelMeta(c).maxOutputTokens) }} out
<span v-if="getModelMeta(c).supportsVision"> · 👁</span>
<span v-if="getModelMeta(c).supportsFunctionCall"> · 🔧</span>
</div>
</td>
<td>{{ getProviderLabel(c.provider) }}</td> <td>{{ getProviderLabel(c.provider) }}</td>
<td>{{ c.temperature != null ? c.temperature : '-' }}</td> <td>{{ c.temperature != null ? c.temperature : '-' }}</td>
<td><code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.api_key || '-' }}</code></td> <td><code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.api_key || '-' }}</code></td>
<!-- F4: 健康状态列 -->
<td> <td>
<div style="display:flex;align-items:center;gap:4px;">
<span v-if="c.is_active" style="color:#16a34a;font-weight:600;"> <span v-if="c.is_active" style="color:#16a34a;font-weight:600;">
🟢 活跃 🟢 活跃
</span> </span>
<span v-else style="color:var(--sub);"> <span v-else style="color:var(--sub);">
未激活 未激活
</span> </span>
<!-- 健康指示器 -->
<span v-if="c.is_active && healthMap[c.id]"
:title="getHealthTooltip(c.id)"
:style="{ cursor: 'help', color: healthMap[c.id].status === 'ONLINE' ? '#16a34a' : '#dc2626' }">
{{ healthMap[c.id].status === 'ONLINE' ? '●' : '●' }}
<span v-if="healthMap[c.id].latencyMs" style="font-size:10px;">{{ healthMap[c.id].latencyMs }}ms</span>
</span>
<span v-else-if="c.is_active" style="color:#d1d5db;cursor:help;" title="未检测"></span>
</div>
</td> </td>
<td>
<button class="btn btn-sm btn-outline" @click="openEditModal(c)">编辑</button>
<button v-if="!c.is_active" class="btn btn-sm btn-primary" @click="activate(c.id)">激活</button>
<button v-if="!c.is_active" class="btn btn-sm btn-danger" @click="remove(c.id, c.name)">删除</button>
<td style="white-space:nowrap;">
<button class="btn btn-sm btn-outline" @click="openEditModal(c)" title="编辑"></button>
<!-- F1: 测试连接 -->
<button class="btn btn-sm btn-outline" @click="testConnection(c)" :disabled="testLoading[c.id]" title="测试连接">
{{ testLoading[c.id] ? '⏳' : '🔌' }}
</button>
<!-- F2: 复制 -->
<button class="btn btn-sm btn-outline" @click="duplicateConfig(c)" title="复制">📋</button>
<button v-if="!c.is_active" class="btn btn-sm btn-primary" @click="activate(c.id)" title="激活"></button>
<button v-if="c.is_active" class="btn btn-sm btn-outline" @click="deactivateConfig(c.id)" title="停用"></button>
<button v-if="!c.is_active" class="btn btn-sm btn-danger" @click="remove(c.id, c.name)" title="删除">🗑</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -159,6 +279,18 @@ export default {
</template> </template>
<button :disabled="currentPage >= totalPages" @click="load(currentPage + 1)">下一页</button> <button :disabled="currentPage >= totalPages" @click="load(currentPage + 1)">下一页</button>
</div> </div>
<!-- F1: 测试结果提示 -->
<div v-if="testResult.visible" class="test-result-toast"
:style="{ position: 'fixed', bottom: '20px', right: '20px', padding: '12px 20px', borderRadius: '8px', zIndex: 9999, color: 'white', maxWidth: '400px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
background: testResult.success ? '#16a34a' : '#dc2626' }">
<div style="font-weight:600;">{{ testResult.success ? '✅ 连接成功' : '❌ 连接失败' }}</div>
<div style="font-size:12px;margin-top:4px;">
延迟{{ testResult.latencyMs }}ms
<span v-if="testResult.dimensions"> · 维度{{ testResult.dimensions }}</span>
</div>
<div v-if="testResult.errorMessage" style="font-size:11px;margin-top:4px;opacity:0.9;">{{ testResult.errorMessage }}</div>
</div>
</div> </div>
<!-- 编辑/新建弹窗 --> <!-- 编辑/新建弹窗 -->
@ -208,12 +340,26 @@ export default {
<!-- 模型名称跨列 --> <!-- 模型名称跨列 -->
<div class="model-form-full"> <div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">模型名称 <span style="color:#dc2626;">*</span></label> <label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">模型名称 <span style="color:#dc2626;">*</span></label>
<!-- F5: 模型下拉项增加能力标签 -->
<input v-if="!currentProviderModels.length" type="text" class="input" v-model="editModal.form.model_name" <input v-if="!currentProviderModels.length" type="text" class="input" v-model="editModal.form.model_name"
:placeholder="currentProviderDefaultModel || '请输入模型名称'"> :placeholder="currentProviderDefaultModel || '请输入模型名称'">
<select v-else class="input" v-model="editModal.form.model_name">
<option v-for="m in currentProviderModels" :key="m" :value="m">{{ m }}</option>
<select v-else class="input" v-model="editModal.form.model_name" @change="onModelChange">
<option v-for="m in currentProviderModels" :key="m.name" :value="m.name">
{{ m.name }}
<template v-if="m.maxContextTokens"> ({{ formatTokens(m.maxContextTokens) }} ctx</template>
<template v-if="m.maxOutputTokens"> · {{ formatTokens(m.maxOutputTokens) }} out</template>
<template v-if="m.supportsVision"> · 👁</template>
<template v-if="m.supportsFunctionCall"> · 🔧</template>
<template v-if="m.maxContextTokens">)</template>
</option>
<option value="__custom__">自定义输入...</option> <option value="__custom__">自定义输入...</option>
</select> </select>
<!-- F5: 选中模型的能力展示 -->
<div v-if="selectedModelMeta" style="font-size:11px;color:#6b7280;margin-top:4px;">
📐 {{ formatTokens(selectedModelMeta.maxContextTokens) }} 上下文 · {{ formatTokens(selectedModelMeta.maxOutputTokens) }} 最大输出
<span v-if="selectedModelMeta.supportsVision"> · 👁 视觉</span>
<span v-if="selectedModelMeta.supportsFunctionCall"> · 🔧 函数调用</span>
</div>
<input v-if="editModal.form.model_name === '__custom__'" type="text" class="input" <input v-if="editModal.form.model_name === '__custom__'" type="text" class="input"
v-model="customModelName" placeholder="输入自定义模型名称" style="margin-top:6px;"> v-model="customModelName" placeholder="输入自定义模型名称" style="margin-top:6px;">
</div> </div>
@ -224,8 +370,15 @@ export default {
<input type="number" class="input" v-model.number="editModal.form.temperature" step="0.1" min="0" max="2" placeholder="0.7"> <input type="number" class="input" v-model.number="editModal.form.temperature" step="0.1" min="0" max="2" placeholder="0.7">
</div> </div>
<div> <div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">最大 Token </label>
<input type="number" class="input" v-model.number="editModal.form.max_tokens" min="1" max="128000" placeholder="2000">
<!-- F5: maxTokens 上限动态变化 -->
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">最大 Token
<span v-if="selectedModelMeta && selectedModelMeta.maxOutputTokens" style="font-weight:400;color:#6b7280;">
(上限 {{ formatTokens(selectedModelMeta.maxOutputTokens) }})
</span>
</label>
<input type="number" class="input" v-model.number="editModal.form.max_tokens" min="1"
:max="selectedModelMeta && selectedModelMeta.maxOutputTokens ? selectedModelMeta.maxOutputTokens : 128000"
placeholder="2000">
</div> </div>
<!-- Base URL跨列 --> <!-- Base URL跨列 -->
@ -243,10 +396,42 @@ export default {
<input type="number" class="input" v-model.number="editModal.form.embeddingDimensions" min="1" max="8192" placeholder="1024" style="max-width:240px;"> <input type="number" class="input" v-model.number="editModal.form.embeddingDimensions" min="1" max="8192" placeholder="1024" style="max-width:240px;">
<div style="font-size:11px;color:#92400e;margin-top:4px;"> <div style="font-size:11px;color:#92400e;margin-top:4px;">
修改维度后需重建向量表<code style="background:#fde68a;padding:1px 4px;border-radius:3px;">DROP TABLE IF EXISTS vector_store CASCADE</code> 修改维度后需重建向量表<code style="background:#fde68a;padding:1px 4px;border-radius:3px;">DROP TABLE IF EXISTS vector_store CASCADE</code>
常用维度千问 text-embedding-v2=1024 | 豆包 doubao-embedding-text=2048 | OpenAI text-embedding-3-small=1536
</div> </div>
</div> </div>
<!-- F3: 高级参数折叠区 -->
<div class="model-form-full">
<details :open="advancedParamsOpen" style="border:1px solid #e5e7eb;border-radius:8px;padding:0;">
<summary @click.prevent="advancedParamsOpen = !advancedParamsOpen"
style="padding:10px 14px;cursor:pointer;font-size:13px;font-weight:600;color:#374151;user-select:none;">
高级生成参数
<span v-if="hasAdvancedParams" style="color:#2563eb;font-weight:400;font-size:11px;margin-left:4px;">已配置</span>
</summary>
<div style="padding:12px 14px;display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Top P (核采样)</label>
<input type="number" class="input" v-model.number="editModal.form.topP" step="0.01" min="0" max="1" placeholder="0.8">
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Top K</label>
<input type="number" class="input" v-model.number="editModal.form.topK" step="1" min="1" max="100" placeholder="不限制">
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">频率惩罚 (Frequency Penalty)</label>
<input type="number" class="input" v-model.number="editModal.form.frequencyPenalty" step="0.1" min="-2" max="2" placeholder="0">
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">存在惩罚 (Presence Penalty)</label>
<input type="number" class="input" v-model.number="editModal.form.presencePenalty" step="0.1" min="-2" max="2" placeholder="0">
</div>
<div style="grid-column:1/-1;">
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">停止词序列逗号分隔最多 4 </label>
<input type="text" class="input" v-model="editModal.form.stopSequences" placeholder="如:END,STOP">
</div>
</div>
</details>
</div>
<!-- 优先级 + 是否激活双列 --> <!-- 优先级 + 是否激活双列 -->
<div> <div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">优先级</label> <label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">优先级</label>
@ -255,7 +440,7 @@ export default {
<div style="display:flex;align-items:flex-end;padding-bottom:4px;"> <div style="display:flex;align-items:flex-end;padding-bottom:4px;">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;">
<input type="checkbox" v-model="editModal.form.is_active"> <input type="checkbox" v-model="editModal.form.is_active">
设为活跃配置同类型只能有一个活跃
设为活跃配置F6: 同类型支持多个活跃按优先级 Fallback
</label> </label>
</div> </div>
@ -267,12 +452,58 @@ export default {
</div> </div>
<!-- 按钮 --> <!-- 按钮 -->
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;">
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;flex-wrap:wrap;">
<button class="btn btn-outline" @click="closeEditModal">取消</button> <button class="btn btn-outline" @click="closeEditModal">取消</button>
<!-- F1: 弹窗内测试连接 -->
<button v-if="editModal.mode === 'edit'" class="btn btn-outline" @click="testConnectionById(editModal.editId)"
:disabled="testLoading[editModal.editId]" style="border-color:#2563eb;color:#2563eb;">
{{ testLoading[editModal.editId] ? '⏳ 测试中...' : '🔌 测试连接' }}
</button>
<button class="btn btn-primary" @click="saveConfig">💾 保存</button> <button class="btn btn-primary" @click="saveConfig">💾 保存</button>
</div> </div>
</div> </div>
</div> </div>
<!-- F7: 导入弹窗 -->
<div class="modal-overlay" :class="{ active: showImportDialog }">
<div class="modal-box" style="max-width:600px;">
<button class="modal-close" @click="showImportDialog = false">×</button>
<h2>📥 导入模型配置</h2>
<div style="margin:16px 0;">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;">选择 JSON 文件</label>
<input type="file" accept=".json" @change="onImportFileSelect" class="input">
</div>
<div v-if="importPreview" style="margin:12px 0;padding:12px;background:#f9fafb;border-radius:8px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">导入预览</div>
<div style="font-size:12px;"> {{ importPreview.configs.length }} 条配置</div>
<div v-for="c in importPreview.configs.slice(0, 5)" :key="c.name" style="font-size:11px;color:#6b7280;margin:2px 0;">
· {{ c.name }} ({{ c.appType || c.app_type }}) - {{ c.modelName || c.model_name }}
</div>
<div v-if="importPreview.configs.length > 5" style="font-size:11px;color:#6b7280;">... 还有 {{ importPreview.configs.length - 5 }} </div>
</div>
<div style="margin:12px 0;">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;">冲突策略</label>
<select class="input" v-model="importConflict" style="max-width:300px;">
<option value="skip">跳过同名配置</option>
<option value="overwrite">覆盖同名配置</option>
</select>
</div>
<div v-if="importResult" style="margin:12px 0;padding:12px;border-radius:8px;"
:style="{ background: importResult.errors?.length ? '#fef2f2' : '#f0fdf4' }">
<div style="font-size:13px;font-weight:600;">导入完成</div>
<div style="font-size:12px;margin-top:4px;">
新增 {{ importResult.added }} · 覆盖 {{ importResult.overwritten }} · 跳过 {{ importResult.skipped }}
</div>
<div v-if="importResult.errors?.length" style="margin-top:8px;">
<div v-for="(err, i) in importResult.errors" :key="i" style="font-size:11px;color:#dc2626;"> {{ err }}</div>
</div>
</div>
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;">
<button class="btn btn-outline" @click="showImportDialog = false">关闭</button>
<button class="btn btn-primary" @click="doImport" :disabled="!importPreview">📥 确认导入</button>
</div>
</div>
</div>
`, `,
setup() { setup() {
const configs = ref([]) const configs = ref([])
@ -283,11 +514,32 @@ export default {
const showApiKey = ref(false) const showApiKey = ref(false)
const customModelName = ref('') const customModelName = ref('')
const providerTip = ref('') const providerTip = ref('')
const advancedParamsOpen = ref(false)
// F4: 健康状态
const healthMap = ref({})
const healthSummary = ref(null)
let healthTimer = null
// 计算当前提供商的模型列表和默认值
// F5: 当前提供商的模型列表和默认值
const currentProviderModels = ref([]) const currentProviderModels = ref([])
const currentProviderDefaultModel = ref('') const currentProviderDefaultModel = ref('')
const providerBaseUrlPlaceholder = 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({ const editModal = ref({
@ -308,44 +560,74 @@ export default {
max_tokens: 2000, max_tokens: 2000,
base_url: '', base_url: '',
embeddingDimensions: 1024, embeddingDimensions: 1024,
// F3: 高级参数
topP: null,
topK: null,
frequencyPenalty: null,
presencePenalty: null,
stopSequences: '',
// 基础字段
priority: 0, priority: 0,
is_active: false, is_active: false,
description: '' 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() { function onProviderChange() {
const provider = editModal.value.form.provider const provider = editModal.value.form.provider
const defaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.other const defaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.other
const isEmbedding = editModal.value.form.app_type === 'EMBEDDING' const isEmbedding = editModal.value.form.app_type === 'EMBEDDING'
// 仅在新建模式下自动填充 Base URL,编辑模式保留用户已配置的值
if (editModal.value.mode !== 'edit') { if (editModal.value.mode !== 'edit') {
editModal.value.form.base_url = defaults.baseUrl editModal.value.form.base_url = defaults.baseUrl
} }
// 按 app_type 选择模型列表和默认模型
const models = isEmbedding ? (defaults.embeddingModels || []) : (defaults.chatModels || []) const models = isEmbedding ? (defaults.embeddingModels || []) : (defaults.chatModels || [])
const defaultModel = isEmbedding ? (defaults.defaultEmbeddingModel || '') : (defaults.defaultChatModel || '') const defaultModel = isEmbedding ? (defaults.defaultEmbeddingModel || '') : (defaults.defaultChatModel || '')
// 仅新建模式且模型名为空时自动填充默认模型
if (editModal.value.mode === 'add' && !editModal.value.form.model_name) { if (editModal.value.mode === 'add' && !editModal.value.form.model_name) {
editModal.value.form.model_name = defaultModel editModal.value.form.model_name = defaultModel
} }
// F5: 使用对象格式的模型列表
currentProviderModels.value = models currentProviderModels.value = models
currentProviderDefaultModel.value = defaultModel currentProviderDefaultModel.value = defaultModel
providerTip.value = defaults.tip || '' providerTip.value = defaults.tip || ''
providerBaseUrlPlaceholder.value = defaults.baseUrl || '私有化部署时可填写自定义地址' providerBaseUrlPlaceholder.value = defaults.baseUrl || '私有化部署时可填写自定义地址'
customModelName.value = '' 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 || [] configs.value = json.data || []
total.value = json.total || 0 total.value = json.total || 0
totalPages.value = json.pages || 1 totalPages.value = json.pages || 1
// 加载后刷新健康状态
loadHealthStatus()
// 构建 Fallback 链
buildFallbackChains()
} else { } else {
toast(json.message || '查询失败', 'error') 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() { function openAddModal() {
@ -376,11 +731,11 @@ export default {
form: createEmptyForm() form: createEmptyForm()
} }
showApiKey.value = false showApiKey.value = false
advancedParamsOpen.value = false
onProviderChange() onProviderChange()
} }
function openEditModal(config) { function openEditModal(config) {
// 从 extraConfig 中读取 embeddingDimensions
let extraConfig = {} let extraConfig = {}
if (config.extraConfig) { if (config.extraConfig) {
try { try {
@ -401,12 +756,20 @@ export default {
max_tokens: config.max_tokens, max_tokens: config.max_tokens,
base_url: config.base_url || '', base_url: config.base_url || '',
embeddingDimensions: extraConfig.dimensions || 1024, 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, priority: config.priority || 0,
is_active: config.is_active || false, is_active: config.is_active || false,
description: config.description || '' description: config.description || ''
} }
} }
showApiKey.value = false showApiKey.value = false
advancedParamsOpen.value = false
onProviderChange() onProviderChange()
} }
@ -416,10 +779,6 @@ export default {
// ==================== 保存配置 ==================== // ==================== 保存配置 ====================
/**
* 将前端表单字段名snake_case转换为后端 Java 实体字段名camelCase
* 因为 Jackson 默认使用 camelCase 反序列化前端发送 snake_case 会导致字段无法映射
*/
function toCamelCase(form) { function toCamelCase(form) {
const data = { const data = {
name: form.name, name: form.name,
@ -434,11 +793,20 @@ export default {
isActive: form.is_active, isActive: form.is_active,
description: form.description description: form.description
} }
// EMBEDDING 类型:将向量维度写入 extraConfig
// 构建 extraConfig
const extraConfig = {}
if (form.app_type === 'EMBEDDING') { 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 return data
} }
@ -446,7 +814,6 @@ export default {
async function saveConfig() { async function saveConfig() {
const form = editModal.value.form const form = editModal.value.form
// 如果选了"自定义输入",取自定义名称
if (form.model_name === '__custom__') { if (form.model_name === '__custom__') {
if (!customModelName.value.trim()) { if (!customModelName.value.trim()) {
toast('请输入自定义模型名称', 'error') toast('请输入自定义模型名称', 'error')
@ -474,12 +841,10 @@ export default {
try { try {
let json let json
// 转换为 camelCase 后再发送,确保 Jackson 正确反序列化
const camelData = toCamelCase(form) const camelData = toCamelCase(form)
if (editModal.value.mode === 'add') { if (editModal.value.mode === 'add') {
json = await api.createModelConfig(camelData) json = await api.createModelConfig(camelData)
} else { } else {
// 编辑模式:API Key 留空则不发送(不覆盖原值)
if (!camelData.apiKey || !camelData.apiKey.trim()) { if (!camelData.apiKey || !camelData.apiKey.trim()) {
delete camelData.apiKey delete camelData.apiKey
} }
@ -487,7 +852,7 @@ export default {
} }
if (json.success) { if (json.success) {
toast(editModal.value.mode === 'add' ? '配置创建成功,已切换生效' : '配置更新成功,已切换生效', 'success')
toast(editModal.value.mode === 'add' ? '配置创建成功' : '配置更新成功', 'success')
closeEditModal() closeEditModal()
load(currentPage.value) load(currentPage.value)
} else { } 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) { async function activate(id) {
if (!confirm('确定激活此配置?同类型的其他配置将被自动停用,新配置将立即生效。')) return
if (!confirm('确定激活此配置?新配置将立即生效。')) return
try { try {
const json = await api.activateModelConfig(id) const json = await api.activateModelConfig(id)
if (json.success) { 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) { 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) { function getAppTypeLabel(appType) {
const map = { CHAT: '智能客服对话', EMBEDDING: '文本向量化', RAG_REWRITE: 'RAG查询重写' }
const map = { CHAT: '智能客服对话', EMBEDDING: '文本向量化', RAG_REWRITE: 'RAG查询重写', RERANK: '重排序' }
return map[appType] || appType return map[appType] || appType
} }
function getAppTypeBadgeClass(appType) { function getAppTypeBadgeClass(appType) {
const map = { CHAT: 'badge-get', EMBEDDING: '', RAG_REWRITE: '' }
const map = { CHAT: 'badge-get', EMBEDDING: '', RAG_REWRITE: '', RERANK: '' }
return map[appType] || '' return map[appType] || ''
} }
@ -549,13 +1046,38 @@ export default {
return found ? found.label : provider 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 { return {
configs, currentPage, totalPages, total, filterAppType, showApiKey, editModal, configs, currentPage, totalPages, total, filterAppType, showApiKey, editModal,
appTypeOptions: APP_TYPE_OPTIONS, providerOptions: PROVIDER_OPTIONS, 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, load, openAddModal, openEditModal, closeEditModal, saveConfig, activate, remove,
onProviderChange, getAppTypeLabel, getAppTypeBadgeClass, getProviderLabel, formatDate onProviderChange, getAppTypeLabel, getAppTypeBadgeClass, getProviderLabel, formatDate
} }

98
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-bar { gap:8px; }
.user-name, .user-role-tag { display:none; } .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;
}

113
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}` let path = `/document/list?page=${page}&size=${size}`
if (categoryId) path += `&categoryId=${categoryId}` if (categoryId) path += `&categoryId=${categoryId}`
if (status) path += `&status=${status}` if (status) path += `&status=${status}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (tag) path += `&tag=${encodeURIComponent(tag)}`
return getJSON(path) return getJSON(path)
} }
@ -291,6 +292,48 @@ export function batchReprocessDocuments(ids) {
return postJSON('/document/batch/reprocess', { 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}`) 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. * Truncate a conversation from the given user turn onward.
*/ */

18
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: { detailModal: {
visible: false, visible: false,
@ -213,6 +230,7 @@ export const store = reactive({
case 'search-test': case 'search-test':
this.loadCategories() this.loadCategories()
this.loadStats() this.loadStats()
this.loadTags()
break break
case 'category': case 'category':
case 'faq': case 'faq':

Loading…
Cancel
Save