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

242
wanchuan_proxy.go Normal file
View File

@@ -0,0 +1,242 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"qiweimanager/config"
)
// WanchuanLogin 登录万川平台并返回原始响应
func (a *App) WanchuanLogin(baseURL, username, password string) string {
startTime := time.Now()
// 构建请求体
loginData := map[string]interface{}{
"username": username,
"password": password,
"loginType": "user",
}
jsonData, err := json.Marshal(loginData)
if err != nil {
globalLogger.Error("[WanchuanLogin] 序列化登录数据失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "序列化登录数据失败: %v"}`, err)
}
// 打印日志(密码打码)
globalLogger.Info("[WanchuanLogin] 登录请求 - URL: %s/api/login, 用户名: %s, 密码: %s",
baseURL, username, maskString(password))
// 发送 POST 请求
url := fmt.Sprintf("%s/api/login", strings.TrimRight(baseURL, "/"))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
globalLogger.Error("[WanchuanLogin] 创建请求失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "创建请求失败: %v"}`, err)
}
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
globalLogger.Error("[WanchuanLogin] 请求失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "请求失败: %v"}`, err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
globalLogger.Error("[WanchuanLogin] 读取响应失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "读取响应失败: %v"}`, err)
}
responseStr := string(body)
// 打印日志token 打码)
maskedResponse := maskToken(responseStr)
duration := time.Since(startTime).Milliseconds()
globalLogger.Info("[WanchuanLogin] 登录完成 - 耗时: %dms, 响应: %s", duration, maskedResponse)
a.AddLogEntry("Wanchuan", "info", fmt.Sprintf("平台登录完成 - 用户: %s", username), duration)
// 原样返回平台响应
return responseStr
}
// WanchuanGetModel 获取模型配置并返回原始响应
func (a *App) WanchuanGetModel(baseURL, code, token string) string {
startTime := time.Now()
globalLogger.Info("[WanchuanGetModel] 获取模型 - URL: %s/api/system/model/getByCode/%s, Token: %s",
baseURL, code, maskString(token))
// 发送 GET 请求
url := fmt.Sprintf("%s/api/system/model/getByCode/%s", strings.TrimRight(baseURL, "/"), code)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
globalLogger.Error("[WanchuanGetModel] 创建请求失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "创建请求失败: %v"}`, err)
}
// 设置认证头(平台同时支持两种)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("token", fmt.Sprintf("Bearer %s", token))
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
globalLogger.Error("[WanchuanGetModel] 请求失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "请求失败: %v"}`, err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
globalLogger.Error("[WanchuanGetModel] 读取响应失败: %v", err)
return fmt.Sprintf(`{"success": false, "error": "读取响应失败: %v"}`, err)
}
responseStr := string(body)
// 打印日志apiKey 打码)
maskedResponse := maskApiKey(responseStr)
duration := time.Since(startTime).Milliseconds()
globalLogger.Info("[WanchuanGetModel] 获取模型完成 - code: %s, 耗时: %dms, 响应: %s", code, duration, maskedResponse)
a.AddLogEntry("Wanchuan", "info", fmt.Sprintf("获取模型配置 - code: %s", code), duration)
// 原样返回平台响应
return responseStr
}
// GetPlatformConfig 获取平台配置
func (a *App) GetPlatformConfig() interface{} {
globalLogger.Info("[GetPlatformConfig] 读取平台配置")
appConfig := config.GetGlobalConfig()
if appConfig == nil {
globalLogger.Warn("[GetPlatformConfig] 全局配置不存在")
return config.PlatformConfig{}
}
return appConfig.PlatformConfig
}
// SavePlatformConfig 保存平台配置
func (a *App) SavePlatformConfig(jsonData string) (bool, string) {
startTime := time.Now()
globalLogger.Info("[SavePlatformConfig] 保存平台配置 - 数据: %s", maskPlatformConfig(jsonData))
var platformConfig config.PlatformConfig
if err := json.Unmarshal([]byte(jsonData), &platformConfig); err != nil {
msg := fmt.Sprintf("解析平台配置失败: %v", err)
globalLogger.Error("%s", msg)
return false, msg
}
if err := config.UpdatePlatformConfig(platformConfig); err != nil {
msg := fmt.Sprintf("保存平台配置失败: %v", err)
globalLogger.Error("%s", msg)
return false, msg
}
duration := time.Since(startTime).Milliseconds()
globalLogger.Info("[SavePlatformConfig] 保存成功 - 耗时: %dms", duration)
a.AddLogEntry("Wanchuan", "info", "平台配置已保存", duration)
return true, "success"
}
// maskString 对字符串打码(保留首尾,中间用 * 替换)
func maskString(s string) string {
if len(s) <= 8 {
return "***"
}
return s[:4] + "****" + s[len(s)-4:]
}
// maskToken 对 JSON 响应中的 token 打码
func maskToken(jsonStr string) string {
var data map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return jsonStr
}
// 递归打码 token 字段
maskTokenInMap(data)
masked, _ := json.Marshal(data)
return string(masked)
}
// maskApiKey 对 JSON 响应中的 apiKey 打码
func maskApiKey(jsonStr string) string {
var data map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return jsonStr
}
// 递归打码 apiKey 字段
maskApiKeyInMap(data)
masked, _ := json.Marshal(data)
return string(masked)
}
// maskPlatformConfig 对平台配置中的密码打码
func maskPlatformConfig(jsonStr string) string {
var data map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return jsonStr
}
if pwd, ok := data["password"].(string); ok && pwd != "" {
data["password"] = maskString(pwd)
}
masked, _ := json.Marshal(data)
return string(masked)
}
func maskTokenInMap(m map[string]interface{}) {
for k, v := range m {
if k == "token" || k == "access_token" {
if s, ok := v.(string); ok && s != "" {
m[k] = maskString(s)
}
} else if subMap, ok := v.(map[string]interface{}); ok {
maskTokenInMap(subMap)
}
}
}
func maskApiKeyInMap(m map[string]interface{}) {
for k, v := range m {
if k == "apiKey" || k == "api_key" {
if s, ok := v.(string); ok && s != "" {
m[k] = maskString(s)
}
} else if k == "encryptedConfig" {
if s, ok := v.(string); ok && s != "" {
// encryptedConfig 是 JSON 字符串,需要二次解析
var configMap map[string]interface{}
if err := json.Unmarshal([]byte(s), &configMap); err == nil {
maskApiKeyInMap(configMap)
masked, _ := json.Marshal(configMap)
m[k] = string(masked)
}
}
} else if subMap, ok := v.(map[string]interface{}); ok {
maskApiKeyInMap(subMap)
}
}
}