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:
2026-06-26 10:17:02 +08:00
parent a926ee6b1b
commit 1517be2a25
10 changed files with 936 additions and 984 deletions

View File

@@ -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") {