From 1517be2a25db3682f65959f141edf7402b6bbb2c Mon Sep 17 00:00:00 2001
From: yuanzhipeng <2501363769@qq.com>
Date: Fri, 26 Jun 2026 10:17:02 +0800
Subject: [PATCH] =?UTF-8?q?feat(auto-reply):=20=E6=8E=A5=E5=85=A5=E4=B8=87?=
=?UTF-8?q?=E5=B7=9D=E5=B9=B3=E5=8F=B0=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE?=
=?UTF-8?q?=20+=20=E5=90=84=E6=A8=A1=E5=9E=8B=E7=8B=AC=E7=AB=8B=E7=BD=91?=
=?UTF-8?q?=E5=85=B3=E5=9B=9E=E9=80=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
万川平台对接
- 新增 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 单元测试(视觉回退分支、陈旧索引判定)
---
config/config_manager.go | 9 +
config/types.go | 49 +-
config/types_test.go | 46 ++
frontend/src/components/AutoReply.vue | 524 +++++++++++++--
frontend/wailsjs/go/main/App.d.ts | 8 +
frontend/wailsjs/go/main/App.js | 16 +
helper/auto_reply_ai.go.bak | 902 --------------------------
helper/auto_reply_retrieval.go | 81 ++-
helper/auto_reply_test.go | 43 ++
wanchuan_proxy.go | 242 +++++++
10 files changed, 936 insertions(+), 984 deletions(-)
delete mode 100644 helper/auto_reply_ai.go.bak
create mode 100644 wanchuan_proxy.go
diff --git a/config/config_manager.go b/config/config_manager.go
index edc48d7..1c0588d 100644
--- a/config/config_manager.go
+++ b/config/config_manager.go
@@ -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 {
diff --git a/config/types.go b/config/types.go
index 839718f..c3cbf6f 100644
--- a/config/types.go
+++ b/config/types.go
@@ -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 判断网关是否为阿里云 DashScope(qwen3-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") ||
diff --git a/config/types_test.go b/config/types_test.go
index f2cf539..60b7104 100644
--- a/config/types_test.go
+++ b/config/types_test.go
@@ -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)
+ }
+}
diff --git a/frontend/src/components/AutoReply.vue b/frontend/src/components/AutoReply.vue
index 6e14341..ffc4eba 100644
--- a/frontend/src/components/AutoReply.vue
+++ b/frontend/src/components/AutoReply.vue
@@ -54,7 +54,8 @@
身份缓存
- {{ status.identityRefreshing ? '刷新中' : formatUnixTime(status.identityLastRefreshAt) }}
+ {{ status.identityRefreshing ? '刷新中' :
+ formatUnixTime(status.identityLastRefreshAt) }}
协同等待中
@@ -79,13 +80,8 @@
@@ -167,13 +163,49 @@
{{ form.ai.provider === 'local' ? '本地模型' : '兼容接口' }}
文本:{{ form.ai.model || '未配置' }}
- 图片:{{ form.ai.visionModel || defaultVisionModel }}
+ 图片:{{ form.ai.visionModel || form.ai.model || defaultVisionModel }}{{ form.ai.visionModel ? '' :
+ '(复用文本模型)' }}
语音:{{ form.ai.audioModel || '未配置' }}
+
+
+
+
@@ -499,7 +544,8 @@
内部 {{ status.internalContactCount || 0 }}
外部 {{ status.externalContactCount || 0 }}
- 缓存 {{ status.identityInitializing ? '初始化中' : (status.identityRefreshing ? '刷新中' : formatUnixTime(status.identityLastRefreshAt)) }}
+ 缓存 {{ status.identityInitializing ? '初始化中' : (status.identityRefreshing ? '刷新中' :
+ formatUnixTime(status.identityLastRefreshAt)) }}
@@ -550,20 +596,24 @@
- 添加
+ 添加
先点击“刷新群列表”,再选择企业总群;未选择总群时只同步企微联系人列表。
-
+
{{ item.name || '未命名群聊' }}
{{ item.conversationId }}
{{ item.source || '群列表' }}
- 删除
+ 删除
暂无内部成员同步群
@@ -624,23 +674,21 @@
{{ identityOptionLabel(item) }}
-
添加
+
添加
暂无手动内部员工
@@ -658,30 +706,29 @@
{{ identityOptionLabel(item) }}
-
添加
+
添加
暂无手动外部客户