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

@@ -162,6 +162,15 @@ func UpdateAutoReplyConfig(autoReplyConfig AutoReplyConfig) error {
return nil
}
// UpdatePlatformConfig updates Wanchuan platform configuration.
func UpdatePlatformConfig(platformConfig PlatformConfig) error {
if globalConfig != nil {
globalConfig.PlatformConfig = platformConfig
return SaveGlobalConfig()
}
return nil
}
// ReloadGlobalConfig reloads config.json from disk.
func ReloadGlobalConfig() (*Config, error) {
if globalConfigManager == nil {

View File

@@ -56,8 +56,12 @@ type RetrievalConfig struct {
RetrievalMode string `json:"retrievalMode"`
EmbeddingIndexPath string `json:"embeddingIndexPath"`
EmbeddingModel string `json:"embeddingModel"`
EmbeddingBaseURL string `json:"embeddingBaseUrl"`
EmbeddingAPIKey string `json:"embeddingApiKey"`
EmbeddingDimensions int `json:"embeddingDimensions"`
RerankModel string `json:"rerankModel"`
RerankBaseURL string `json:"rerankBaseUrl"`
RerankAPIKey string `json:"rerankApiKey"`
RecallTopK int `json:"recallTopK"`
RerankTopK int `json:"rerankTopK"`
FinalTopK int `json:"finalTopK"`
@@ -145,10 +149,18 @@ type ReplyPolicyConfig struct {
SensitiveKeywords []string `json:"sensitiveKeywords"`
}
// PlatformConfig stores Wanchuan platform credentials.
type PlatformConfig struct {
BaseURL string `json:"baseUrl"`
Username string `json:"username"`
Password string `json:"password"`
}
// Config stores the application configuration.
type Config struct {
CallbackConfig CallbackConfig `json:"callbackConfig"`
AutoReplyConfig AutoReplyConfig `json:"autoReplyConfig"`
PlatformConfig PlatformConfig `json:"platformConfig"`
LastUpdated int64 `json:"lastUpdated"`
}
@@ -165,7 +177,12 @@ func NewDefaultConfig() *Config {
DeviceCode: "",
},
AutoReplyConfig: NewDefaultAutoReplyConfig(),
LastUpdated: time.Now().Unix(),
PlatformConfig: PlatformConfig{
BaseURL: "",
Username: "",
Password: "",
},
LastUpdated: time.Now().Unix(),
}
}
@@ -399,11 +416,26 @@ func (c *Config) ApplyDefaults() {
if strings.TrimSpace(c.AutoReplyConfig.AI.SystemPrompt) == "" {
c.AutoReplyConfig.AI.SystemPrompt = defaultAuto.AI.SystemPrompt
}
if c.AutoReplyConfig.AI.VisionModel == "" ||
(strings.EqualFold(c.AutoReplyConfig.AI.VisionModel, c.AutoReplyConfig.AI.Model) &&
!isVisionCapableModelName(c.AutoReplyConfig.AI.VisionModel)) ||
isLikelyTextOnlyQwenModel(c.AutoReplyConfig.AI.VisionModel) {
c.AutoReplyConfig.AI.VisionModel = defaultAuto.AI.VisionModel
visionGateway := strings.TrimSpace(c.AutoReplyConfig.AI.VisionBaseURL)
if visionGateway == "" {
visionGateway = strings.TrimSpace(c.AutoReplyConfig.AI.BaseURL)
}
if isDashScopeGateway(visionGateway) {
// DashScope 网关:空/文本模型一律回退到专用视觉模型 qwen3-vl-plus
if c.AutoReplyConfig.AI.VisionModel == "" ||
(strings.EqualFold(c.AutoReplyConfig.AI.VisionModel, c.AutoReplyConfig.AI.Model) &&
!isVisionCapableModelName(c.AutoReplyConfig.AI.VisionModel)) ||
isLikelyTextOnlyQwenModel(c.AutoReplyConfig.AI.VisionModel) {
c.AutoReplyConfig.AI.VisionModel = defaultAuto.AI.VisionModel
}
} else if strings.TrimSpace(c.AutoReplyConfig.AI.VisionBaseURL) == "" {
// 非 DashScope 且没有独立视觉网关(如万川统一网关):
// 视觉留空时清空 VisionModel让请求期 fallbackString(VisionModel, Model) 动态复用聊天模型,
// 这样后续用户改聊天模型,视觉会自动跟随,不会被锁死在旧值。
// 仅当用户在同一网关上显式填了与聊天模型不同的视觉模型时才保留其选择。
if strings.EqualFold(strings.TrimSpace(c.AutoReplyConfig.AI.VisionModel), strings.TrimSpace(c.AutoReplyConfig.AI.Model)) {
c.AutoReplyConfig.AI.VisionModel = ""
}
}
if c.AutoReplyConfig.AI.AudioProvider == "" {
c.AutoReplyConfig.AI.AudioProvider = defaultAuto.AI.AudioProvider
@@ -509,6 +541,11 @@ func dedupeStrings(items []string) []string {
return result
}
// isDashScopeGateway 判断网关是否为阿里云 DashScopeqwen3-vl-plus 等默认模型仅对其有意义)
func isDashScopeGateway(url string) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(url)), "dashscope.aliyuncs.com")
}
func isVisionCapableModelName(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
return strings.Contains(name, "vl") ||

View File

@@ -99,3 +99,49 @@ func TestApplyDefaultsFixesWrongRerankConfig(t *testing.T) {
t.Errorf("Expected rerank model to be corrected to 'qwen3-rerank', got %q", cfg.AutoReplyConfig.Retrieval.RerankModel)
}
}
// 非 DashScope 统一网关(如万川)且无独立视觉网关时:视觉模型 == 聊天模型应被清空,
// 以便运行期 fallbackString(VisionModel, Model) 动态跟随聊天模型,不锁死旧值。
func TestApplyDefaultsNonDashScopeVisionFollowsChat(t *testing.T) {
cfg := NewDefaultConfig()
cfg.AutoReplyConfig.AI.BaseURL = "https://wanchuan.example/v1"
cfg.AutoReplyConfig.AI.Model = "wanchuan-chat"
cfg.AutoReplyConfig.AI.VisionBaseURL = ""
cfg.AutoReplyConfig.AI.VisionModel = "wanchuan-chat" // 与聊天模型相同(之前回填留下的值)
cfg.ApplyDefaults()
if cfg.AutoReplyConfig.AI.VisionModel != "" {
t.Errorf("Expected vision model cleared to follow chat on non-DashScope unified gateway, got %q", cfg.AutoReplyConfig.AI.VisionModel)
}
}
// 非 DashScope 网关上用户在同一网关显式填了不同的视觉模型时,应保留其选择。
func TestApplyDefaultsNonDashScopeKeepsExplicitVision(t *testing.T) {
cfg := NewDefaultConfig()
cfg.AutoReplyConfig.AI.BaseURL = "https://wanchuan.example/v1"
cfg.AutoReplyConfig.AI.Model = "wanchuan-chat"
cfg.AutoReplyConfig.AI.VisionBaseURL = ""
cfg.AutoReplyConfig.AI.VisionModel = "wanchuan-vl" // 与聊天模型不同,属用户显式选择
cfg.ApplyDefaults()
if cfg.AutoReplyConfig.AI.VisionModel != "wanchuan-vl" {
t.Errorf("Expected explicit different vision model preserved, got %q", cfg.AutoReplyConfig.AI.VisionModel)
}
}
// DashScope 网关:视觉模型为空或文本模型时仍应回退到专用视觉模型,不受上面改动影响。
func TestApplyDefaultsDashScopeStillFallsBackToVisionModel(t *testing.T) {
cfg := NewDefaultConfig()
cfg.AutoReplyConfig.AI.BaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
cfg.AutoReplyConfig.AI.Model = "qwen-turbo"
cfg.AutoReplyConfig.AI.VisionBaseURL = ""
cfg.AutoReplyConfig.AI.VisionModel = ""
cfg.ApplyDefaults()
if cfg.AutoReplyConfig.AI.VisionModel != defaultVisionModel {
t.Errorf("Expected DashScope vision fallback to %q, got %q", defaultVisionModel, cfg.AutoReplyConfig.AI.VisionModel)
}
}