feat(auto-reply): 接入万川平台模型配置 + 各模型独立网关回退
万川平台对接 - 新增 wanchuan_proxy.go:WanchuanLogin/WanchuanGetModel 代理登录与按 code 拉取模型, 日志对 password/token/apiKey 打码(含 encryptedConfig 二次解析) - 新增 PlatformConfig(baseUrl/username/password)及 Get/SavePlatformConfig 持久化 - 前端万川卡片:登录→拉取 chat/vision/embedding/rerank/voice→回填 form 并保存→必要时重建向量索引 各模型独立网关(url+key),留空回退聊天网关 - RetrievalConfig 新增 embeddingBaseUrl/embeddingApiKey、rerankBaseUrl/rerankApiKey - embeddingRequestConfig/rerankRequestConfig:优先独立网关,未配置回退 AI.BaseURL/APIKey - vision/audio 同模式:非 DashScope 网关下视觉/语音模型留空时不再锁死或强写 DashScope, 运行期由 fallbackString(VisionModel, Model) 动态复用聊天模型 陈旧向量空间防护 - loadEmbeddingIndex 检测磁盘索引与当前 embedding 模型/维度不一致时清空向量、回退关键词检索, 并提示重建(embeddingIndexStaleReason,兼容旧版无模型名索引) UI 状态修复 - 登录拉模型期间统一置全局 busy,禁用闸门收敛为 busy(与刷新联系人等按钮同范式), platformBusy 仅保留用于按钮「处理中…」文案,杜绝并发读写 form 与反向可点洞 其他 - 删除遗留 helper/auto_reply_ai.go.bak - 补充 config/helper 单元测试(视觉回退分支、陈旧索引判定)
This commit is contained in:
@@ -88,10 +88,43 @@ func (e *AutoReplyEngine) loadEmbeddingIndex() error {
|
||||
if idx.Entries == nil {
|
||||
idx.Entries = make(map[string]EmbeddingEntry)
|
||||
}
|
||||
// 防止"陈旧向量空间"问题:磁盘上的索引若与当前 embedding 模型/维度不一致,
|
||||
// 它处于不同的向量空间,余弦相似度会得到无意义的分数(cosineSimilarity 还会静默截断长度)。
|
||||
// 此时清空向量条目(保留模型/维度元信息),让检索自动回退到关键词,直到用户重建索引。
|
||||
if mismatch := embeddingIndexStaleReason(idx, cfg.Retrieval); mismatch != "" && len(idx.Entries) > 0 {
|
||||
if globalLogger != nil {
|
||||
globalLogger.Warn("[Embedding] 索引与当前配置不一致(%s),已忽略陈旧向量并回退关键词检索,请重建知识库索引", mismatch)
|
||||
}
|
||||
e.setLastErrorWithScope(autoReplyErrorScopeKnowledge, "Embedding 索引与当前模型/维度不一致("+mismatch+"),已回退关键词检索,请重建知识库索引")
|
||||
idx.Entries = make(map[string]EmbeddingEntry)
|
||||
idx.Model = strings.TrimSpace(cfg.Retrieval.EmbeddingModel)
|
||||
idx.Dimensions = cfg.Retrieval.EmbeddingDimensions
|
||||
}
|
||||
e.updateEmbeddingStatus(&idx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// embeddingIndexStaleReason 返回磁盘索引与当前配置不一致的原因;一致则返回空字符串。
|
||||
// 不区分具体平台(DashScope / 万川 / 其它自建网关),只要模型名或维度对不上就视为陈旧向量空间。
|
||||
func embeddingIndexStaleReason(idx EmbeddingIndex, retrievalCfg config.RetrievalConfig) string {
|
||||
wantModel := strings.TrimSpace(retrievalCfg.EmbeddingModel)
|
||||
gotModel := strings.TrimSpace(idx.Model)
|
||||
if wantModel != "" {
|
||||
if gotModel == "" {
|
||||
// 旧版本生成的索引未记录模型名,无法证明它与当前模型同属一个向量空间,按陈旧处理。
|
||||
return fmt.Sprintf("模型 (未知)→%s", wantModel)
|
||||
}
|
||||
if !strings.EqualFold(wantModel, gotModel) {
|
||||
return fmt.Sprintf("模型 %s→%s", gotModel, wantModel)
|
||||
}
|
||||
}
|
||||
wantDim := retrievalCfg.EmbeddingDimensions
|
||||
if wantDim > 0 && idx.Dimensions > 0 && wantDim != idx.Dimensions {
|
||||
return fmt.Sprintf("维度 %d→%d", idx.Dimensions, wantDim)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *AutoReplyEngine) updateEmbeddingStatus(idx *EmbeddingIndex) {
|
||||
if idx == nil {
|
||||
idx = NewEmbeddingIndex("", 0)
|
||||
@@ -107,9 +140,10 @@ func (e *AutoReplyEngine) updateEmbeddingStatus(idx *EmbeddingIndex) {
|
||||
|
||||
func (e *AutoReplyEngine) rebuildEmbeddingIndex(idx *KnowledgeIndex) error {
|
||||
cfg := e.getConfig()
|
||||
if strings.TrimSpace(cfg.AI.APIKey) == "" || strings.TrimSpace(cfg.AI.BaseURL) == "" {
|
||||
embedCfg := embeddingRequestConfig(cfg.AI, cfg.Retrieval)
|
||||
if strings.TrimSpace(embedCfg.APIKey) == "" || strings.TrimSpace(embedCfg.BaseURL) == "" {
|
||||
e.updateEmbeddingStatus(NewEmbeddingIndex(cfg.Retrieval.EmbeddingModel, cfg.Retrieval.EmbeddingDimensions))
|
||||
return fmt.Errorf("Embedding索引跳过:AI Base URL 或 API Key 未配置")
|
||||
return fmt.Errorf("Embedding索引跳过:Embedding/AI Base URL 或 API Key 未配置")
|
||||
}
|
||||
if idx == nil {
|
||||
return nil
|
||||
@@ -455,8 +489,9 @@ func (e *AutoReplyEngine) searchVectorKnowledge(query string, limit int) ([]Know
|
||||
if idx == nil || embeddingIndex == nil || len(embeddingIndex.Entries) == 0 {
|
||||
return nil, fmt.Errorf("向量索引为空,请先重建知识库索引")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AI.APIKey) == "" || strings.TrimSpace(cfg.AI.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("AI Base URL 或 API Key 未配置")
|
||||
embedCfg := embeddingRequestConfig(cfg.AI, cfg.Retrieval)
|
||||
if strings.TrimSpace(embedCfg.APIKey) == "" || strings.TrimSpace(embedCfg.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("Embedding/AI Base URL 或 API Key 未配置")
|
||||
}
|
||||
vectors, err := callDashScopeEmbeddings(cfg.AI, cfg.Retrieval, []string{query})
|
||||
if err != nil {
|
||||
@@ -492,7 +527,8 @@ func callDashScopeEmbeddings(aiCfg config.AIConfig, retrievalCfg config.Retrieva
|
||||
if len(inputs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
url := strings.TrimRight(aiCfg.BaseURL, "/")
|
||||
embedCfg := embeddingRequestConfig(aiCfg, retrievalCfg)
|
||||
url := strings.TrimRight(embedCfg.BaseURL, "/")
|
||||
if !strings.HasSuffix(url, "/embeddings") {
|
||||
url += "/embeddings"
|
||||
}
|
||||
@@ -511,7 +547,7 @@ func callDashScopeEmbeddings(aiCfg config.AIConfig, retrievalCfg config.Retrieva
|
||||
} `json:"data"`
|
||||
Error interface{} `json:"error"`
|
||||
}
|
||||
if err := doRetrievalJSONRequest(aiCfg, url, payload, &response); err != nil {
|
||||
if err := doRetrievalJSONRequest(embedCfg, url, payload, &response); err != nil {
|
||||
// 检测是否是模型配置错误
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(strings.ToLower(errMsg), "unsupported model") &&
|
||||
@@ -562,8 +598,9 @@ func callDashScopeRerank(aiCfg config.AIConfig, retrievalCfg config.RetrievalCon
|
||||
Error interface{} `json:"error"`
|
||||
}
|
||||
var lastErr error
|
||||
for _, url := range dashScopeRerankURLs(aiCfg) {
|
||||
if err := doRetrievalJSONRequest(aiCfg, url, payload, &response); err != nil {
|
||||
rerankCfg := rerankRequestConfig(aiCfg, retrievalCfg)
|
||||
for _, url := range dashScopeRerankURLs(rerankCfg) {
|
||||
if err := doRetrievalJSONRequest(rerankCfg, url, payload, &response); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
@@ -629,6 +666,34 @@ func doRetrievalJSONRequest(aiCfg config.AIConfig, url string, payload interface
|
||||
return nil
|
||||
}
|
||||
|
||||
// embeddingRequestConfig 返回用于向量调用的有效配置:
|
||||
// 优先使用 Retrieval.EmbeddingBaseURL / EmbeddingAPIKey,未配置则回退到聊天网关 AI.BaseURL / APIKey。
|
||||
func embeddingRequestConfig(aiCfg config.AIConfig, retrievalCfg config.RetrievalConfig) config.AIConfig {
|
||||
embedCfg := aiCfg
|
||||
if base := strings.TrimSpace(retrievalCfg.EmbeddingBaseURL); base != "" {
|
||||
embedCfg.BaseURL = base
|
||||
}
|
||||
key := strings.TrimSpace(retrievalCfg.EmbeddingAPIKey)
|
||||
if key != "" && !looksLikeURL(key) {
|
||||
embedCfg.APIKey = key
|
||||
}
|
||||
return embedCfg
|
||||
}
|
||||
|
||||
// rerankRequestConfig 返回用于重排调用的有效配置:
|
||||
// 优先使用 Retrieval.RerankBaseURL / RerankAPIKey,未配置则回退到聊天网关 AI.BaseURL / APIKey。
|
||||
func rerankRequestConfig(aiCfg config.AIConfig, retrievalCfg config.RetrievalConfig) config.AIConfig {
|
||||
rerankCfg := aiCfg
|
||||
if base := strings.TrimSpace(retrievalCfg.RerankBaseURL); base != "" {
|
||||
rerankCfg.BaseURL = base
|
||||
}
|
||||
key := strings.TrimSpace(retrievalCfg.RerankAPIKey)
|
||||
if key != "" && !looksLikeURL(key) {
|
||||
rerankCfg.APIKey = key
|
||||
}
|
||||
return rerankCfg
|
||||
}
|
||||
|
||||
func dashScopeRerankURLs(aiCfg config.AIConfig) []string {
|
||||
baseURL := strings.TrimRight(aiCfg.BaseURL, "/")
|
||||
if strings.Contains(baseURL, "dashscope.aliyuncs.com") {
|
||||
|
||||
Reference in New Issue
Block a user