mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(llm): UI-configurable embedding models with per-KB binding and dynamic factory
This commit is contained in:
parent
04ad281ffd
commit
25978ceb12
@ -6,10 +6,18 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.llm.model.*;
|
||||
import org.springframework.ai.embedding.EmbeddingModel;
|
||||
import org.springframework.ai.embedding.EmbeddingRequest;
|
||||
import org.springframework.ai.embedding.EmbeddingResponse;
|
||||
import vip.mate.llm.embedding.EmbeddingModelFactory;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.llm.service.ModelDiscoveryService;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
import vip.mate.system.model.SystemSettingEntity;
|
||||
import vip.mate.system.repository.SystemSettingMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -22,6 +30,10 @@ public class ModelConfigController {
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelProviderService modelProviderService;
|
||||
private final ModelDiscoveryService modelDiscoveryService;
|
||||
private final EmbeddingModelFactory embeddingModelFactory;
|
||||
private final SystemSettingMapper systemSettingMapper;
|
||||
|
||||
private static final String SYSTEM_SETTING_DEFAULT_EMBEDDING_ID = "embedding.default.model.id";
|
||||
|
||||
@Operation(summary = "获取 Provider 列表")
|
||||
@GetMapping
|
||||
@ -63,7 +75,10 @@ public class ModelConfigController {
|
||||
@PutMapping("/{providerId}/config")
|
||||
public R<ProviderInfoDTO> updateProviderConfig(@PathVariable String providerId,
|
||||
@RequestBody ProviderConfigRequest request) {
|
||||
return R.ok(modelProviderService.updateProviderConfig(providerId, request));
|
||||
ProviderInfoDTO updated = modelProviderService.updateProviderConfig(providerId, request);
|
||||
// Provider 的 apiKey/baseUrl 变化时,清空 embedding factory 的缓存,确保下次用新凭证
|
||||
embeddingModelFactory.evictAll();
|
||||
return R.ok(updated);
|
||||
}
|
||||
|
||||
@Operation(summary = "创建自定义 Provider")
|
||||
@ -153,4 +168,75 @@ public class ModelConfigController {
|
||||
@PathVariable String modelId) {
|
||||
return R.ok(modelDiscoveryService.testModel(providerId, modelId));
|
||||
}
|
||||
|
||||
// ==================== Embedding 模型管理 ====================
|
||||
|
||||
@Operation(summary = "按类型筛选模型(chat / embedding)")
|
||||
@GetMapping("/by-type")
|
||||
public R<List<ModelConfigEntity>> listByType(@RequestParam(defaultValue = "chat") String modelType) {
|
||||
return R.ok(modelConfigService.listByType(modelType));
|
||||
}
|
||||
|
||||
@Operation(summary = "测试 Embedding 模型连通性(嵌入一个短文本验证 API key)")
|
||||
@PostMapping("/embedding/{modelId}/test")
|
||||
public R<Map<String, Object>> testEmbedding(@PathVariable Long modelId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
try {
|
||||
ModelConfigEntity config = modelConfigService.getModel(modelId);
|
||||
if (!"embedding".equals(config.getModelType())) {
|
||||
result.put("success", false);
|
||||
result.put("message", "模型类型不是 embedding: " + config.getModelType());
|
||||
return R.ok(result);
|
||||
}
|
||||
// 清除缓存,确保本次测试用最新的 API key
|
||||
embeddingModelFactory.evict(modelId);
|
||||
EmbeddingModel model = embeddingModelFactory.build(config);
|
||||
EmbeddingResponse resp = model.call(new EmbeddingRequest(List.of("test"), null));
|
||||
float[] vec = resp.getResults().get(0).getOutput();
|
||||
result.put("success", true);
|
||||
result.put("dimensions", vec.length);
|
||||
result.put("model", config.getModelName());
|
||||
result.put("message", "连通性测试成功");
|
||||
} catch (Exception e) {
|
||||
result.put("success", false);
|
||||
result.put("message", e.getMessage());
|
||||
}
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取系统默认 Embedding 模型 ID")
|
||||
@GetMapping("/embedding/default")
|
||||
public R<Map<String, Object>> getDefaultEmbedding() {
|
||||
SystemSettingEntity entity = systemSettingMapper.selectOne(
|
||||
new LambdaQueryWrapper<SystemSettingEntity>()
|
||||
.eq(SystemSettingEntity::getSettingKey, SYSTEM_SETTING_DEFAULT_EMBEDDING_ID)
|
||||
.last("LIMIT 1"));
|
||||
if (entity == null || entity.getSettingValue() == null || entity.getSettingValue().isBlank()) {
|
||||
return R.ok(Map.of("defaultModelId", ""));
|
||||
}
|
||||
return R.ok(Map.of("defaultModelId", entity.getSettingValue()));
|
||||
}
|
||||
|
||||
@Operation(summary = "设置系统默认 Embedding 模型")
|
||||
@PostMapping("/embedding/default")
|
||||
public R<Void> setDefaultEmbedding(@RequestBody Map<String, Object> body) {
|
||||
Object v = body.get("modelId");
|
||||
String value = v == null ? "" : v.toString();
|
||||
|
||||
SystemSettingEntity existing = systemSettingMapper.selectOne(
|
||||
new LambdaQueryWrapper<SystemSettingEntity>()
|
||||
.eq(SystemSettingEntity::getSettingKey, SYSTEM_SETTING_DEFAULT_EMBEDDING_ID)
|
||||
.last("LIMIT 1"));
|
||||
if (existing != null) {
|
||||
existing.setSettingValue(value);
|
||||
systemSettingMapper.updateById(existing);
|
||||
} else {
|
||||
SystemSettingEntity fresh = new SystemSettingEntity();
|
||||
fresh.setSettingKey(SYSTEM_SETTING_DEFAULT_EMBEDDING_ID);
|
||||
fresh.setSettingValue(value);
|
||||
fresh.setDescription("Default embedding model id for wiki semantic search");
|
||||
systemSettingMapper.insert(fresh);
|
||||
}
|
||||
return R.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,184 @@
|
||||
package vip.mate.llm.embedding;
|
||||
|
||||
import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
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.ai.retry.RetryUtils;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.model.EmbeddingProtocol;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
import vip.mate.llm.service.ModelProviderService;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Embedding 模型工厂
|
||||
* <p>
|
||||
* 根据 {@link ModelConfigEntity}(model_type='embedding')构造对应的 {@link EmbeddingModel}:
|
||||
* <ul>
|
||||
* <li>DashScope:复用 provider UI 配置的 apiKey/baseUrl,构造 {@link DashScopeEmbeddingModel}</li>
|
||||
* <li>OpenAI 兼容(OpenAI / DeepSeek / 智谱 / Kimi / Moonshot / Ollama 本地等):构造 {@link OpenAiEmbeddingModel}</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 设计要点:
|
||||
* <ol>
|
||||
* <li><b>Provider api_key 共用</b>:与 chat 模型共用 {@code mate_model_provider.api_key},避免重复配置</li>
|
||||
* <li><b>缓存</b>:按 ModelConfigEntity.id 缓存 EmbeddingModel 实例,避免每次查询都重建</li>
|
||||
* <li><b>回落</b>:provider apiKey 未配时,DashScope 走 yml 的 {@code spring.ai.dashscope.api-key}
|
||||
* (兼容老用户),其他 provider 直接报错让用户去 UI 配置</li>
|
||||
* <li><b>独立实现</b>:不引用 {@code AgentGraphBuilder} 的 chat-specific 重写(如 reasoning content patch),
|
||||
* embedding 的请求格式稳定,不需要这些补丁</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EmbeddingModelFactory {
|
||||
|
||||
private final ModelProviderService providerService;
|
||||
private final DashScopeConnectionProperties dashScopeConnectionProperties;
|
||||
|
||||
/** 构造 API 时共享的 retry template(用 Spring AI 默认) */
|
||||
private static final RetryTemplate DEFAULT_RETRY = RetryUtils.DEFAULT_RETRY_TEMPLATE;
|
||||
|
||||
/** 按 modelConfig.id 缓存 EmbeddingModel,config 变更时调用 {@link #evict} 清除 */
|
||||
private final ConcurrentHashMap<Long, EmbeddingModel> cache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 构造或复用指定配置对应的 EmbeddingModel
|
||||
*
|
||||
* @throws MateClawException 当 provider 未配置或协议不支持时
|
||||
*/
|
||||
public EmbeddingModel build(ModelConfigEntity modelConfig) {
|
||||
if (modelConfig == null) {
|
||||
throw new MateClawException("err.embedding.config_null", "Embedding model config is null");
|
||||
}
|
||||
if (modelConfig.getId() != null) {
|
||||
EmbeddingModel cached = cache.get(modelConfig.getId());
|
||||
if (cached != null) return cached;
|
||||
}
|
||||
|
||||
EmbeddingModel fresh = doBuild(modelConfig);
|
||||
if (modelConfig.getId() != null) {
|
||||
cache.put(modelConfig.getId(), fresh);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定配置的缓存实例(provider api_key 变更 / 模型切换时调用)
|
||||
*/
|
||||
public void evict(Long modelConfigId) {
|
||||
if (modelConfigId != null) {
|
||||
cache.remove(modelConfigId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空所有缓存(provider 表刷新后调用) */
|
||||
public void evictAll() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// ==================== 内部实现 ====================
|
||||
|
||||
private EmbeddingModel doBuild(ModelConfigEntity modelConfig) {
|
||||
ModelProviderEntity provider = providerService.getProviderConfig(modelConfig.getProvider());
|
||||
if (provider == null) {
|
||||
throw new MateClawException("err.embedding.provider_missing",
|
||||
"Embedding provider '" + modelConfig.getProvider() + "' not found in mate_model_provider");
|
||||
}
|
||||
|
||||
EmbeddingProtocol protocol = EmbeddingProtocol.fromProviderId(provider.getProviderId());
|
||||
log.info("[EmbeddingFactory] Building embedding model: provider={}, model={}, protocol={}",
|
||||
provider.getProviderId(), modelConfig.getModelName(), protocol);
|
||||
|
||||
return switch (protocol) {
|
||||
case DASHSCOPE_EMBEDDING -> buildDashScope(provider, modelConfig);
|
||||
case OPENAI_EMBEDDING -> buildOpenAi(provider, modelConfig);
|
||||
};
|
||||
}
|
||||
|
||||
private EmbeddingModel buildDashScope(ModelProviderEntity provider, ModelConfigEntity modelConfig) {
|
||||
// API Key 回落链:provider UI → yml
|
||||
String apiKey = provider.getApiKey();
|
||||
if (!StringUtils.hasText(apiKey) || !providerService.hasUsableApiKey(apiKey)) {
|
||||
apiKey = dashScopeConnectionProperties.getApiKey();
|
||||
}
|
||||
if (!providerService.hasUsableApiKey(apiKey)) {
|
||||
throw new MateClawException("err.embedding.dashscope_key_missing",
|
||||
"DashScope API Key 未配置。请在模型设置中填写 dashscope provider 的 API Key。");
|
||||
}
|
||||
|
||||
DashScopeApi.Builder apiBuilder = DashScopeApi.builder().apiKey(apiKey.trim());
|
||||
|
||||
// Base URL 可选
|
||||
String baseUrl = provider.getBaseUrl();
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
baseUrl = dashScopeConnectionProperties.getBaseUrl();
|
||||
}
|
||||
if (StringUtils.hasText(baseUrl)) {
|
||||
apiBuilder.baseUrl(baseUrl.trim());
|
||||
}
|
||||
|
||||
DashScopeApi api = apiBuilder.build();
|
||||
|
||||
DashScopeEmbeddingOptions options = DashScopeEmbeddingOptions.builder()
|
||||
.withModel(modelConfig.getModelName())
|
||||
.build();
|
||||
|
||||
return new DashScopeEmbeddingModel(api, MetadataMode.EMBED, options, DEFAULT_RETRY);
|
||||
}
|
||||
|
||||
private EmbeddingModel buildOpenAi(ModelProviderEntity provider, ModelConfigEntity modelConfig) {
|
||||
if (!providerService.isProviderConfigured(provider.getProviderId())) {
|
||||
throw new MateClawException("err.embedding.provider_not_configured",
|
||||
"Provider '" + provider.getProviderId() + "' 未完成配置(缺少 API Key 或 Base URL)");
|
||||
}
|
||||
String apiKey = provider.getApiKey();
|
||||
if (!providerService.hasUsableApiKey(apiKey)) {
|
||||
throw new MateClawException("err.embedding.openai_key_invalid",
|
||||
"Provider API Key 未配置或无效: " + provider.getProviderId());
|
||||
}
|
||||
String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl());
|
||||
if (!StringUtils.hasText(baseUrl)) {
|
||||
throw new MateClawException("err.embedding.openai_baseurl_missing",
|
||||
"Provider Base URL 未配置: " + provider.getProviderId());
|
||||
}
|
||||
|
||||
// 最简构造:不做 chat-specific 的 header 重写、reasoning patch 等
|
||||
OpenAiApi api = OpenAiApi.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.apiKey(apiKey.trim())
|
||||
.embeddingsPath("/v1/embeddings")
|
||||
.build();
|
||||
|
||||
OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder()
|
||||
.model(modelConfig.getModelName())
|
||||
.build();
|
||||
|
||||
return new OpenAiEmbeddingModel(api, MetadataMode.EMBED, options, DEFAULT_RETRY);
|
||||
}
|
||||
|
||||
/** 归一化 OpenAI base URL:去掉末尾的 /v1 / 斜杠(避免双 /v1) */
|
||||
private String normalizeOpenAiBaseUrl(String baseUrl) {
|
||||
if (!StringUtils.hasText(baseUrl)) return null;
|
||||
String u = baseUrl.trim();
|
||||
while (u.endsWith("/")) u = u.substring(0, u.length() - 1);
|
||||
if (u.endsWith("/v1")) u = u.substring(0, u.length() - 3);
|
||||
return u;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package vip.mate.llm.model;
|
||||
|
||||
/**
|
||||
* Embedding 模型协议。
|
||||
* <p>
|
||||
* 与 {@link ModelProtocol}(Chat 协议)分离——chat 和 embedding 在同一个 Provider 下
|
||||
* 可能走不同的请求格式:
|
||||
* <ul>
|
||||
* <li>DashScope 的 embedding endpoint 是专用 path(/api/v1/services/embeddings/text-embedding/text-embedding)</li>
|
||||
* <li>OpenAI 兼容协议的 embedding 统一走 /v1/embeddings</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 通过 {@link #fromProviderId} 从 providerId 推断协议,新增 provider 时只需扩展这里。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
public enum EmbeddingProtocol {
|
||||
|
||||
DASHSCOPE_EMBEDDING("dashscope-embedding"),
|
||||
OPENAI_EMBEDDING("openai-embedding");
|
||||
|
||||
private final String id;
|
||||
|
||||
EmbeddingProtocol(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 providerId 推断 embedding 协议。
|
||||
* <ul>
|
||||
* <li>dashscope / 任何包含 "dashscope" / "qwen" / "aliyun" 的 → DASHSCOPE_EMBEDDING</li>
|
||||
* <li>其他(openai / deepseek / kimi / zhipu / moonshot / 任何 OpenAI 兼容) → OPENAI_EMBEDDING</li>
|
||||
* </ul>
|
||||
*/
|
||||
public static EmbeddingProtocol fromProviderId(String providerId) {
|
||||
if (providerId == null) return OPENAI_EMBEDDING;
|
||||
String p = providerId.toLowerCase().trim();
|
||||
if (p.contains("dashscope") || p.contains("qwen") || p.contains("aliyun")) {
|
||||
return DASHSCOPE_EMBEDDING;
|
||||
}
|
||||
return OPENAI_EMBEDDING;
|
||||
}
|
||||
}
|
||||
@ -47,6 +47,14 @@ public class ModelConfigEntity {
|
||||
|
||||
private Boolean isDefault;
|
||||
|
||||
/**
|
||||
* 模型类型:chat(默认,LLM 对话) / embedding(文本向量化)
|
||||
* <p>
|
||||
* 参考 Dify 的 ModelType 抽象:允许同一 Provider 下同时管理 chat 和 embedding 两类模型,
|
||||
* API Key 共用(存于 mate_model_provider)。
|
||||
*/
|
||||
private String modelType;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -33,6 +33,9 @@ public class ModelConfigService {
|
||||
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.eq(ModelConfigEntity::getEnabled, true)
|
||||
.eq(ModelConfigEntity::getProvider, "dashscope")
|
||||
// 仅 chat 类型(排除 embedding),NULL 兼容老数据
|
||||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||||
.orderByAsc(ModelConfigEntity::getName));
|
||||
}
|
||||
@ -44,6 +47,41 @@ public class ModelConfigService {
|
||||
.orderByAsc(ModelConfigEntity::getName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按模型类型筛选(RFC: embedding UI 配置)。
|
||||
* <p>
|
||||
* modelType 参数:
|
||||
* <ul>
|
||||
* <li>{@code "chat"} — 对话模型(默认,包括老数据 modelType IS NULL)</li>
|
||||
* <li>{@code "embedding"} — 文本向量化模型</li>
|
||||
* </ul>
|
||||
*/
|
||||
public List<ModelConfigEntity> listByType(String modelType) {
|
||||
if ("chat".equals(modelType)) {
|
||||
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||||
.orderByAsc(ModelConfigEntity::getName));
|
||||
}
|
||||
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.eq(ModelConfigEntity::getModelType, modelType)
|
||||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||||
.orderByAsc(ModelConfigEntity::getName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找第一个 enabled 的 embedding 模型(WikiEmbeddingService 的 fallback 路径)
|
||||
*/
|
||||
public ModelConfigEntity findFirstEnabledEmbedding() {
|
||||
return modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.eq(ModelConfigEntity::getModelType, "embedding")
|
||||
.eq(ModelConfigEntity::getEnabled, true)
|
||||
.orderByDesc(ModelConfigEntity::getIsDefault)
|
||||
.orderByAsc(ModelConfigEntity::getName)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public ModelConfigEntity getModel(Long id) {
|
||||
ModelConfigEntity entity = modelConfigMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
@ -53,14 +91,19 @@ public class ModelConfigService {
|
||||
}
|
||||
|
||||
public ModelConfigEntity getDefaultModel() {
|
||||
// 默认 chat 模型:明确排除 embedding 类型
|
||||
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.eq(ModelConfigEntity::getIsDefault, true)
|
||||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||||
.last("LIMIT 1"));
|
||||
if (entity != null) {
|
||||
return entity;
|
||||
}
|
||||
entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||
.eq(ModelConfigEntity::getEnabled, true)
|
||||
.and(w -> w.isNull(ModelConfigEntity::getModelType)
|
||||
.or().eq(ModelConfigEntity::getModelType, "chat"))
|
||||
.orderByAsc(ModelConfigEntity::getName)
|
||||
.last("LIMIT 1"));
|
||||
if (entity == null) {
|
||||
|
||||
@ -111,7 +111,17 @@ public class WikiController {
|
||||
String name = (String) body.get("name");
|
||||
String description = (String) body.get("description");
|
||||
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null;
|
||||
return R.ok(kbService.update(id, name, description, agentId));
|
||||
kbService.update(id, name, description, agentId);
|
||||
// RFC Embedding UI: 允许通过此接口绑定 / 解绑 embedding 模型
|
||||
if (body.containsKey("embeddingModelId")) {
|
||||
Object v = body.get("embeddingModelId");
|
||||
Long embeddingModelId = null;
|
||||
if (v != null && !v.toString().isBlank()) {
|
||||
embeddingModelId = Long.valueOf(v.toString());
|
||||
}
|
||||
kbService.updateEmbeddingModelId(id, embeddingModelId);
|
||||
}
|
||||
return R.ok(kbService.getById(id));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
|
||||
@ -45,6 +45,14 @@ public class WikiKnowledgeBaseEntity {
|
||||
/** 所属工作区 ID(默认 1 = default) */
|
||||
private Long workspaceId;
|
||||
|
||||
/**
|
||||
* 绑定的 Embedding 模型 ID(mate_model_config.id,model_type='embedding')。
|
||||
* <p>
|
||||
* NULL = 使用系统默认(mate_system_setting 的 embedding.default.model.id),
|
||||
* 再无则取任意 enabled 的 embedding 模型,最终全无则语义搜索降级为不可用。
|
||||
*/
|
||||
private Long embeddingModelId;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -94,7 +94,7 @@ public class HybridRetriever {
|
||||
public List<ChunkHit> searchChunks(Long kbId, String query, int topK) {
|
||||
if (!embeddingService.isAvailable()) return List.of();
|
||||
|
||||
float[] queryVec = embeddingService.embedQuery(query);
|
||||
float[] queryVec = embeddingService.embedQuery(kbId, query);
|
||||
if (queryVec == null) return List.of();
|
||||
|
||||
List<WikiChunkEntity> allChunks = chunkService.listByKbId(kbId);
|
||||
@ -118,7 +118,7 @@ public class HybridRetriever {
|
||||
|
||||
/** 语义搜索:chunk cosine → 聚合到 page(同页多 chunk 取最高分) */
|
||||
private List<RankedItem> semanticSearch(Long kbId, String query, int limit) {
|
||||
float[] queryVec = embeddingService.embedQuery(query);
|
||||
float[] queryVec = embeddingService.embedQuery(kbId, query);
|
||||
if (queryVec == null) return List.of();
|
||||
|
||||
List<WikiChunkEntity> allChunks = chunkService.listByKbId(kbId);
|
||||
|
||||
@ -2,14 +2,20 @@ package vip.mate.wiki.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.embedding.EmbeddingModel;
|
||||
import org.springframework.ai.embedding.EmbeddingRequest;
|
||||
import org.springframework.ai.embedding.EmbeddingResponse;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.llm.embedding.EmbeddingModelFactory;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.system.model.SystemSettingEntity;
|
||||
import vip.mate.system.repository.SystemSettingMapper;
|
||||
import vip.mate.wiki.WikiProperties;
|
||||
import vip.mate.wiki.model.WikiChunkEntity;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
import vip.mate.wiki.repository.WikiChunkMapper;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
@ -17,55 +23,99 @@ import java.nio.ByteOrder;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* RFC-011: Wiki 嵌入服务
|
||||
* RFC-011 + Embedding-UI-Config: Wiki 嵌入服务
|
||||
* <p>
|
||||
* 使用 Spring AI 的 {@link EmbeddingModel}(DashScope auto-config)对 chunk 做向量化。
|
||||
* <ul>
|
||||
* <li>{@link #embedMissingChunks} — 批量嵌入缺失 embedding 的 chunk(材料处理后异步调用)</li>
|
||||
* <li>{@link #embedQuery} — 查询向量化(混合搜索时调用)</li>
|
||||
* </ul>
|
||||
* 按知识库(KB)动态解析应使用的 Embedding 模型,解析优先级:
|
||||
* <ol>
|
||||
* <li>KB 级绑定:{@link WikiKnowledgeBaseEntity#getEmbeddingModelId()}</li>
|
||||
* <li>系统默认:{@code mate_system_setting.setting_key = 'embedding.default.model.id'}</li>
|
||||
* <li>任意 enabled 的 embedding 模型(取第一个)</li>
|
||||
* <li>全无 → 返回不可用,上层降级(语义搜索返回空,关键词搜索仍可用)</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WikiEmbeddingService {
|
||||
|
||||
private final EmbeddingModel embeddingModel;
|
||||
private final WikiChunkMapper chunkMapper;
|
||||
private final WikiProperties properties;
|
||||
private final boolean available;
|
||||
private final EmbeddingModelFactory factory;
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final WikiKnowledgeBaseService kbService;
|
||||
private final SystemSettingMapper systemSettingMapper;
|
||||
|
||||
public WikiEmbeddingService(ObjectProvider<EmbeddingModel> embeddingModelProvider,
|
||||
WikiChunkMapper chunkMapper, WikiProperties properties) {
|
||||
this.chunkMapper = chunkMapper;
|
||||
this.properties = properties;
|
||||
EmbeddingModel model = embeddingModelProvider.getIfAvailable();
|
||||
this.embeddingModel = model;
|
||||
this.available = model != null;
|
||||
if (!available) {
|
||||
log.warn("[WikiEmbedding] No EmbeddingModel bean found — semantic search disabled. "
|
||||
+ "Ensure spring-ai-alibaba-starter-dashscope is on classpath and DASHSCOPE_API_KEY is set.");
|
||||
} else {
|
||||
log.info("[WikiEmbedding] EmbeddingModel available: {}", model.getClass().getSimpleName());
|
||||
/** 系统默认 embedding 模型的 mate_system_setting key */
|
||||
public static final String SYSTEM_SETTING_DEFAULT_EMBEDDING_ID = "embedding.default.model.id";
|
||||
|
||||
/**
|
||||
* 判断全局是否有可用的 embedding 能力(任何 enabled 的 embedding 模型配置)
|
||||
*/
|
||||
public boolean isAvailable() {
|
||||
try {
|
||||
ModelConfigEntity fallback = modelConfigService.findFirstEnabledEmbedding();
|
||||
return fallback != null;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isAvailable() { return available; }
|
||||
/**
|
||||
* 解析指定 KB 应使用的 embedding 模型与模型实例
|
||||
*/
|
||||
public Resolved resolveForKb(Long kbId) {
|
||||
// 优先级 1:KB 级绑定
|
||||
try {
|
||||
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||
if (kb != null && kb.getEmbeddingModelId() != null) {
|
||||
ModelConfigEntity model = safeGetModel(kb.getEmbeddingModelId());
|
||||
if (isUsable(model)) {
|
||||
return new Resolved(factory.build(model), model.getModelName());
|
||||
}
|
||||
log.warn("[WikiEmbedding] KB {} bound embedding model {} is unusable, falling back",
|
||||
kbId, kb.getEmbeddingModelId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("[WikiEmbedding] KB binding resolve failed for kbId={}: {}", kbId, e.getMessage());
|
||||
}
|
||||
|
||||
// 优先级 2:系统默认
|
||||
Long defaultId = readSystemDefaultEmbeddingId();
|
||||
if (defaultId != null) {
|
||||
ModelConfigEntity model = safeGetModel(defaultId);
|
||||
if (isUsable(model)) {
|
||||
return new Resolved(factory.build(model), model.getModelName());
|
||||
}
|
||||
log.warn("[WikiEmbedding] System default embedding model {} is unusable, falling back", defaultId);
|
||||
}
|
||||
|
||||
// 优先级 3:任意 enabled
|
||||
ModelConfigEntity anyEnabled = modelConfigService.findFirstEnabledEmbedding();
|
||||
if (isUsable(anyEnabled)) {
|
||||
return new Resolved(factory.build(anyEnabled), anyEnabled.getModelName());
|
||||
}
|
||||
|
||||
log.warn("[WikiEmbedding] No usable embedding model configured. "
|
||||
+ "Configure one under Settings → Models → Embedding tab.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量嵌入指定 KB 中缺失 embedding 的 chunk。
|
||||
* <p>
|
||||
* 只嵌入 embedding 为 NULL 或 embeddingModel 与当前配置不匹配的 chunk。
|
||||
* 模型切换时自动触发全量重嵌(通过 embeddingModel 字段比对)。
|
||||
* 只嵌入 embedding 为 NULL 或 embeddingModel 与当前解析出的模型不一致的 chunk。
|
||||
* 模型切换时自动触发全量重嵌(通过 embedding_model 字段比对)。
|
||||
*/
|
||||
public int embedMissingChunks(Long kbId) {
|
||||
if (!available) {
|
||||
log.debug("[WikiEmbedding] Skipping — no EmbeddingModel available");
|
||||
Resolved r = resolveForKb(kbId);
|
||||
if (r == null) {
|
||||
log.debug("[WikiEmbedding] Skipping kbId={} — no embedding model available", kbId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
String modelName = properties.getEmbeddingModel();
|
||||
String modelName = r.modelName();
|
||||
List<WikiChunkEntity> pending = chunkMapper.selectList(
|
||||
new LambdaQueryWrapper<WikiChunkEntity>()
|
||||
.eq(WikiChunkEntity::getKbId, kbId)
|
||||
@ -87,8 +137,7 @@ public class WikiEmbeddingService {
|
||||
.map(WikiChunkEntity::getContent)
|
||||
.toList();
|
||||
|
||||
EmbeddingResponse resp = embeddingModel.call(
|
||||
new EmbeddingRequest(inputs, null));
|
||||
EmbeddingResponse resp = r.model().call(new EmbeddingRequest(inputs, null));
|
||||
|
||||
for (int i = 0; i < batch.size(); i++) {
|
||||
float[] vec = resp.getResults().get(i).getOutput();
|
||||
@ -99,28 +148,33 @@ public class WikiEmbeddingService {
|
||||
}
|
||||
total += batch.size();
|
||||
} catch (Exception e) {
|
||||
log.error("[WikiEmbedding] Batch embedding failed (kbId={}, batchSize={}): {}",
|
||||
kbId, batch.size(), e.getMessage());
|
||||
log.error("[WikiEmbedding] Batch embedding failed (kbId={}, batchSize={}, model={}): {}",
|
||||
kbId, batch.size(), modelName, e.getMessage());
|
||||
// 继续下一批,不中断
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[WikiEmbedding] Embedded {}/{} chunks for kbId={}, model={}",
|
||||
total, pending.size(), kbId, modelName);
|
||||
if (total == 0 && !pending.isEmpty()) {
|
||||
log.warn("[WikiEmbedding] ALL {} chunks failed for kbId={} model={} — check API key / model availability",
|
||||
pending.size(), kbId, modelName);
|
||||
} else {
|
||||
log.info("[WikiEmbedding] Embedded {}/{} chunks for kbId={}, model={}",
|
||||
total, pending.size(), kbId, modelName);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询向量化(混合搜索时调用)
|
||||
* 查询向量化(混合搜索时调用,需指定 KB 以便解析对应模型)
|
||||
*/
|
||||
public float[] embedQuery(String query) {
|
||||
if (!available) return null;
|
||||
public float[] embedQuery(Long kbId, String query) {
|
||||
Resolved r = resolveForKb(kbId);
|
||||
if (r == null) return null;
|
||||
try {
|
||||
EmbeddingResponse resp = embeddingModel.call(
|
||||
new EmbeddingRequest(List.of(query), null));
|
||||
EmbeddingResponse resp = r.model().call(new EmbeddingRequest(List.of(query), null));
|
||||
return resp.getResults().get(0).getOutput();
|
||||
} catch (Exception e) {
|
||||
log.error("[WikiEmbedding] Query embedding failed: {}", e.getMessage());
|
||||
log.error("[WikiEmbedding] Query embedding failed for kbId={}: {}", kbId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -136,6 +190,38 @@ public class WikiEmbeddingService {
|
||||
log.info("[WikiEmbedding] Cleared all embeddings for kbId={}", kbId);
|
||||
}
|
||||
|
||||
// ==================== 私有 helper ====================
|
||||
|
||||
private ModelConfigEntity safeGetModel(Long id) {
|
||||
try {
|
||||
return modelConfigService.getModel(id);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isUsable(ModelConfigEntity model) {
|
||||
return model != null
|
||||
&& Boolean.TRUE.equals(model.getEnabled())
|
||||
&& "embedding".equals(model.getModelType());
|
||||
}
|
||||
|
||||
private Long readSystemDefaultEmbeddingId() {
|
||||
try {
|
||||
SystemSettingEntity entity = systemSettingMapper.selectOne(
|
||||
new LambdaQueryWrapper<SystemSettingEntity>()
|
||||
.eq(SystemSettingEntity::getSettingKey, SYSTEM_SETTING_DEFAULT_EMBEDDING_ID)
|
||||
.last("LIMIT 1"));
|
||||
if (entity == null || entity.getSettingValue() == null || entity.getSettingValue().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Long.parseLong(entity.getSettingValue().trim());
|
||||
} catch (Exception e) {
|
||||
log.debug("[WikiEmbedding] Failed to read system default embedding id: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 向量序列化 ====================
|
||||
|
||||
public static byte[] floatsToBytes(float[] vec) {
|
||||
@ -163,4 +249,14 @@ public class WikiEmbeddingService {
|
||||
float denom = (float) (Math.sqrt(normA) * Math.sqrt(normB));
|
||||
return denom == 0 ? 0f : dot / denom;
|
||||
}
|
||||
|
||||
/** 解析结果 DTO */
|
||||
public record Resolved(EmbeddingModel model, String modelName) {}
|
||||
|
||||
/**
|
||||
* 暴露 factory 给外部(如测试连通性 API)
|
||||
*/
|
||||
public EmbeddingModelFactory getFactory() {
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,6 +112,25 @@ public class WikiKnowledgeBaseService {
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 KB 绑定的 embedding 模型 ID。
|
||||
* <p>
|
||||
* 切换模型后,旧的向量维度/语义空间与新模型不一致,下次搜索/处理时会被
|
||||
* WikiEmbeddingService 自动检测为"model 不匹配"触发重嵌。
|
||||
* 这里不主动清空 embedding(让 embed_model 字段的差异自己触发重建)。
|
||||
*
|
||||
* @param embeddingModelId null 表示解绑(走系统默认)
|
||||
*/
|
||||
@Transactional
|
||||
public void updateEmbeddingModelId(Long id, Long embeddingModelId) {
|
||||
WikiKnowledgeBaseEntity entity = kbMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
throw new IllegalArgumentException("Knowledge base not found: " + id);
|
||||
}
|
||||
entity.setEmbeddingModelId(embeddingModelId);
|
||||
kbMapper.updateById(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateConfig(Long id, String configContent) {
|
||||
WikiKnowledgeBaseEntity entity = kbMapper.selectById(id);
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
-- V14: Embedding model UI config (对标 Dify)
|
||||
-- 区分 chat / embedding 两种模型类型,让 embedding 也能通过 Settings/Models UI 管理
|
||||
ALTER TABLE mate_model_config ADD COLUMN IF NOT EXISTS model_type VARCHAR(32) DEFAULT 'chat';
|
||||
|
||||
-- 知识库可绑定特定 embedding 模型;NULL 表示使用系统默认
|
||||
ALTER TABLE mate_wiki_knowledge_base ADD COLUMN IF NOT EXISTS embedding_model_id BIGINT DEFAULT NULL;
|
||||
|
||||
-- 播种 DashScope embedding 模型(builtin,共享 dashscope provider 的 api_key)
|
||||
-- 1000001001..003 段用于 embedding 模型,与 1000000xxx 段的 chat 模型错开
|
||||
MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000001001, 'Text Embedding v3', 'dashscope', 'text-embedding-v3',
|
||||
'DashScope 通义千问 v3 通用文本向量模型(1024 维)', 0, 0, 0,
|
||||
TRUE, TRUE, TRUE, 'embedding', NOW(), NOW(), 0);
|
||||
|
||||
MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted)
|
||||
KEY (id)
|
||||
VALUES (1000001002, 'Text Embedding v2', 'dashscope', 'text-embedding-v2',
|
||||
'DashScope 通义千问 v2 文本向量模型(1536 维)', 0, 0, 0,
|
||||
TRUE, TRUE, FALSE, 'embedding', NOW(), NOW(), 0);
|
||||
|
||||
-- 设置 v3 为系统默认 embedding 模型(WikiEmbeddingService 的第 2 优先级)
|
||||
MERGE INTO mate_system_setting (setting_key, setting_value, description, create_time, update_time)
|
||||
KEY (setting_key)
|
||||
VALUES ('embedding.default.model.id', '1000001001', 'Default embedding model id for wiki semantic search', NOW(), NOW());
|
||||
@ -0,0 +1,20 @@
|
||||
-- V14: Embedding model UI config (对标 Dify)
|
||||
ALTER TABLE mate_model_config ADD COLUMN IF NOT EXISTS model_type VARCHAR(32) DEFAULT 'chat';
|
||||
ALTER TABLE mate_wiki_knowledge_base ADD COLUMN IF NOT EXISTS embedding_model_id BIGINT DEFAULT NULL;
|
||||
|
||||
-- 播种 DashScope embedding(与 chat 模型共享 provider apiKey)
|
||||
INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted)
|
||||
VALUES (1000001001, 'Text Embedding v3', 'dashscope', 'text-embedding-v3',
|
||||
'DashScope 通义千问 v3 通用文本向量模型(1024 维)', 0, 0, 0,
|
||||
TRUE, TRUE, TRUE, 'embedding', NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE model_type = 'embedding';
|
||||
|
||||
INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted)
|
||||
VALUES (1000001002, 'Text Embedding v2', 'dashscope', 'text-embedding-v2',
|
||||
'DashScope 通义千问 v2 文本向量模型(1536 维)', 0, 0, 0,
|
||||
TRUE, TRUE, FALSE, 'embedding', NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE model_type = 'embedding';
|
||||
|
||||
INSERT INTO mate_system_setting (setting_key, setting_value, description, create_time, update_time)
|
||||
VALUES ('embedding.default.model.id', '1000001001', 'Default embedding model id for wiki semantic search', NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE setting_value = setting_value;
|
||||
@ -269,6 +269,15 @@ export const modelApi = {
|
||||
http.post(`/models/${providerId}/test-connection`),
|
||||
testModel: (providerId: string, modelId: string) =>
|
||||
http.post(`/models/${providerId}/models/${encodeURIComponent(modelId)}/test`),
|
||||
|
||||
// ==================== Embedding Model (RFC Embedding UI) ====================
|
||||
listByType: (modelType: 'chat' | 'embedding') =>
|
||||
http.get('/models/by-type', { params: { modelType } }),
|
||||
testEmbedding: (modelId: string | number) =>
|
||||
http.post(`/models/embedding/${modelId}/test`),
|
||||
getDefaultEmbedding: () => http.get('/models/embedding/default'),
|
||||
setDefaultEmbedding: (modelId: string | number | '') =>
|
||||
http.post('/models/embedding/default', { modelId }),
|
||||
}
|
||||
|
||||
// ==================== OAuth ====================
|
||||
@ -355,7 +364,7 @@ export const wikiApi = {
|
||||
listKBsByAgent: (agentId: number) => http.get(`/wiki/knowledge-bases/agent/${agentId}`),
|
||||
createKB: (data: { name: string; description?: string; agentId?: number }) =>
|
||||
http.post('/wiki/knowledge-bases', data),
|
||||
updateKB: (id: number, data: { name?: string; description?: string; agentId?: number }) =>
|
||||
updateKB: (id: number, data: { name?: string; description?: string; agentId?: number; embeddingModelId?: string | number | null }) =>
|
||||
http.put(`/wiki/knowledge-bases/${id}`, data),
|
||||
deleteKB: (id: number) => http.delete(`/wiki/knowledge-bases/${id}`),
|
||||
getConfig: (id: number) => http.get(`/wiki/knowledge-bases/${id}/config`),
|
||||
|
||||
238
mateclaw-ui/src/views/Settings/Models/EmbeddingModelsSection.vue
Normal file
238
mateclaw-ui/src/views/Settings/Models/EmbeddingModelsSection.vue
Normal file
@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="provider-group embedding-section">
|
||||
<h3 class="group-title">
|
||||
<svg class="group-title__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<line x1="4.93" y1="4.93" x2="9.17" y2="9.17"/>
|
||||
<line x1="14.83" y1="14.83" x2="19.07" y2="19.07"/>
|
||||
<line x1="14.83" y1="9.17" x2="19.07" y2="4.93"/>
|
||||
<line x1="4.93" y1="19.07" x2="9.17" y2="14.83"/>
|
||||
</svg>
|
||||
Embedding 模型
|
||||
<span class="group-hint">知识库语义检索使用,与 Chat 模型共享 Provider 的 API Key</span>
|
||||
</h3>
|
||||
|
||||
<div v-if="loading" class="loading-state">加载中...</div>
|
||||
<div v-else-if="models.length === 0" class="empty-state">
|
||||
暂无可用的 Embedding 模型。系统已预置 DashScope Text Embedding v3/v2,
|
||||
请在"云端模型"下的 <strong>DashScope</strong> Provider 中配置 API Key。
|
||||
</div>
|
||||
|
||||
<div v-else class="embedding-grid">
|
||||
<div v-for="model in models" :key="model.id" class="embedding-card">
|
||||
<div class="embedding-card-header">
|
||||
<div class="embedding-name">
|
||||
{{ model.name }}
|
||||
<span v-if="String(model.id) === defaultModelId" class="default-badge">默认</span>
|
||||
</div>
|
||||
<span class="provider-badge">{{ model.provider }}</span>
|
||||
</div>
|
||||
<div class="embedding-model-id">{{ model.modelName }}</div>
|
||||
<div v-if="model.description" class="embedding-desc">{{ model.description }}</div>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div v-if="testResults[String(model.id)]" class="test-result" :class="testResults[String(model.id)].success ? 'success' : 'error'">
|
||||
<span v-if="testResults[String(model.id)].success">
|
||||
✓ 测试通过 · 维度 {{ testResults[String(model.id)].dimensions }}
|
||||
</span>
|
||||
<span v-else>✗ {{ testResults[String(model.id)].message }}</span>
|
||||
</div>
|
||||
|
||||
<div class="embedding-actions">
|
||||
<button
|
||||
class="card-btn test-btn"
|
||||
:disabled="testingId === String(model.id)"
|
||||
@click="onTest(model)"
|
||||
>
|
||||
{{ testingId === String(model.id) ? '测试中...' : '测试连通性' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="String(model.id) !== defaultModelId"
|
||||
class="card-btn"
|
||||
@click="onSetDefault(model)"
|
||||
>
|
||||
设为系统默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { modelApi } from '@/api'
|
||||
|
||||
interface EmbeddingModel {
|
||||
id: string | number
|
||||
name: string
|
||||
provider: string
|
||||
modelName: string
|
||||
description?: string
|
||||
enabled?: boolean
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
const models = ref<EmbeddingModel[]>([])
|
||||
const loading = ref(false)
|
||||
const defaultModelId = ref<string>('')
|
||||
const testingId = ref<string>('')
|
||||
const testResults = ref<Record<string, { success: boolean; dimensions?: number; message?: string }>>({})
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [listRes, defaultRes] = await Promise.all([
|
||||
modelApi.listByType('embedding'),
|
||||
modelApi.getDefaultEmbedding(),
|
||||
])
|
||||
models.value = (listRes.data as any[]) || []
|
||||
defaultModelId.value = String((defaultRes.data as any)?.defaultModelId || '')
|
||||
} catch (e: any) {
|
||||
console.error('[EmbeddingModels] Load failed:', e?.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest(model: EmbeddingModel) {
|
||||
testingId.value = String(model.id)
|
||||
try {
|
||||
const res = await modelApi.testEmbedding(model.id)
|
||||
const data = res.data as any
|
||||
testResults.value[String(model.id)] = {
|
||||
success: !!data?.success,
|
||||
dimensions: data?.dimensions,
|
||||
message: data?.message,
|
||||
}
|
||||
} catch (e: any) {
|
||||
testResults.value[String(model.id)] = {
|
||||
success: false,
|
||||
message: e?.message || '请求失败',
|
||||
}
|
||||
} finally {
|
||||
testingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function onSetDefault(model: EmbeddingModel) {
|
||||
try {
|
||||
await modelApi.setDefaultEmbedding(model.id)
|
||||
defaultModelId.value = String(model.id)
|
||||
} catch (e: any) {
|
||||
console.error('[EmbeddingModels] Set default failed:', e?.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
defineExpose({ refresh: loadAll })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embedding-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.group-hint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--mc-text-tertiary);
|
||||
margin-left: 12px;
|
||||
}
|
||||
.loading-state, .empty-state {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--mc-text-tertiary);
|
||||
background: var(--mc-bg-sunken);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.empty-state strong { color: var(--mc-primary); }
|
||||
|
||||
.embedding-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.embedding-card {
|
||||
padding: 16px;
|
||||
background: var(--mc-bg-surface);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.embedding-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.embedding-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--mc-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.default-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: var(--mc-primary-bg);
|
||||
color: var(--mc-primary);
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.provider-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-secondary);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.embedding-model-id {
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.embedding-desc {
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.test-result {
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.test-result.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: rgb(21, 128, 61);
|
||||
}
|
||||
.test-result.error {
|
||||
background: var(--mc-danger-bg);
|
||||
color: var(--mc-danger);
|
||||
}
|
||||
.embedding-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.card-btn {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--mc-border);
|
||||
background: transparent;
|
||||
color: var(--mc-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-btn:hover:not(:disabled) { background: var(--mc-bg-sunken); }
|
||||
.card-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.test-btn {
|
||||
background: var(--mc-primary-bg);
|
||||
color: var(--mc-primary);
|
||||
border-color: var(--mc-primary);
|
||||
}
|
||||
</style>
|
||||
@ -64,6 +64,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedding 模型(RFC Embedding UI) -->
|
||||
<EmbeddingModelsSection />
|
||||
|
||||
<div v-if="savedTip" class="save-tip">{{ savedTip }}</div>
|
||||
|
||||
<!-- Provider Config Modal -->
|
||||
@ -119,6 +122,7 @@ import { ElMessage } from 'element-plus'
|
||||
import type { ProviderInfo, ProviderModelInfo } from '@/types'
|
||||
import { useProviders } from './useProviders'
|
||||
import ProviderCard from './ProviderCard.vue'
|
||||
import EmbeddingModelsSection from './EmbeddingModelsSection.vue'
|
||||
import ProviderConfigModal from './modals/ProviderConfigModal.vue'
|
||||
import ManageModelsModal from './modals/ManageModelsModal.vue'
|
||||
|
||||
|
||||
@ -5,6 +5,25 @@
|
||||
<p class="config-desc">{{ t('wiki.configDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Embedding 模型绑定(RFC Embedding UI) -->
|
||||
<div class="embedding-config">
|
||||
<label class="embedding-label">
|
||||
Embedding 模型
|
||||
<span class="embedding-hint">用于该知识库的语义检索;留空走系统默认</span>
|
||||
</label>
|
||||
<div class="embedding-row">
|
||||
<select v-model="embeddingModelId" class="embedding-select" :disabled="savingEmbedding">
|
||||
<option value="">跟随系统默认</option>
|
||||
<option v-for="m in embeddingOptions" :key="m.id" :value="String(m.id)">
|
||||
{{ m.name }} ({{ m.modelName }})
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn-secondary" @click="saveEmbeddingBinding" :disabled="savingEmbedding">
|
||||
{{ savingEmbedding ? '保存中...' : '保存绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="configContent"
|
||||
class="config-editor"
|
||||
@ -25,7 +44,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useWikiStore } from '@/stores/useWikiStore'
|
||||
import { wikiApi } from '@/api/index'
|
||||
import { wikiApi, modelApi } from '@/api/index'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useWikiStore()
|
||||
@ -33,6 +52,43 @@ const store = useWikiStore()
|
||||
const configContent = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
// RFC Embedding UI: KB 级 embedding 模型绑定
|
||||
interface EmbeddingOption { id: string | number; name: string; modelName: string }
|
||||
const embeddingModelId = ref<string>('')
|
||||
const embeddingOptions = ref<EmbeddingOption[]>([])
|
||||
const savingEmbedding = ref(false)
|
||||
|
||||
async function loadEmbeddingOptions() {
|
||||
try {
|
||||
const res = await modelApi.listByType('embedding')
|
||||
embeddingOptions.value = ((res.data as any[]) || []).filter(m => m.enabled !== false)
|
||||
} catch (e) {
|
||||
console.error('[WikiConfig] Failed to load embedding options', e)
|
||||
}
|
||||
}
|
||||
|
||||
function loadEmbeddingBinding() {
|
||||
const kb: any = store.currentKB
|
||||
embeddingModelId.value = kb?.embeddingModelId ? String(kb.embeddingModelId) : ''
|
||||
}
|
||||
|
||||
async function saveEmbeddingBinding() {
|
||||
if (!store.currentKB) return
|
||||
savingEmbedding.value = true
|
||||
try {
|
||||
await wikiApi.updateKB(store.currentKB.id, {
|
||||
embeddingModelId: embeddingModelId.value === '' ? null : embeddingModelId.value,
|
||||
})
|
||||
// 同步 store 里的当前 KB
|
||||
const kb: any = store.currentKB
|
||||
kb.embeddingModelId = embeddingModelId.value === '' ? null : Number(embeddingModelId.value)
|
||||
} catch (e) {
|
||||
console.error('[WikiConfig] Failed to save embedding binding', e)
|
||||
} finally {
|
||||
savingEmbedding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
if (!store.currentKB) return
|
||||
try {
|
||||
@ -57,7 +113,10 @@ async function saveConfig() {
|
||||
|
||||
watch(() => store.currentKB, () => {
|
||||
loadConfig()
|
||||
loadEmbeddingBinding()
|
||||
}, { immediate: true })
|
||||
|
||||
loadEmbeddingOptions()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -74,6 +133,15 @@ watch(() => store.currentKB, () => {
|
||||
.config-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 6px; letter-spacing: -0.02em; }
|
||||
.config-desc { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.6; }
|
||||
|
||||
/* Embedding binding */
|
||||
.embedding-config { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: var(--mc-bg-sunken); border-radius: 10px; border: 1px solid var(--mc-border-light); }
|
||||
.embedding-label { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); display: flex; align-items: baseline; gap: 8px; }
|
||||
.embedding-hint { font-size: 11px; font-weight: 400; color: var(--mc-text-tertiary); }
|
||||
.embedding-row { display: flex; gap: 8px; align-items: center; }
|
||||
.embedding-select { flex: 1; padding: 7px 12px; border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); font-size: 13px; outline: none; }
|
||||
.embedding-select:focus { border-color: var(--mc-primary); }
|
||||
.embedding-select:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* Editor */
|
||||
.config-editor { width: 100%; flex: 1; min-height: 0; padding: 16px; border: 1px solid var(--mc-border); border-radius: 14px; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.7; resize: none; overflow: auto; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }
|
||||
.config-editor:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user