Browse Source

把嵌入模型支持从仅千问扩展到多厂商。前端限制、后端自动覆盖

master
wanghanlin 23 hours ago
parent
commit
c4af6699cf
  1. 42
      CLAUDE.md
  2. 44
      client/dist/chatbot-sdk.js
  3. 2
      client/dist/chatbot-sdk.js.map
  4. 2
      client/dist/chatbot-sdk.min.js
  5. 2
      client/dist/chatbot-sdk.min.js.map
  6. 33
      client/src/api.ts
  7. 10
      client/src/chat.ts
  8. 52
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  9. 19
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  10. 58
      src/main/java/com/wok/supportbot/config/DynamicEmbeddingModel.java
  11. 160
      src/main/java/com/wok/supportbot/config/EmbeddingConfigFixer.java
  12. 190
      src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java
  13. 52
      src/main/java/com/wok/supportbot/config/ModelConfigLoader.java
  14. 40
      src/main/java/com/wok/supportbot/controller/AiController.java
  15. 5
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  16. 16
      src/main/java/com/wok/supportbot/controller/ConversationController.java
  17. 14
      src/main/java/com/wok/supportbot/rag/load/InMemoryVectorStoreConfig.java
  18. 52
      src/main/java/com/wok/supportbot/rag/load/PgVectorStoreConfig.java
  19. 13
      src/main/resources/application.yml
  20. 58
      src/main/resources/static/components/ModelConfigManager.js
  21. 44
      src/main/resources/static/sdk/chatbot-sdk.js
  22. 2
      src/main/resources/static/sdk/chatbot-sdk.js.map
  23. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js
  24. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js.map
  25. 1012
      src/main/resources/static/sdk/test.html

42
CLAUDE.md

@ -37,7 +37,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
`SupportBotApplication.java``@SpringBootApplication(exclude = PgVectorStoreAutoConfiguration.class)`,因为项目在 `PgVectorStoreConfig` 中手动配置 PgVectorStore Bean(标记 `@Primary`),不使用自动配置。另有一个 `InMemoryVectorStoreConfig` 作为开发备选。
### Spring AI 集成模式
- **ChatClient Builder**: 所有对话通过 `ChatClient.builder(dashscopeChatModel)`
- **ChatClient Builder**: 所有对话通过 `ChatClient.builder(chatModelFactory.getChatModel("CHAT"))` 构建,ChatModel 由 `ChatModelFactory` 按 DB 活跃配置动态创
- **Advisor 链**: `MessageChatMemoryAdvisor`(记忆) → `MyLoggerAdvisor`(日志) → `QuestionAnswerAdvisor`(RAG)
- **结构化输出**: `ProductInfoApp` 使用 `.entity(ProductInfo.class)` 提取结构化数据
- **SSE 流式**: 三种实现 — Flux\<String\>、Flux\<ServerSentEvent\>、SseEmitter
@ -57,26 +57,27 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
## 关键配置
- `application.yml` 含 API Key,已被 `.gitignore` 排除
- 对话模型: `qwen-turbo`,temperature: 0.7;Embedding: `text-embedding-v2`(1536维
- `application.yml`DashScope API Key,已被 `.gitignore` 排除
- **模型名称、温度、最大 Token 等参数已全部迁移到前端「AI 大模型配置管理」页面**,通过 `ai_model_config` 表管理,不再在 yml 中配置(yml 仅保留 `api-key`
- MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法)
- **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失。新增 Long ID 字段时务必加上此注解
- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`
- 向量维度: 1536,距离类型: COSINE_DISTANCE,索引: HNSW
- **向量维度**: `knowledge.vector.dimension` 配置(默认 1536)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW
- **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true
- **上传校验**: `ALLOWED_EXTENSIONS` 白名单 + 50MB 大小限制(`spring.servlet.multipart` 配置),前后端双重校验
- **文档去重**: `KnowledgeDocument.contentHash` 字段(SHA-256),上传时自动计算并查重
- **数据库自动初始化**: `DatabaseInitConfig` 在启动时检查并创建 `knowledge_category`/`knowledge_document`/`ai_model_config` 等表,对已存在的 `knowledge_document` 表会自动补加 `content_hash` 列。注意 `knowledge-base.sql` 脚本为早期版本,缺少此列,实际以 `DatabaseInitConfig` 为准
### 模型配置管理
- **ai_model_config 表**: 存储大模型配置,支持多套配置按 App 类型(CHAT / PRODUCT_EXTRACT / EMBEDDING / RAG_REWRITE)独立管理
- **ai_model_config 表**: 存储大模型配置,支持多套配置按 App 类型(CHAT / PRODUCT_EXTRACT / EMBEDDING / RAG_REWRITE)独立管理。**所有模型参数(名称、温度、最大Token、API Key、Base URL 等)全部由此表管理,不再依赖 application.yml**
- **激活互斥**: 同一 App 类型只能有一个 `is_active=true` 的配置,激活操作由 Service 层 `@Transactional` 保证
- **启动 seed**: 首次启动时自动从 `application.yml` 读取当前配置写入 DB 作为默认数据
- **启动校验**: `ModelConfigLoader` 在应用就绪后比较 DB 活跃配置与 yml 配置,不一致时打印 WARNING 日志
- **启动 seed**: 首次启动时使用硬编码默认值写入 DB(`qwen-turbo` / `text-embedding-v2`),用户可在前端修改
- **启动校验**: `ModelConfigLoader` 在应用就绪后检查 DB 中每种 App 类型是否有活跃配置,并对 DashScope 提供商比较 DB 与 yml 的 API Key 一致性
- **API Key 脱敏**: 前端展示时只显示前 4 位 + `****` + 后 4 位
- **运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel,配置变更时立即生效(无需重启)
- **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),通过 `spring-ai-openai` + 自定义 `baseUrl` 接入
- **缓存刷新**: 配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `AssistantApp.clearCache()`
- **ChatModel 运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel(包括 DashScope,不再复用 yml 自动配置的 Bean),配置变更时立即生效(无需重启)
- **EmbeddingModel 运行时切换**: 通过 `EmbeddingModelFactory` + `DynamicEmbeddingModel` 代理,按 DB 活跃配置动态创建/缓存 EmbeddingModel,`PgVectorStoreConfig` 和 `InMemoryVectorStoreConfig` 注入 `DynamicEmbeddingModel`,向量化模型配置变更后无需重启即可生效
- **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),ChatModel 和 EmbeddingModel 均通过对应 API 手动构建
- **缓存刷新**: 配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `EmbeddingModelFactory.clearCache()` + `AssistantApp.clearCache()`
### 依赖版本
- Spring AI BOM: `1.0.1`,统一管理所有 `org.springframework.ai` 依赖版本
@ -84,6 +85,25 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- `spring-ai-openai`: BOM 管理(OpenAI 兼容提供商支持)
- `spring-ai-alibaba-starter` (M6.1) 已移除,不再使用
### EmbeddingModel 架构
- **EmbeddingConfigFixer**:`ApplicationListener<ApplicationReadyEvent>`,启动时检查 EMBEDDING 配置合理性、**校验 EmbeddingModel 实际维度与 yml 配置是否一致**,不一致时 WARN 告警并给出修复步骤。**不再强制修正非 DashScope 配置**,尊重用户在 DB 中配置的提供商和模型
- **EmbeddingModelFactory**:按 DB 活跃配置动态创建/缓存 EmbeddingModel,**支持多种提供商**:
- DashScope(通义千问):`DashScopeEmbeddingModel`
- OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI):通过 `OpenAiEmbeddingModel` + 自定义 baseUrl + `embeddingsPath` 创建
- **各厂商 embeddingsPath 映射**
| 提供商 | embeddingsPath | 说明 |
|--------|---------------|------|
| dashscope | — | 不走 OpenAI 兼容,使用 DashScopeEmbeddingModel |
| volcengine | `/embeddings` | baseUrl 已含 `/api/v3`,不能重复加 `/v1` |
| moonshot | `/embeddings` | baseUrl 已含 `/v1` |
| zhipu | `/embeddings` | baseUrl 已含 `/api/paas/v4` |
| deepseek | `/v1/embeddings` | 标准 OpenAI 路径 |
| openai | `/v1/embeddings` | 标准 OpenAI 路径 |
- **豆包多模态模型**(`doubao-embedding-vision*`)使用 `/embeddings/multimodal` 端点,请求格式与 OpenAI 不兼容,Factory 会检测并拒绝创建,提示改用纯文本模型
- 注意:各提供商的 embedding 端点兼容性由用户自行验证,向量维度需与 PgVectorStore 的 `dimensions(1536)` 一致
- **DynamicEmbeddingModel**:代理类实现 `EmbeddingModel` 接口,每次调用委托给 Factory,使 VectorStore 无需重建即可热切换
- **前端**:`ModelConfigManager.js` 对 EMBEDDING 类型不再限制 provider,可自由选择任意提供商
## 前端架构
- **技术栈**: Vue 3 CDN + ES Module(`importmap` 引入,无构建工具)
@ -110,4 +130,4 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- `DocumentService.searchDocuments()`: Spring AI 1.0.1 的 filter 支持有限,分类过滤暂未实现
- `CompressionQueryRewriter`: 当前传入空历史列表
- MyBatis Plus 3.5.12 的 `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现
- `ChatModelFactory` 中 DashScope 配置始终返回同一个自动配置 Bean,不支持按 DB 中的不同模型名/温度创建独立实例
- `PgVectorStoreConfig.dimensions(1536)` 硬编码了向量维度,切换非 1536 维的 Embedding 模型时需修改并重建 vector_store 表 → **已修复:维度由 `knowledge.vector.dimension` 配置,启动时自动检测不匹配并告警**

44
client/dist/chatbot-sdk.js

@ -98,15 +98,9 @@ var ChatbotSDK = (function () {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig.integrateId);
if (currentConfig.userId) {
params.set('accountId', currentConfig.userId);
}
if (currentConfig.roleId) {
params.set('roleId', String(currentConfig.roleId));
}
if (currentConfig.categoryId) {
params.set('categoryId', String(currentConfig.categoryId));
}
setIfPresent(params, 'accountId', currentConfig.userId);
setIfPresent(params, 'roleId', currentConfig.roleId);
setIfPresent(params, 'categoryId', currentConfig.categoryId);
return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`);
}
/** 构建 SSE 流式请求 URL */
@ -114,17 +108,21 @@ var ChatbotSDK = (function () {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig.integrateId);
if (currentConfig.userId) {
params.set('accountId', currentConfig.userId);
}
if (currentConfig.roleId) {
params.set('roleId', String(currentConfig.roleId));
}
if (currentConfig.categoryId) {
params.set('categoryId', String(currentConfig.categoryId));
}
setIfPresent(params, 'accountId', currentConfig.userId);
setIfPresent(params, 'roleId', currentConfig.roleId);
setIfPresent(params, 'categoryId', currentConfig.categoryId);
return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`);
}
/**
* 安全设置可选参数仅当 value 非空时追加数字类型直接转字符串
*/
function setIfPresent(params, key, value) {
if (value === undefined || value === null)
return;
if (typeof value === 'string' && value.trim() === '')
return;
params.set(key, String(value));
}
/** 带超时的 fetch 封装 */
async function safeFetch(url, options = {}, timeout = REQUEST_TIMEOUT) {
const controller = new AbortController();
@ -1084,18 +1082,18 @@ var ChatbotSDK = (function () {
let aiContent;
const aiTimestamp = now();
if (config$1.streaming) {
// 流式输出
// 流式输出:气泡由 sendStreamMessage 内部的 onChunk 回调创建
aiContent = await sendStreamMessage(text, aiTimestamp);
}
else {
// 同步请求
// 同步请求:需要在此渲染 AI 气泡
aiContent = await chatRequest(text);
}
// 4. 隐藏 loading
// 4. 隐藏 loading(流式模式在 onChunk 中已隐藏,此处做兜底)
if (hideLoadingFn$1)
hideLoadingFn$1();
// 5. 渲染 AI 气泡
if (messagesContainer$1) {
// 5. 非流式模式:在此渲染 AI 气泡;流式模式气泡已在 stream 回调中创建
if (!config$1.streaming && messagesContainer$1) {
renderAIBubble(messagesContainer$1, aiContent, aiTimestamp);
}
const aiMsg = {

2
client/dist/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File

33
client/src/api.ts

@ -30,15 +30,9 @@ function buildChatUrl(message: string): string {
params.set('message', message);
params.set('chatId', currentConfig!.integrateId);
if (currentConfig!.userId) {
params.set('accountId', currentConfig!.userId);
}
if (currentConfig!.roleId) {
params.set('roleId', String(currentConfig!.roleId));
}
if (currentConfig!.categoryId) {
params.set('categoryId', String(currentConfig!.categoryId));
}
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'roleId', currentConfig!.roleId);
setIfPresent(params, 'categoryId', currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`);
}
@ -49,19 +43,22 @@ function buildChatSSEUrl(message: string): string {
params.set('message', message);
params.set('chatId', currentConfig!.integrateId);
if (currentConfig!.userId) {
params.set('accountId', currentConfig!.userId);
}
if (currentConfig!.roleId) {
params.set('roleId', String(currentConfig!.roleId));
}
if (currentConfig!.categoryId) {
params.set('categoryId', String(currentConfig!.categoryId));
}
setIfPresent(params, 'accountId', currentConfig!.userId);
setIfPresent(params, 'roleId', currentConfig!.roleId);
setIfPresent(params, 'categoryId', currentConfig!.categoryId);
return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`);
}
/**
* value
*/
function setIfPresent(params: URLSearchParams, key: string, value: string | number | undefined): void {
if (value === undefined || value === null) return;
if (typeof value === 'string' && value.trim() === '') return;
params.set(key, String(value));
}
/** 带超时的 fetch 封装 */
async function safeFetch(
url: string,

10
client/src/chat.ts

@ -145,18 +145,18 @@ async function handleSend(): Promise<void> {
const aiTimestamp = now();
if (config.streaming) {
// 流式输出
// 流式输出:气泡由 sendStreamMessage 内部的 onChunk 回调创建
aiContent = await sendStreamMessage(text, aiTimestamp);
} else {
// 同步请求
// 同步请求:需要在此渲染 AI 气泡
aiContent = await chatRequest(text);
}
// 4. 隐藏 loading
// 4. 隐藏 loading(流式模式在 onChunk 中已隐藏,此处做兜底)
if (hideLoadingFn) hideLoadingFn();
// 5. 渲染 AI 气泡
if (messagesContainer) {
// 5. 非流式模式:在此渲染 AI 气泡;流式模式气泡已在 stream 回调中创建
if (!config.streaming && messagesContainer) {
renderAIBubble(messagesContainer, aiContent, aiTimestamp);
}
const aiMsg: ChatMessage = {

52
src/main/java/com/wok/supportbot/config/ChatModelFactory.java

@ -1,14 +1,17 @@
package com.wok.supportbot.config;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import com.wok.supportbot.entity.AiModelConfig;
import com.wok.supportbot.service.AiModelConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.util.Map;
@ -17,8 +20,10 @@ import java.util.concurrent.ConcurrentHashMap;
/**
* ChatModel 工厂
* DB 活跃配置动态创建/缓存 ChatModel 实例支持运行时切换提供商
* - DashScope复用 spring-ai-alibaba-starter-dashscope 自动配置的 Bean
* - DeepSeek / Kimi / 豆包 / 智谱等通过 spring-ai-openai 模块 + 自定义 baseUrl 创建
* - DashScope手动构造 DashScopeApi + DashScopeChatModel DB 配置读取 modelName/temperature
* - DeepSeek / Kimi / 豆包 / 智谱 / OpenAI 通过 spring-ai-openai 模块 + 自定义 baseUrl 创建
*
* 所有 ChatModel 均通过 DB 配置动态创建不再复用 application.yml 自动配置的 Bean
*/
@Component
@Slf4j
@ -27,12 +32,8 @@ public class ChatModelFactory {
@Autowired
private AiModelConfigService configService;
/**
* DashScope 自动配置的 ChatModel Bean
* spring-ai-alibaba-starter-dashscope 注册标记 @Primary
*/
@Autowired
private ChatModel dashscopeChatModel;
private ToolCallingManager toolCallingManager;
/**
* ChatModel 缓存key = "provider:apiKey:modelName"
@ -52,16 +53,16 @@ public class ChatModelFactory {
/**
* 按应用类型获取活跃的 ChatModel
* 如果该类型无活跃配置回退到 DashScope 默认
* 如果该类型无活跃配置抛出异常不应在缺少配置时静默回退
*
* @param appType 应用类型CHAT / PRODUCT_EXTRACT / EMBEDDING / RAG_REWRITE
* @return ChatModel 实例
*/
public ChatModel getChatModel(String appType) {
AiModelConfig config = configService.getActiveConfigWithFullKey(appType);
if (config == null) {
log.warn("应用类型 [{}] 无活跃配置,回退到 DashScope 默认", appType);
return dashscopeChatModel;
if (config == null || config.getApiKey() == null || config.getApiKey().isBlank()) {
throw new IllegalStateException(
"应用类型 [" + appType + "] 无活跃配置,请在管理页面配置该类型的 AI 模型");
}
return getOrCreateChatModel(config);
}
@ -77,13 +78,32 @@ public class ChatModelFactory {
/**
* 创建 ChatModel 实例
* - dashscope复用自动配置的 Bean
* - dashscope手动构造 DashScopeApi + DashScopeChatModel DB 配置指定 model/temperature/maxTokens
* - 其他提供商通过 OpenAI 兼容 API 创建
*/
private ChatModel createChatModel(AiModelConfig config) {
if ("dashscope".equals(config.getProvider())) {
log.info("复用 DashScope ChatModel Bean: model={}", config.getModelName());
return dashscopeChatModel;
if ("dashscope".equalsIgnoreCase(config.getProvider())) {
log.info("创建 DashScope ChatModel: model={}, temperature={}, maxTokens={}",
config.getModelName(), config.getTemperature(), config.getMaxTokens());
DashScopeApi api = DashScopeApi.builder()
.apiKey(config.getApiKey())
.build();
DashScopeChatOptions.DashscopeChatOptionsBuilder optionsBuilder = DashScopeChatOptions.builder()
.withModel(config.getModelName());
if (config.getTemperature() != null) {
optionsBuilder.withTemperature(config.getTemperature());
}
if (config.getMaxTokens() != null) {
optionsBuilder.withMaxToken(config.getMaxTokens());
}
return DashScopeChatModel.builder()
.dashScopeApi(api)
.defaultOptions(optionsBuilder.build())
.toolCallingManager(toolCallingManager)
.build();
}
// OpenAI 兼容提供商

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

@ -21,15 +21,6 @@ public class DatabaseInitConfig {
@Value("${spring.ai.dashscope.api-key:}")
private String dashscopeApiKey;
@Value("${spring.ai.dashscope.chat.options.model:qwen-turbo}")
private String chatModelName;
@Value("${spring.ai.dashscope.chat.options.temperature:0.7}")
private Double chatTemperature;
@Value("${spring.ai.dashscope.embedding.options.model:text-embedding-v2}")
private String embeddingModelName;
@PostConstruct
public void init() {
try {
@ -401,16 +392,16 @@ public class DatabaseInitConfig {
}
insertDefaultModelConfig("Chat Default", "CHAT", "dashscope",
dashscopeApiKey, chatModelName, chatTemperature, 2000,
true, 100, "Default chat model config from application.yml");
dashscopeApiKey, "qwen-turbo", 0.7, 2000,
true, 100, "Default chat model config");
insertDefaultModelConfig("Product Extract Default", "PRODUCT_EXTRACT", "dashscope",
dashscopeApiKey, chatModelName, 0.3, 2000,
dashscopeApiKey, "qwen-turbo", 0.3, 2000,
true, 90, "Default product extraction model config");
insertDefaultModelConfig("Embedding Default", "EMBEDDING", "dashscope",
dashscopeApiKey, embeddingModelName, null, null,
dashscopeApiKey, "text-embedding-v2", null, null,
true, 80, "Default embedding model config");
insertDefaultModelConfig("RAG Rewrite Default", "RAG_REWRITE", "dashscope",
dashscopeApiKey, chatModelName, 0.5, 1000,
dashscopeApiKey, "qwen-turbo", 0.5, 1000,
true, 70, "Default RAG rewrite model config");
}

58
src/main/java/com/wok/supportbot/config/DynamicEmbeddingModel.java

@ -0,0 +1,58 @@
package com.wok.supportbot.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import java.util.List;
/**
* 动态 EmbeddingModel 代理
* 实现 EmbeddingModel 接口每次调用委托给 EmbeddingModelFactory 当前活跃的实例
* 这样 PgVectorStore / SimpleVectorStore 无需重建即可在运行时切换向量化模型
*
* 注意每次调用都走 factoryfactory 内部有缓存开销很小
* 仅在配置变更后第一次调用时才触发新实例创建
*/
@Slf4j
public class DynamicEmbeddingModel implements EmbeddingModel {
private final EmbeddingModelFactory embeddingModelFactory;
public DynamicEmbeddingModel(EmbeddingModelFactory embeddingModelFactory) {
this.embeddingModelFactory = embeddingModelFactory;
}
private EmbeddingModel getDelegate() {
return embeddingModelFactory.getEmbeddingModel();
}
@Override
public float[] embed(Document document) {
return getDelegate().embed(document);
}
@Override
public EmbeddingResponse embedForResponse(List<String> texts) {
return getDelegate().embedForResponse(texts);
}
@Override
public EmbeddingResponse call(EmbeddingRequest request) {
return getDelegate().call(request);
}
@Override
public int dimensions() {
return getDelegate().dimensions();
}
/**
* 返回当前委托的 EmbeddingModel 实例供调试/日志使用
*/
public EmbeddingModel getCurrentDelegate() {
return getDelegate();
}
}

160
src/main/java/com/wok/supportbot/config/EmbeddingConfigFixer.java

@ -0,0 +1,160 @@
package com.wok.supportbot.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* Embedding 配置检查器
* 在应用启动完成后检查 EMBEDDING 配置的合理性并记录日志
* 注意不再强制修正非 DashScope 配置尊重用户在 DB 中配置的提供商和模型
* 新增检测 EmbeddingModel 返回的实际维度与 yml 配置的向量维度是否一致
*/
@Component
@Slf4j
public class EmbeddingConfigFixer implements ApplicationListener<ApplicationReadyEvent> {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private EmbeddingModelFactory embeddingModelFactory;
@Value("${spring.ai.dashscope.api-key:}")
private String dashscopeApiKey;
@Value("${knowledge.vector.dimension:1536}")
private int fallbackDimension;
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
try {
checkEmbeddingConfig();
checkDimensionConsistency();
} catch (Exception e) {
log.warn("Embedding 配置检查异常(不影响启动): {}", e.getMessage());
}
}
private void checkEmbeddingConfig() {
// 打印当前 EMBEDDING 所有配置
List<Map<String, Object>> allConfigs = jdbcTemplate.queryForList(
"SELECT id, name, provider, model_name, api_key, base_url, is_active, is_delete " +
"FROM ai_model_config WHERE app_type = 'EMBEDDING' ORDER BY is_active DESC");
log.info("========== EMBEDDING 配置检查 ==========");
for (Map<String, Object> row : allConfigs) {
String apiKey = (String) row.get("api_key");
String maskedKey = apiKey != null && apiKey.length() > 8
? apiKey.substring(0, 4) + "****" + apiKey.substring(apiKey.length() - 4)
: "****";
log.info(" id={} name={} provider={} model={} apiKey={} baseUrl={} active={} deleted={}",
row.get("id"), row.get("name"), row.get("provider"),
row.get("model_name"), maskedKey, row.get("base_url"),
row.get("is_active"), row.get("is_delete"));
}
// 查找活跃配置
Map<String, Object> activeConfig = null;
for (Map<String, Object> row : allConfigs) {
if (Boolean.TRUE.equals(row.get("is_active"))
&& Boolean.FALSE.equals(row.get("is_delete"))) {
activeConfig = row;
break;
}
}
if (activeConfig == null) {
log.warn("EMBEDDING 类型无活跃配置,请在前端管理页面配置");
return;
}
String apiKey = (String) activeConfig.get("api_key");
String modelName = (String) activeConfig.get("model_name");
String provider = (String) activeConfig.get("provider");
String baseUrl = (String) activeConfig.get("base_url");
// 仅记录警告不再强制改写用户可能有意使用非 DashScope Embedding 提供商
if (!"dashscope".equalsIgnoreCase(provider)) {
log.info("EMBEDDING 使用非 DashScope 提供商 [{}],model={}。"
+ "请确保前端「向量维度」与实际模型输出一致,否则需重建 vector_store 表", provider, modelName);
}
// DashScope 用户的 API Key 一致性提示不强制修正尊重用户 DB 配置
if ("dashscope".equalsIgnoreCase(provider)
&& dashscopeApiKey != null && !dashscopeApiKey.isBlank()
&& !dashscopeApiKey.equals(apiKey)) {
log.warn("EMBEDDING DashScope 配置的 API Key 与 application.yml 不一致,以 DB 配置为准。"
+ "如需统一管理,请在管理页面修改。");
}
if (baseUrl != null && !baseUrl.isBlank()) {
log.debug("EMBEDDING 配置了自定义 baseUrl={}", baseUrl);
}
log.info("EMBEDDING 配置检查完成,当前使用 provider={} model={}", provider, modelName);
}
/**
* 检测 EmbeddingModel 实际返回的维度与配置的向量维度是否一致
* 配置维度优先级DB extraConfig.dimensions > application.yml knowledge.vector.dimension
*/
private void checkDimensionConsistency() {
try {
int configuredDimension = resolveConfiguredDimension();
EmbeddingModel model = embeddingModelFactory.getEmbeddingModel();
int actualDimension = model.dimensions();
if (actualDimension > 0 && actualDimension != configuredDimension) {
log.warn("========== ⚠️ 向量维度不匹配!==========");
log.warn("Embedding 模型 {} 返回维度: {}", model, actualDimension);
log.warn("当前配置的向量维度: {}", configuredDimension);
log.warn("请执行以下步骤修复:");
log.warn(" 1. 在前端「AI 大模型配置管理」中修改 EMBEDDING 配置的向量维度为 {}", actualDimension);
log.warn(" 2. 在 PostgreSQL 中执行: DROP TABLE IF EXISTS vector_store CASCADE");
log.warn(" 3. 重启服务(PgVectorStore 会自动重建表)");
log.warn(" 4. 重新上传所有知识库文档");
log.warn("========================================");
} else {
log.info("向量维度校验通过: Embedding 模型返回 {} 维,配置 {} 维,一致",
actualDimension, configuredDimension);
}
} catch (Exception e) {
log.warn("无法校验向量维度(EmbeddingModel 初始化可能失败): {}", e.getMessage());
}
}
/**
* 解析配置维度DB extraConfig 优先yml 兜底
*/
private int resolveConfiguredDimension() {
try {
List<Map<String, Object>> activeConfigs = jdbcTemplate.queryForList(
"SELECT extra_config FROM ai_model_config WHERE app_type = 'EMBEDDING' AND is_active = true AND is_delete = false LIMIT 1");
if (!activeConfigs.isEmpty()) {
Map<String, Object> row = activeConfigs.get(0);
String extraConfigJson = (String) row.get("extra_config");
// 简单解析 JSONB 中的 dimensions 字段
if (extraConfigJson != null && extraConfigJson.contains("\"dimensions\"")) {
java.util.regex.Pattern p = java.util.regex.Pattern.compile("\"dimensions\"\\s*:\\s*(\\d+)");
java.util.regex.Matcher m = p.matcher(extraConfigJson);
if (m.find()) {
int dim = Integer.parseInt(m.group(1));
log.info("从 DB EMBEDDING extraConfig 读取维度: {}", dim);
return dim;
}
}
}
} catch (Exception e) {
log.warn("从 DB 读取 EMBEDDING 维度失败: {}", e.getMessage());
}
log.info("使用 application.yml fallback 维度: {}", fallbackDimension);
return fallbackDimension;
}
}

190
src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java

@ -0,0 +1,190 @@
package com.wok.supportbot.config;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel;
import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingOptions;
import com.wok.supportbot.entity.AiModelConfig;
import com.wok.supportbot.service.AiModelConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* EmbeddingModel 工厂
* DB 活跃配置EMBEDDING 类型动态创建/缓存 EmbeddingModel 实例支持运行时切换提供商
* - DashScope手动构造 DashScopeApi + DashScopeEmbeddingModel
* - DeepSeek / Kimi / 豆包 / 智谱 / OpenAI 通过 spring-ai-openai 模块 + 自定义 baseUrl 创建
*/
@Component
@Slf4j
public class EmbeddingModelFactory {
@Autowired
private AiModelConfigService configService;
/**
* EmbeddingModel 缓存key = "provider:apiKey:modelName"
*/
private final ConcurrentHashMap<String, EmbeddingModel> cache = new ConcurrentHashMap<>();
/**
* 各提供商默认的 API 基础地址
*/
private static final Map<String, String> DEFAULT_BASE_URLS = Map.of(
"deepseek", "https://api.deepseek.com",
"moonshot", "https://api.moonshot.cn/v1",
"volcengine", "https://ark.cn-beijing.volces.com/api/v3",
"zhipu", "https://open.bigmodel.cn/api/paas/v4",
"openai", "https://api.openai.com"
);
/**
* 各提供商的 Embedding API 路径相对于 baseUrl
* OpenAI 默认是 /v1/embeddings但许多国产厂商在 baseUrl 中已包含版本号
* /v1/api/v3/api/paas/v4因此只需 /embeddings 即可
* 豆包多模态模型需使用 /embeddings/multimodal OpenAI 请求格式不兼容另行处理
*/
private static final Map<String, String> EMBEDDINGS_PATHS = Map.of(
"deepseek", "/v1/embeddings",
"moonshot", "/embeddings", // baseUrl 已含 /v1
"volcengine", "/embeddings", // baseUrl 已含 /api/v3
"zhipu", "/embeddings", // baseUrl 已含 /api/paas/v4
"openai", "/v1/embeddings"
);
/**
* 获取当前活跃的 EmbeddingModel
* 查询 DB app_type=EMBEDDING 的活跃配置无配置时使用 DashScope text-embedding-v2 作为兜底
*
* @return EmbeddingModel 实例
*/
public EmbeddingModel getEmbeddingModel() {
AiModelConfig config = configService.getActiveConfigWithFullKey("EMBEDDING");
if (config == null || config.getApiKey() == null || config.getApiKey().isBlank()) {
log.warn("EMBEDDING 类型无活跃配置,请在管理页面配置向量化模型");
throw new IllegalStateException("EMBEDDING 类型无活跃配置,请在管理页面配置向量化模型");
}
log.debug("获取 EMBEDDING 活跃配置: provider={}, modelName={}, apiKey前4位={}",
config.getProvider(), config.getModelName(),
config.getApiKey().substring(0, Math.min(4, config.getApiKey().length())));
return getOrCreateEmbeddingModel(config);
}
/**
* 获取或创建 EmbeddingModel带缓存
* 缓存 key = provider:apiKey:modelName配置不变则复用实例
*/
private EmbeddingModel getOrCreateEmbeddingModel(AiModelConfig config) {
String cacheKey = config.getProvider() + ":" + config.getApiKey() + ":" + config.getModelName();
return cache.computeIfAbsent(cacheKey, k -> createEmbeddingModel(config));
}
/**
* 创建 EmbeddingModel 实例
* - dashscope手动构造 DashScopeApi + DashScopeEmbeddingModel
* - volcengine 豆包多模态模型抛出异常提示选用纯文本模型
* - 其他提供商通过 OpenAI 兼容 API 创建
*/
private EmbeddingModel createEmbeddingModel(AiModelConfig config) {
RetryTemplate retryTemplate = createRetryTemplate();
if ("dashscope".equalsIgnoreCase(config.getProvider())) {
log.info("创建 DashScope EmbeddingModel: model={}", config.getModelName());
DashScopeApi api = DashScopeApi.builder()
.apiKey(config.getApiKey())
.build();
DashScopeEmbeddingOptions options = DashScopeEmbeddingOptions.builder()
.withModel(config.getModelName())
.build();
return new DashScopeEmbeddingModel(api, MetadataMode.EMBED, options, retryTemplate);
}
// 豆包多模态模型检测doubao-embedding-vision* 使用 /embeddings/multimodal 端点
// 请求/响应格式与 OpenAI 不兼容无法通过 OpenAiEmbeddingModel 调用
// 提示用户改用纯文本模型
if ("volcengine".equalsIgnoreCase(config.getProvider())
&& config.getModelName() != null
&& config.getModelName().contains("vision")) {
throw new IllegalArgumentException(
"豆包多模态向量模型 (" + config.getModelName() + ") 不支持当前使用的 OpenAI 兼容 API 格式。"
+ "请将 EMBEDDING 模型名称改为纯文本模型,如 doubao-embedding-text-240515(2048维)或 doubao-embedding-large(4096维)。"
+ "注意:切换向量维度后需调整 PgVectorStoreConfig.dimensions 并重建 vector_store 表。");
}
// OpenAI 兼容提供商
String baseUrl = resolveBaseUrl(config);
String embeddingsPath = resolveEmbeddingsPath(config);
log.info("创建 OpenAI 兼容 EmbeddingModel: provider={}, baseUrl={}, embeddingsPath={}, model={}",
config.getProvider(), baseUrl, embeddingsPath, config.getModelName());
// 维度一致性提示
log.info("⚠ 请确认模型 [{}] 的向量维度与 PgVectorStoreConfig.dimensions(1536) 一致,否则需调整并重建向量表",
config.getModelName());
OpenAiApi api = OpenAiApi.builder()
.apiKey(config.getApiKey())
.baseUrl(baseUrl)
.embeddingsPath(embeddingsPath)
.build();
OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder()
.model(config.getModelName())
.build();
return new OpenAiEmbeddingModel(api, MetadataMode.EMBED, options, retryTemplate);
}
/**
* 解析 API 基础地址优先使用 DB 配置的 baseUrl否则使用提供商默认值
*/
private String resolveBaseUrl(AiModelConfig config) {
if (config.getBaseUrl() != null && !config.getBaseUrl().isBlank()) {
return config.getBaseUrl();
}
String defaultUrl = DEFAULT_BASE_URLS.get(config.getProvider());
if (defaultUrl != null) {
return defaultUrl;
}
throw new IllegalArgumentException(
"未知提供商 [" + config.getProvider() + "],请在配置中填写 API 基础地址 (baseUrl)");
}
/**
* 解析 Embedding API 路径优先使用提供商已知路径否则使用 OpenAI 默认 /v1/embeddings
*/
private String resolveEmbeddingsPath(AiModelConfig config) {
String known = EMBEDDINGS_PATHS.get(config.getProvider());
return known != null ? known : "/v1/embeddings";
}
/**
* 创建简单的重试模板最多 3 指数退避
*/
private RetryTemplate createRetryTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(1000);
backOffPolicy.setMultiplier(2);
backOffPolicy.setMaxInterval(10000);
retryTemplate.setBackOffPolicy(backOffPolicy);
return retryTemplate;
}
/**
* 清除 EmbeddingModel 缓存配置变更时调用
*/
public void clearCache() {
cache.clear();
log.info("EmbeddingModel 缓存已清除");
}
}

52
src/main/java/com/wok/supportbot/config/ModelConfigLoader.java

@ -11,7 +11,7 @@ import org.springframework.stereotype.Component;
/**
* 模型配置加载器
* 应用启动完成后从数据库读取活跃配置并与 application.yml 中的配置进行一致性校验
* 应用启动完成后校验数据库中各应用类型的活跃配置是否存在
*/
@Component
@Slf4j
@ -23,20 +23,14 @@ public class ModelConfigLoader implements ApplicationListener<ApplicationReadyEv
@Value("${spring.ai.dashscope.api-key:}")
private String dashscopeApiKey;
@Value("${spring.ai.dashscope.chat.options.model:qwen-turbo}")
private String chatModelName;
@Value("${spring.ai.dashscope.embedding.options.model:text-embedding-v2}")
private String embeddingModelName;
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
log.info("========== AI 模型配置校验 ==========");
try {
checkAppTypeConfig("CHAT", chatModelName);
checkAppTypeConfig("PRODUCT_EXTRACT", chatModelName);
checkAppTypeConfig("EMBEDDING", embeddingModelName);
checkAppTypeConfig("RAG_REWRITE", chatModelName);
checkAppTypeConfig("CHAT");
checkAppTypeConfig("PRODUCT_EXTRACT");
checkAppTypeConfig("EMBEDDING");
checkAppTypeConfig("RAG_REWRITE");
log.info("========== AI 模型配置校验完成 ==========");
} catch (Exception e) {
log.warn("模型配置校验异常(不影响启动): {}", e.getMessage());
@ -44,35 +38,27 @@ public class ModelConfigLoader implements ApplicationListener<ApplicationReadyEv
}
/**
* 校验指定应用类型的数据库配置与 application.yml 配置是否一致
*
* @param appType 应用类型
* @param ymlModelName yml 中配置的模型名称
* 校验指定应用类型的数据库活跃配置是否存在
* 仅比较 API Key 一致性 DashScope 提供商的配置
*/
private void checkAppTypeConfig(String appType, String ymlModelName) {
private void checkAppTypeConfig(String appType) {
AiModelConfig activeConfig = aiModelConfigService.getActiveConfigWithFullKey(appType);
if (activeConfig == null) {
log.warn(" [{}] 数据库中无活跃配置,将使用 application.yml 默认值", appType);
log.warn(" [{}] ⚠️ 数据库中无活跃配置,请在管理页面配置", appType);
return;
}
String dbModelName = activeConfig.getModelName();
String dbApiKey = activeConfig.getApiKey();
boolean modelMismatch = !ymlModelName.equals(dbModelName);
boolean apiKeyMismatch = !dashscopeApiKey.equals(dbApiKey);
if (modelMismatch || apiKeyMismatch) {
log.warn(" [{}] ⚠️ 数据库配置与 application.yml 不一致!", appType);
log.warn(" DB : modelName={}, apiKey={}****",
dbModelName,
AiModelConfigService.maskApiKey(dbApiKey));
log.warn(" YML : modelName={}, apiKey={}****",
ymlModelName,
AiModelConfigService.maskApiKey(dashscopeApiKey));
log.warn(" 提示:请更新 application.yml 并重启服务使配置生效");
// 仅比较 API Key如果 DB 配置的 provider dashscope对比 yml 中的 apiKey
if ("dashscope".equals(activeConfig.getProvider())
&& dashscopeApiKey != null && !dashscopeApiKey.isBlank()
&& !dashscopeApiKey.equals(activeConfig.getApiKey())) {
log.warn(" [{}] ⚠️ DB 中的 API Key 与 application.yml 不一致!", appType);
log.warn(" DB : apiKey={}****", AiModelConfigService.maskApiKey(activeConfig.getApiKey()));
log.warn(" YML : apiKey={}****", AiModelConfigService.maskApiKey(dashscopeApiKey));
log.warn(" 提示:建议在管理页面统一管理 API Key,或保持 yml 与 DB 一致");
} else {
log.info(" [{}] ✅ 活跃配置 [{}] provider={} (与 application.yml 一致)",
appType, dbModelName, activeConfig.getProvider());
log.info(" [{}] ✅ 活跃配置 [{}] provider={}",
appType, activeConfig.getModelName(), activeConfig.getProvider());
}
}
}

40
src/main/java/com/wok/supportbot/controller/AiController.java

@ -71,8 +71,8 @@ public class AiController {
* @return AI 回答
*/
@GetMapping("/assistant_app/chat/sync")
public String doChatWithAssistantAppSync(String message, String chatId, Long roleId, Long accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
public String doChatWithAssistantAppSync(String message, String chatId, Long roleId, String accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(toLong(accountId), roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChat(message, chatId, resolveSystemPrompt(scope, systemPrompt));
@ -87,8 +87,8 @@ public class AiController {
* @return
*/
@GetMapping(value = "/assistant_app/chat/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> doChatWithLoveAppSSE(String message, String chatId, Long roleId, Long accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
public Flux<String> doChatWithLoveAppSSE(String message, String chatId, Long roleId, String accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(toLong(accountId), roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt));
@ -103,8 +103,8 @@ public class AiController {
* @return
*/
@GetMapping(value = "/assistant_app/chat/server_sent_event")
public Flux<ServerSentEvent<String>> doChatWithAssistantAppServerSentEvent(String message, String chatId, Long roleId, Long accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
public Flux<ServerSentEvent<String>> doChatWithAssistantAppServerSentEvent(String message, String chatId, Long roleId, String accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(toLong(accountId), roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt))
@ -122,8 +122,8 @@ public class AiController {
* @return
*/
@GetMapping(value = "/assistant_app/chat/sse_emitter")
public SseEmitter doChatWithAssistantAppServerSseEmitter(String message, String chatId, Long roleId, Long accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
public SseEmitter doChatWithAssistantAppServerSseEmitter(String message, String chatId, Long roleId, String accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(toLong(accountId), roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
// 创建一个超时时间较长的 SseEmitter
@ -154,8 +154,8 @@ public class AiController {
* @return AI 回答
*/
@GetMapping("/assistant_app/chat/rag/sync")
public String doChatWithRagSync(String message, String chatId, String rewriteStrategy, Long roleId, Long accountId, Long categoryId, String categoryIds, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
public String doChatWithRagSync(String message, String chatId, String rewriteStrategy, Long roleId, String accountId, Long categoryId, String categoryIds, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(toLong(accountId), roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
String sys = resolveSystemPrompt(scope, systemPrompt);
@ -181,8 +181,8 @@ public class AiController {
* @return 流式 AI 回答
*/
@GetMapping(value = "/assistant_app/chat/rag/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> doChatWithRagSSE(String message, String chatId, String rewriteStrategy, Long roleId, Long accountId, Long categoryId, String categoryIds, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
public Flux<String> doChatWithRagSSE(String message, String chatId, String rewriteStrategy, Long roleId, String accountId, Long categoryId, String categoryIds, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(toLong(accountId), roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
String sys = resolveSystemPrompt(scope, systemPrompt);
@ -201,8 +201,8 @@ public class AiController {
*/
@GetMapping("/assistant_app/rag/sources")
public Map<String, Object> getRagSources(String message, String chatId, String rewriteStrategy,
Long roleId, Long accountId, Long categoryId, String categoryIds) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
Long roleId, String accountId, Long categoryId, String categoryIds) {
AccountRoleContext context = resolveAccountRole(toLong(accountId), roleId);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
if (message == null || message.isBlank() || isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) {
return Map.of("success", true, "data", List.of());
@ -315,4 +315,16 @@ public class AiController {
private record AccountRoleContext(Long accountId, Long roleId) {
}
/** 将字符串 accountId 安全转为 Long,非数字字符串返回 null */
private static Long toLong(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
}

5
src/main/java/com/wok/supportbot/controller/AiModelConfigController.java

@ -2,6 +2,7 @@ package com.wok.supportbot.controller;
import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.config.EmbeddingModelFactory;
import com.wok.supportbot.entity.AiModelConfig;
import com.wok.supportbot.service.AiModelConfigService;
import org.springframework.beans.factory.annotation.Autowired;
@ -23,6 +24,9 @@ public class AiModelConfigController {
@Autowired
private ChatModelFactory chatModelFactory;
@Autowired
private EmbeddingModelFactory embeddingModelFactory;
@Autowired
private AssistantApp assistantApp;
@ -273,6 +277,7 @@ public class AiModelConfigController {
*/
private void refreshCache() {
chatModelFactory.clearCache();
embeddingModelFactory.clearCache();
assistantApp.clearCache();
}
}

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

@ -34,10 +34,10 @@ public class ConversationController {
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long accountId,
@RequestParam(required = false) String accountId,
@RequestParam(required = false) Long roleId) {
try {
Map<String, Object> result = conversationService.listConversations(page, size, keyword, accountId, roleId);
Map<String, Object> result = conversationService.listConversations(page, size, keyword, toLong(accountId), roleId);
Map<String, Object> data = new java.util.HashMap<>();
data.put("success", true);
data.put("data", result.get("records"));
@ -201,4 +201,16 @@ public class ConversationController {
));
}
}
/** 将字符串 accountId 安全转为 Long,非数字字符串返回 null */
private static Long toLong(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
}

14
src/main/java/com/wok/supportbot/rag/load/InMemoryVectorStoreConfig.java

@ -1,20 +1,26 @@
package com.wok.supportbot.rag.load;
import org.springframework.ai.embedding.EmbeddingModel;
import com.wok.supportbot.config.DynamicEmbeddingModel;
import com.wok.supportbot.config.EmbeddingModelFactory;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 向量数据库配置初始化基于内存的向量数据库 Bean
* 使用 DynamicEmbeddingModel 代理支持运行时切换向量化模型无需重启
*/
@Configuration
public class InMemoryVectorStoreConfig {
@Autowired
private EmbeddingModelFactory embeddingModelFactory;
@Bean
VectorStore inMemoryVectorStore(EmbeddingModel dashscopeEmbeddingModel) {
SimpleVectorStore simpleVectorStore = SimpleVectorStore.builder(dashscopeEmbeddingModel).build();;
return simpleVectorStore;
VectorStore inMemoryVectorStore() {
DynamicEmbeddingModel dynamicEmbeddingModel = new DynamicEmbeddingModel(embeddingModelFactory);
return SimpleVectorStore.builder(dynamicEmbeddingModel).build();
}
}

52
src/main/java/com/wok/supportbot/rag/load/PgVectorStoreConfig.java

@ -1,8 +1,13 @@
package com.wok.supportbot.rag.load;
import org.springframework.ai.embedding.EmbeddingModel;
import com.wok.supportbot.config.DynamicEmbeddingModel;
import com.wok.supportbot.config.EmbeddingModelFactory;
import com.wok.supportbot.entity.AiModelConfig;
import com.wok.supportbot.service.AiModelConfigService;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.pgvector.PgVectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@ -12,15 +17,28 @@ import static org.springframework.ai.vectorstore.pgvector.PgVectorStore.PgDistan
import static org.springframework.ai.vectorstore.pgvector.PgVectorStore.PgIndexType.HNSW;
/**
* 向量数据库配置初始化基于pgsql的向量数据库 Bean
* 使用 DynamicEmbeddingModel 代理支持运行时切换向量化模型无需重启
* 向量维度优先级DB ai_model_config.extra_config.dimensions用户前端配置 > application.yml knowledge.vector.dimension默认 1536
*/
@Configuration
public class PgVectorStoreConfig {
@Autowired
private EmbeddingModelFactory embeddingModelFactory;
@Autowired
private AiModelConfigService configService;
@Value("${knowledge.vector.dimension:1536}")
private int defaultVectorDimension;
@Bean
@Primary // 默认使用pgsql储存向量
public VectorStore pgVectorVectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel dashscopeEmbeddingModel) {
return PgVectorStore.builder(jdbcTemplate, dashscopeEmbeddingModel)
.dimensions(1536) // Optional: defaults to model dimensions or 1536
public VectorStore pgVectorVectorStore(JdbcTemplate jdbcTemplate) {
int vectorDimension = resolveVectorDimension();
DynamicEmbeddingModel dynamicEmbeddingModel = new DynamicEmbeddingModel(embeddingModelFactory);
return PgVectorStore.builder(jdbcTemplate, dynamicEmbeddingModel)
.dimensions(vectorDimension) // DB extraConfig yml 读取
.distanceType(COSINE_DISTANCE) // Optional: defaults to COSINE_DISTANCE
.indexType(HNSW) // Optional: defaults to HNSW
.initializeSchema(true) // Optional: defaults to false
@ -29,4 +47,30 @@ public class PgVectorStoreConfig {
.maxDocumentBatchSize(10000) // Optional: defaults to 10000
.build();
}
/**
* 解析向量维度优先从 DB EMBEDDING 活跃配置的 extraConfig.dimensions 读取
* 无配置时回退到 application.yml knowledge.vector.dimension
*/
private int resolveVectorDimension() {
try {
AiModelConfig config = configService.getActiveConfigWithFullKey("EMBEDDING");
if (config != null && config.getExtraConfig() != null) {
Object dimObj = config.getExtraConfig().get("dimensions");
if (dimObj instanceof Number) {
int dim = ((Number) dimObj).intValue();
if (dim > 0) {
log.info("从 DB EMBEDDING 配置 ({}) extraConfig 读取向量维度: {}", config.getModelName(), dim);
return dim;
}
}
}
} catch (Exception e) {
log.warn("读取 DB EMBEDDING 配置维度失败,使用 yml 默认值: {}", e.getMessage());
}
log.info("使用 yml 默认向量维度: {}", defaultVectorDimension);
return defaultVectorDimension;
}
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(PgVectorStoreConfig.class);
}

13
src/main/resources/application.yml

@ -9,17 +9,11 @@ server:
# ==================== Spring AI Alibaba DashScope 配置 ====================
# 注意: 需要替换为你的阿里云 DashScope API Key
# 获取地址: https://dashscope.console.aliyun.com/
# 模型名称、温度等参数已迁移到前端「AI 大模型配置管理」页面,在 ai_model_config 表中管理
spring:
ai:
dashscope:
api-key: sk-ws-H.RPMIMYH.G2gK.MEQCIFQ2aUocl1x5Q8sod1UgcBy0DzC5aJda5J-14thyXERBAiBYwX1k-7lWbEWYPnnDmJ9UV11uxLa13czU4hMMWJzi3A
chat:
options:
model: qwen-turbo
temperature: 0.7
embedding:
options:
model: text-embedding-v2
# ==================== 数据源配置(PostgreSQL + PGVector) ====================
datasource:
@ -72,6 +66,11 @@ knowledge:
min-chunk-size-chars: 10
max-num-chunks: 5000
keep-separator: true
vector:
# 向量维度,需与 Embedding 模型输出维度一致。
# 千问 text-embedding-v2: 1536 | 豆包 doubao-embedding-text-240515: 2048 | OpenAI text-embedding-3-small: 1536
# 修改此值后需重建 vector_store 表(PG 不支持直接修改 vector 列维度)
dimension: 1536
role:
# 严格隔离:true=角色未绑定知识库分类时禁止检索任何内容;false(默认)=可检索全部知识库
strict-isolation: false

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

@ -224,6 +224,16 @@ export default {
<input type="text" class="input" v-model="editModal.form.base_url" :placeholder="providerBaseUrlPlaceholder">
</div>
<!-- 向量维度 EMBEDDING 类型显示 -->
<div v-if="editModal.form.app_type === 'EMBEDDING'" style="padding:10px 14px;background:#fefce8;border:1px solid #fde68a;border-radius:8px;">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:6px;">📐 向量维度 <span style="color:#dc2626;">*</span></label>
<input type="number" class="input" v-model.number="editModal.form.embeddingDimensions" min="1" max="8192" placeholder="1536" style="max-width:200px;">
<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>
常用维度千问 text-embedding-v2=1536 | 豆包 doubao-embedding-text=2048 | OpenAI text-embedding-3-small=1536
</div>
</div>
<!-- 优先级 -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">优先级</label>
@ -284,6 +294,7 @@ export default {
temperature: 0.7,
max_tokens: 2000,
base_url: '',
embeddingDimensions: 1536,
priority: 0,
is_active: false,
description: ''
@ -350,6 +361,13 @@ export default {
}
function openEditModal(config) {
// 从 extraConfig 中读取 embeddingDimensions
let extraConfig = {}
if (config.extraConfig) {
try {
extraConfig = typeof config.extraConfig === 'string' ? JSON.parse(config.extraConfig) : config.extraConfig
} catch (e) {}
}
editModal.value = {
visible: true,
mode: 'edit',
@ -363,6 +381,7 @@ export default {
temperature: config.temperature,
max_tokens: config.max_tokens,
base_url: config.base_url || '',
embeddingDimensions: extraConfig.dimensions || 1536,
priority: config.priority || 0,
is_active: config.is_active || false,
description: config.description || ''
@ -378,6 +397,33 @@ export default {
// ==================== 保存配置 ====================
/**
* 将前端表单字段名snake_case转换为后端 Java 实体字段名camelCase
* 因为 Jackson 默认使用 camelCase 反序列化前端发送 snake_case 会导致字段无法映射
*/
function toCamelCase(form) {
const data = {
name: form.name,
appType: form.app_type,
provider: form.provider,
apiKey: form.api_key,
modelName: form.model_name,
temperature: form.temperature,
maxTokens: form.max_tokens,
baseUrl: form.base_url,
priority: form.priority,
isActive: form.is_active,
description: form.description
}
// EMBEDDING 类型:将向量维度写入 extraConfig
if (form.app_type === 'EMBEDDING') {
data.extraConfig = {
dimensions: form.embeddingDimensions || 1536
}
}
return data
}
async function saveConfig() {
const form = editModal.value.form
@ -409,14 +455,16 @@ export default {
try {
let json
// 转换为 camelCase 后再发送,确保 Jackson 正确反序列化
const camelData = toCamelCase(form)
if (editModal.value.mode === 'add') {
json = await api.createModelConfig(form)
json = await api.createModelConfig(camelData)
} else {
const updateData = { ...form }
if (!updateData.api_key || !updateData.api_key.trim()) {
delete updateData.api_key
// 编辑模式:API Key 留空则不发送(不覆盖原值)
if (!camelData.apiKey || !camelData.apiKey.trim()) {
delete camelData.apiKey
}
json = await api.updateModelConfig(editModal.value.editId, updateData)
json = await api.updateModelConfig(editModal.value.editId, camelData)
}
if (json.success) {

44
src/main/resources/static/sdk/chatbot-sdk.js

@ -98,15 +98,9 @@ var ChatbotSDK = (function () {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig.integrateId);
if (currentConfig.userId) {
params.set('accountId', currentConfig.userId);
}
if (currentConfig.roleId) {
params.set('roleId', String(currentConfig.roleId));
}
if (currentConfig.categoryId) {
params.set('categoryId', String(currentConfig.categoryId));
}
setIfPresent(params, 'accountId', currentConfig.userId);
setIfPresent(params, 'roleId', currentConfig.roleId);
setIfPresent(params, 'categoryId', currentConfig.categoryId);
return buildUrl(`/ai/assistant_app/chat/sync?${params.toString()}`);
}
/** 构建 SSE 流式请求 URL */
@ -114,17 +108,21 @@ var ChatbotSDK = (function () {
const params = new URLSearchParams();
params.set('message', message);
params.set('chatId', currentConfig.integrateId);
if (currentConfig.userId) {
params.set('accountId', currentConfig.userId);
}
if (currentConfig.roleId) {
params.set('roleId', String(currentConfig.roleId));
}
if (currentConfig.categoryId) {
params.set('categoryId', String(currentConfig.categoryId));
}
setIfPresent(params, 'accountId', currentConfig.userId);
setIfPresent(params, 'roleId', currentConfig.roleId);
setIfPresent(params, 'categoryId', currentConfig.categoryId);
return buildUrl(`/ai/assistant_app/chat/sse?${params.toString()}`);
}
/**
* 安全设置可选参数仅当 value 非空时追加数字类型直接转字符串
*/
function setIfPresent(params, key, value) {
if (value === undefined || value === null)
return;
if (typeof value === 'string' && value.trim() === '')
return;
params.set(key, String(value));
}
/** 带超时的 fetch 封装 */
async function safeFetch(url, options = {}, timeout = REQUEST_TIMEOUT) {
const controller = new AbortController();
@ -1084,18 +1082,18 @@ var ChatbotSDK = (function () {
let aiContent;
const aiTimestamp = now();
if (config$1.streaming) {
// 流式输出
// 流式输出:气泡由 sendStreamMessage 内部的 onChunk 回调创建
aiContent = await sendStreamMessage(text, aiTimestamp);
}
else {
// 同步请求
// 同步请求:需要在此渲染 AI 气泡
aiContent = await chatRequest(text);
}
// 4. 隐藏 loading
// 4. 隐藏 loading(流式模式在 onChunk 中已隐藏,此处做兜底)
if (hideLoadingFn$1)
hideLoadingFn$1();
// 5. 渲染 AI 气泡
if (messagesContainer$1) {
// 5. 非流式模式:在此渲染 AI 气泡;流式模式气泡已在 stream 回调中创建
if (!config$1.streaming && messagesContainer$1) {
renderAIBubble(messagesContainer$1, aiContent, aiTimestamp);
}
const aiMsg = {

2
src/main/resources/static/sdk/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File

1012
src/main/resources/static/sdk/test.html
File diff suppressed because it is too large
View File

Loading…
Cancel
Save