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)
}
}

View File

@@ -54,7 +54,8 @@
</div>
<div class="metric">
<span>身份缓存</span>
<strong :class="status.identityRefreshing ? 'muted' : ''">{{ status.identityRefreshing ? '刷新中' : formatUnixTime(status.identityLastRefreshAt) }}</strong>
<strong :class="status.identityRefreshing ? 'muted' : ''">{{ status.identityRefreshing ? '刷新中' :
formatUnixTime(status.identityLastRefreshAt) }}</strong>
</div>
<div class="metric">
<span>协同等待中</span>
@@ -79,13 +80,8 @@
</div>
<nav class="section-nav" aria-label="自动客服模块导航">
<button
v-for="item in sectionNavItems"
:key="item.id"
type="button"
class="section-nav-btn"
@click="scrollToSection(item.id)"
>
<button v-for="item in sectionNavItems" :key="item.id" type="button" class="section-nav-btn"
@click="scrollToSection(item.id)">
{{ item.label }}
</button>
</nav>
@@ -167,13 +163,49 @@
<div class="section-meta">
<span>{{ form.ai.provider === 'local' ? '本地模型' : '兼容接口' }}</span>
<span>文本{{ form.ai.model || '未配置' }}</span>
<span>图片{{ form.ai.visionModel || defaultVisionModel }}</span>
<span>图片{{ form.ai.visionModel || form.ai.model || defaultVisionModel }}{{ form.ai.visionModel ? '' :
'(复用文本模型)' }}</span>
<span>语音{{ form.ai.audioModel || '未配置' }}</span>
</div>
</div>
<div v-if="sectionAlerts('ai').length" class="section-alerts">
<div v-for="alert in sectionAlerts('ai')" :key="alert" class="section-alert">{{ alert }}</div>
</div>
<!-- 万川平台配置卡片 -->
<div class="config-subsection wanchuan-platform-card">
<div class="config-subsection-header">
<strong>万川平台</strong>
<span>从万川 AI 平台自动获取模型配置</span>
</div>
<div class="form-grid wanchuan-form-row">
<label>
<span>平台地址</span>
<input v-model="platformForm.baseUrl" placeholder="https://platform.example.com">
</label>
<label>
<span>账号</span>
<input v-model="platformForm.username" placeholder="用户名">
</label>
<label>
<span>密码</span>
<input v-model="platformForm.password" type="password" placeholder="密码">
</label>
<div class="wanchuan-actions">
<!-- 禁用统一走全局 busy刷新联系人等按钮同范式busy || 本按钮字段守卫
登录时 loginAndFetchModels 已置 busy=trueplatformBusy 仅保留用于本按钮处理中文案
避免别处操作占用 busy 时这里误显处理中 -->
<button class="ghost-btn" @click="loginAndFetchModels"
:disabled="busy || !platformForm.baseUrl || !platformForm.username || !platformForm.password">
{{ platformBusy ? '处理中...' : '登录并获取模型' }}
</button>
<button class="ghost-btn" @click="resetPlatformConfig"
:disabled="busy || !platformForm.baseUrl || !platformForm.username || !platformForm.password">重新获取模型</button>
</div>
</div>
<div v-if="platformMessage" class="inline-message" :class="platformMessageType">{{ platformMessage }}</div>
</div>
<div class="ai-config-groups">
<div class="config-subsection">
<div class="config-subsection-header">
@@ -342,6 +374,17 @@
用于文本向量化例如text-embedding-v4, text-embedding-v3
</small>
</label>
<label>
<span>Embedding Base URL</span>
<input v-model="form.retrieval.embeddingBaseUrl" placeholder="留空则复用文本模型网关">
<small style="color: #666; font-size: 12px; margin-top: 4px; display: block;">
向量模型独立网关留空回退到文本回复 Base URL
</small>
</label>
<label>
<span>Embedding API Key</span>
<input v-model="form.retrieval.embeddingApiKey" type="password" placeholder="留空则复用文本模型密钥">
</label>
<label>
<span>Embedding 维度</span>
<input type="number" v-model.number="form.retrieval.embeddingDimensions" min="128">
@@ -353,6 +396,17 @@
用于结果重排序例如qwen3-rerank, gte-rerank-v2
</small>
</label>
<label>
<span>Rerank Base URL</span>
<input v-model="form.retrieval.rerankBaseUrl" placeholder="留空则复用文本模型网关">
<small style="color: #666; font-size: 12px; margin-top: 4px; display: block;">
重排模型独立网关留空回退到文本回复 Base URL
</small>
</label>
<label>
<span>Rerank API Key</span>
<input v-model="form.retrieval.rerankApiKey" type="password" placeholder="留空则复用文本模型密钥">
</label>
<label>
<span>召回 TopK</span>
<input type="number" v-model.number="form.retrieval.recallTopK" min="1">
@@ -425,24 +479,13 @@
<label>
<span>人工同事</span>
<div class="handoff-human-combobox">
<input
v-model="handoffHumanQuery"
class="handoff-human-search"
placeholder="输入姓名、拼音、userID 模糊搜索"
@focus="startHandoffHumanEditing"
@input="handleHandoffHumanInput"
@keydown.enter.prevent="previewFirstHandoffHuman"
@keydown.esc.prevent="closeHandoffHumanDropdown"
>
<input v-model="handoffHumanQuery" class="handoff-human-search" placeholder="输入姓名、拼音、userID 模糊搜索"
@focus="startHandoffHumanEditing" @input="handleHandoffHumanInput"
@keydown.enter.prevent="previewFirstHandoffHuman" @keydown.esc.prevent="closeHandoffHumanDropdown">
<div v-if="handoffHumanDropdownOpen" class="handoff-human-dropdown">
<button
v-for="item in handoffHumanOptions"
:key="item.userId"
type="button"
class="handoff-human-option"
<button v-for="item in handoffHumanOptions" :key="item.userId" type="button" class="handoff-human-option"
:class="{ selected: pendingHandoffHumanId === item.userId }"
@mousedown.prevent="previewHandoffHuman(item)"
>
@mousedown.prevent="previewHandoffHuman(item)">
<strong>{{ identityOptionName(item) }}</strong>
<span>{{ item.userId }}</span>
<small>{{ identitySourceLabel(item.source) }}</small>
@@ -453,7 +496,8 @@
</div>
</div>
<div class="select-action-row">
<button type="button" class="mini-btn" @click="confirmHandoffHuman" :disabled="!canConfirmHandoffHuman">确认选择</button>
<button type="button" class="mini-btn" @click="confirmHandoffHuman"
:disabled="!canConfirmHandoffHuman">确认选择</button>
<button type="button" class="mini-btn" @click="clearHandoffHuman">清空</button>
</div>
<small class="field-hint">{{ selectedHandoffHumanSummary }}</small>
@@ -480,7 +524,8 @@
</label>
<label class="wide">
<span>通知模板</span>
<textarea v-model="form.handoff.messageTemplate" rows="8" :placeholder="defaultHandoffTemplateHint"></textarea>
<textarea v-model="form.handoff.messageTemplate" rows="8"
:placeholder="defaultHandoffTemplateHint"></textarea>
<small class="field-hint">留空时会按私聊/群聊自动使用上面的默认模板填写后则使用你自定义的模板</small>
</label>
</div>
@@ -499,7 +544,8 @@
<div class="section-meta">
<span>内部 {{ status.internalContactCount || 0 }}</span>
<span>外部 {{ status.externalContactCount || 0 }}</span>
<span>缓存 {{ status.identityInitializing ? '初始化中' : (status.identityRefreshing ? '刷新中' : formatUnixTime(status.identityLastRefreshAt)) }}</span>
<span>缓存 {{ status.identityInitializing ? '初始化中' : (status.identityRefreshing ? '刷新中' :
formatUnixTime(status.identityLastRefreshAt)) }}</span>
</div>
</div>
<div v-if="sectionAlerts('identity').length" class="section-alerts">
@@ -550,20 +596,24 @@
<input v-model="internalGroupQuery" placeholder="搜索总群名称或 conversationId">
<select v-model="selectedInternalGroupId" :disabled="internalGroupFilteredOptions.length === 0">
<option value="">{{ groupSelectHint() }}</option>
<option v-for="item in internalGroupFilteredOptions" :key="item.conversationId" :value="item.conversationId">
<option v-for="item in internalGroupFilteredOptions" :key="item.conversationId"
:value="item.conversationId">
{{ groupOptionLabel(item) }}
</option>
</select>
<button type="button" class="ghost-btn" @click="addSelectedInternalGroup" :disabled="!canAddInternalGroup">添加</button>
<button type="button" class="ghost-btn" @click="addSelectedInternalGroup"
:disabled="!canAddInternalGroup">添加</button>
</div>
<div v-if="groupOptions.length === 0" class="field-hint">先点击刷新群列表再选择企业总群未选择总群时只同步企微联系人列表</div>
<div v-if="internalSelectedGroups.length" class="identity-selected-list">
<div v-for="item in internalSelectedGroups" :key="item.conversationId" class="identity-selected-row group-selected-row">
<div v-for="item in internalSelectedGroups" :key="item.conversationId"
class="identity-selected-row group-selected-row">
<strong>{{ item.name || '未命名群聊' }}</strong>
<span class="identity-user-id">{{ item.conversationId }}</span>
<span>{{ item.source || '群列表' }}</span>
<span></span>
<button type="button" class="ghost-btn small-btn" @click="removeInternalGroup(item.conversationId)">删除</button>
<button type="button" class="ghost-btn small-btn"
@click="removeInternalGroup(item.conversationId)">删除</button>
</div>
</div>
<div v-else class="identity-empty">暂无内部成员同步群</div>
@@ -624,23 +674,21 @@
{{ identityOptionLabel(item) }}
</option>
</select>
<button type="button" class="ghost-btn" @click="addSelectedIdentity('internal')" :disabled="!canAddIdentity('internal')">添加</button>
<button type="button" class="ghost-btn" @click="addSelectedIdentity('internal')"
:disabled="!canAddIdentity('internal')">添加</button>
</div>
<div v-if="internalSelectedContacts.length" class="identity-selected-list">
<div v-for="item in internalSelectedContacts" :key="item.userId" class="identity-selected-row">
<strong>{{ item.name || '未填写名称' }}</strong>
<span class="identity-user-id">{{ item.userId }}</span>
<span>{{ identitySourceLabel(item.source) }}</span>
<input
v-if="item.needsLabel"
:value="identityManualLabelDraft('internal', item.userId)"
placeholder="补充名称"
@input="setIdentityLabelDraft('internal', item.userId, $event.target.value)"
<input v-if="item.needsLabel" :value="identityManualLabelDraft('internal', item.userId)"
placeholder="补充名称" @input="setIdentityLabelDraft('internal', item.userId, $event.target.value)"
@blur="commitIdentityLabelDraft('internal', item.userId)"
@keydown.enter.prevent="commitIdentityLabelDraft('internal', item.userId)"
>
@keydown.enter.prevent="commitIdentityLabelDraft('internal', item.userId)">
<span v-else></span>
<button type="button" class="ghost-btn small-btn" @click="removeIdentity('internal', item.userId)">删除</button>
<button type="button" class="ghost-btn small-btn"
@click="removeIdentity('internal', item.userId)">删除</button>
</div>
</div>
<div v-else class="identity-empty">暂无手动内部员工</div>
@@ -658,30 +706,29 @@
{{ identityOptionLabel(item) }}
</option>
</select>
<button type="button" class="ghost-btn" @click="addSelectedIdentity('external')" :disabled="!canAddIdentity('external')">添加</button>
<button type="button" class="ghost-btn" @click="addSelectedIdentity('external')"
:disabled="!canAddIdentity('external')">添加</button>
</div>
<div v-if="externalSelectedContacts.length" class="identity-selected-list">
<div v-for="item in externalSelectedContacts" :key="item.userId" class="identity-selected-row">
<strong>{{ item.name || '未填写名称' }}</strong>
<span class="identity-user-id">{{ item.userId }}</span>
<span>{{ identitySourceLabel(item.source) }}</span>
<input
v-if="item.needsLabel"
:value="identityManualLabelDraft('external', item.userId)"
placeholder="补充名称"
@input="setIdentityLabelDraft('external', item.userId, $event.target.value)"
<input v-if="item.needsLabel" :value="identityManualLabelDraft('external', item.userId)"
placeholder="补充名称" @input="setIdentityLabelDraft('external', item.userId, $event.target.value)"
@blur="commitIdentityLabelDraft('external', item.userId)"
@keydown.enter.prevent="commitIdentityLabelDraft('external', item.userId)"
>
@keydown.enter.prevent="commitIdentityLabelDraft('external', item.userId)">
<span v-else></span>
<button type="button" class="ghost-btn small-btn" @click="removeIdentity('external', item.userId)">删除</button>
<button type="button" class="ghost-btn small-btn"
@click="removeIdentity('external', item.userId)">删除</button>
</div>
</div>
<div v-else class="identity-empty">暂无手动外部客户</div>
</div>
<label>
<span>批量粘贴内部员工 ID</span>
<textarea v-model="internalUserIdsText" rows="3" placeholder="每行一个 user_id例如 1688855899845302"></textarea>
<textarea v-model="internalUserIdsText" rows="3"
placeholder="每行一个 user_id例如 1688855899845302"></textarea>
</label>
<label>
<span>批量粘贴外部客户 ID</span>
@@ -705,7 +752,8 @@
<button class="ghost-btn" @click="saveConfig('identity')" :disabled="busy">保存配置</button>
<button class="ghost-btn" @click="refreshContacts" :disabled="busy || status.identityRefreshing">刷新联系人</button>
<button class="ghost-btn" @click="refreshGroups" :disabled="busy">刷新群列表</button>
<button class="ghost-btn" @click="syncInternalGroups" :disabled="busy || status.identityRefreshing || !form.identity.internalGroupConversationIds.length">同步内部成员群</button>
<button class="ghost-btn" @click="syncInternalGroups"
:disabled="busy || status.identityRefreshing || !form.identity.internalGroupConversationIds.length">同步内部成员群</button>
</div>
</section>
@@ -756,11 +804,15 @@
<div v-if="!status.lastMessages || status.lastMessages.length === 0" class="empty">暂无处理记录</div>
</div>
<div v-if="recordTotalCount > 0" class="record-pagination">
<button class="ghost-btn small-btn" type="button" @click="goRecordPage(1)" :disabled="recordCurrentPage <= 1">首页</button>
<button class="ghost-btn small-btn" type="button" @click="prevRecordPage" :disabled="recordCurrentPage <= 1">上一</button>
<button class="ghost-btn small-btn" type="button" @click="goRecordPage(1)"
:disabled="recordCurrentPage <= 1"></button>
<button class="ghost-btn small-btn" type="button" @click="prevRecordPage"
:disabled="recordCurrentPage <= 1">上一页</button>
<span>{{ recordPageSummary }}显示 {{ recordStartIndex }}-{{ recordEndIndex }} / {{ recordTotalCount }}</span>
<button class="ghost-btn small-btn" type="button" @click="nextRecordPage" :disabled="recordCurrentPage >= recordTotalPages">下一页</button>
<button class="ghost-btn small-btn" type="button" @click="goRecordPage(recordTotalPages)" :disabled="recordCurrentPage >= recordTotalPages"></button>
<button class="ghost-btn small-btn" type="button" @click="nextRecordPage"
:disabled="recordCurrentPage >= recordTotalPages">下一</button>
<button class="ghost-btn small-btn" type="button" @click="goRecordPage(recordTotalPages)"
:disabled="recordCurrentPage >= recordTotalPages">末页</button>
</div>
</section>
</div>
@@ -783,7 +835,11 @@ import {
TestAIConnection,
TestHumanHandoff,
SendWxWorkData,
LogFrontend
LogFrontend,
GetPlatformConfig,
SavePlatformConfig,
WanchuanLogin,
WanchuanGetModel
} from '../../wailsjs/go/main/App.js'
const busy = ref(false)
@@ -815,6 +871,16 @@ let identityOptionsKey = ''
let currentIdentityScopeKey = ''
let timer = null
// 万川平台相关状态
const platformBusy = ref(false)
const platformMessage = ref('')
const platformMessageType = ref('success')
const platformForm = reactive({
baseUrl: '',
username: '',
password: ''
})
const form = reactive(defaultConfig())
const sectionNavItems = [
{ id: 'listen', label: '监听策略' },
@@ -1055,8 +1121,12 @@ function defaultConfig() {
retrievalMode: 'hybrid_rerank',
embeddingIndexPath: 'config/knowledge/embedding_index.json',
embeddingModel: 'text-embedding-v4',
embeddingBaseUrl: '',
embeddingApiKey: '',
embeddingDimensions: 512,
rerankModel: 'qwen3-rerank',
rerankBaseUrl: '',
rerankApiKey: '',
recallTopK: 50,
rerankTopK: 30,
finalTopK: 8
@@ -1397,15 +1467,15 @@ function filterHandoffHumanOptions(options, query) {
}
function handoffHumanMatchScore(item, query, selectedId) {
const userId = String(item.userId || '').trim()
const userId = String(item.userId || '').trim()
if (!userId || isRobotUserId(userId)) return -1
let score = userId === selectedId ? 20 : 0
if (!query) return score
const name = String(item.name || '').toLowerCase()
const source = String(item.source || '').toLowerCase()
const accountName = String(item.sourceAccountName || '').toLowerCase()
const accountUserId = String(item.sourceAccountUserId || '').toLowerCase()
const clientId = String(item.clientId || '').toLowerCase()
const name = String(item.name || '').toLowerCase()
const source = String(item.source || '').toLowerCase()
const accountName = String(item.sourceAccountName || '').toLowerCase()
const accountUserId = String(item.sourceAccountUserId || '').toLowerCase()
const clientId = String(item.clientId || '').toLowerCase()
const userIdLower = userId.toLowerCase()
const fields = [name, userIdLower, source, accountName, accountUserId, clientId].filter(Boolean)
if (name === query || userIdLower === query) return score + 100
@@ -1911,14 +1981,29 @@ function isLikelyTextOnlyQwenModel(model) {
['turbo', 'plus', 'max', 'long', 'coder', 'math', 'instruct'].some(token => name.includes(token))
}
function isDashScopeGateway(url) {
return String(url || '').toLowerCase().includes('dashscope.aliyuncs.com')
}
function normalizeVisionModelConfig() {
if (!form.ai) return
const visionModel = String(form.ai.visionModel || '').trim()
const textModel = String(form.ai.model || '').trim()
if (!visionModel ||
(visionModel.toLowerCase() === textModel.toLowerCase() && !isVisionCapableModelName(visionModel)) ||
isLikelyTextOnlyQwenModel(visionModel)) {
form.ai.visionModel = defaultVisionModel
const visionGateway = String(form.ai.visionBaseUrl || '').trim() || String(form.ai.baseUrl || '').trim()
if (isDashScopeGateway(visionGateway)) {
// DashScope 网关:空/文本模型一律回退到专用视觉模型 qwen3-vl-plus
if (!visionModel ||
(visionModel.toLowerCase() === textModel.toLowerCase() && !isVisionCapableModelName(visionModel)) ||
isLikelyTextOnlyQwenModel(visionModel)) {
form.ai.visionModel = defaultVisionModel
}
} else if (!String(form.ai.visionBaseUrl || '').trim()) {
// 非 DashScope 且无独立视觉网关(如万川统一网关):
// 视觉模型与聊天模型相同时清空,让后端 fallbackString(visionModel, model) 动态复用聊天模型,
// 这样改聊天模型时视觉会自动跟随,不会锁死在旧值。仅保留用户在同网关上显式填的不同视觉模型。
if (visionModel.toLowerCase() === textModel.toLowerCase()) {
form.ai.visionModel = ''
}
}
}
@@ -1933,7 +2018,9 @@ function normalizeAIConfigBeforeSave() {
if (!form.ai.audioProvider) form.ai.audioProvider = 'auto'
if (!form.ai.audioMode) form.ai.audioMode = 'openai_audio_chat'
if (!form.ai.audioModel) form.ai.audioModel = 'qwen3-asr-flash'
if (!form.ai.audioBaseUrl) form.ai.audioBaseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
// 语音网关与 vision/embedding/rerank 一致audioBaseUrl 留空时不强写 DashScope
// 运行期由后端 audioRequestConfig 回退复用聊天网关。否则非 DashScope如万川会出现
// 「DashScope URL + 万川聊天 Key」的错配导致鉴权失败。用户填了独立语音网关则保留其值。
if (!['concise', 'medium', 'detailed'].includes(form.ai.replyDetail)) form.ai.replyDetail = 'detailed'
if (looksLikeUrl(form.ai.audioApiKey)) {
form.ai.audioApiKey = ''
@@ -1969,6 +2056,267 @@ async function loadConfig() {
}
}
// 加载万川平台配置
async function loadPlatformConfig() {
try {
const cfg = await GetPlatformConfig()
if (cfg) {
platformForm.baseUrl = cfg.baseUrl || ''
platformForm.username = cfg.username || ''
platformForm.password = cfg.password || ''
}
} catch (err) {
LogFrontend('warn', `加载平台配置失败: ${err.message || err}`)
}
}
// 万川平台模型编码(平台按用途用不同 code 区分)
// 知识库三件套chat/embedding/rerank注册在 preSaleInSaleAfterSale- 业务前缀下;
// vision/voice 是平台系统级共享模型,用裸 code带前缀的 preSaleInSaleAfterSale-voice 平台会返回 500「功能模型不存在」
// 一切以平台 getByCode 实际返回为准。
const WANCHUAN_CODE_CHAT = 'preSaleInSaleAfterSale-chat' // 聊天/文本模型 → form.ai
const WANCHUAN_CODE_EMBEDDING = 'preSaleInSaleAfterSale-text-embedding' // 向量模型 → form.retrieval.embeddingModel
const WANCHUAN_CODE_RERANK = 'preSaleInSaleAfterSale-rerank' // 重排模型 → form.retrieval.rerankModel
const WANCHUAN_CODE_VOICE = 'voice' // 语音模型(ASR) → form.ai.audio*(裸 codeparaformer-v2
// 图片识别模型 → form.ai.vision*。平台把视觉模型注册在裸 code「vision」下functionName=售前售后视觉),
// 带独立 endpointUrl/apiKey与聊天网关不同必须按此 code 拉取并回填,不能复用聊天模型。
// 仍按「可选」处理:万一平台某天下线该 code自动回退运行时复用聊天模型不中断主流程。
const WANCHUAN_CODE_VISION = 'vision'
// 拉取一个「可选」模型配置:失败/平台无此 code 时返回 null不中断主流程。
// code 为空表示该用途万川暂未提供独立模型,直接跳过(运行时由后端回退复用聊天网关)。
async function fetchOptionalWanchuanModel(code, label, token) {
if (!code) {
LogFrontend('info', `[万川平台] 未配置 ${label} code跳过运行时回退复用聊天网关`)
return null
}
try {
LogFrontend('info', `[万川平台] 开始获取 ${label} 模型配置 (${code})`)
const response = await WanchuanGetModel(platformForm.baseUrl, code, token)
const model = parseModelConfig(JSON.parse(response), code)
if (!model || !model.modelName) return null
LogFrontend('info', `[万川平台] ${label} 解析结果 - modelName: ${model.modelName}, endpointUrl: ${model.endpointUrl}, apiKey 长度: ${model.apiKey?.length || 0}`)
return model
} catch (err) {
LogFrontend('warn', `[万川平台] ${label} 模型获取失败(跳过,运行时回退复用聊天网关): ${err.message || err}`)
return null
}
}
// 登录并获取模型配置
async function loginAndFetchModels() {
platformBusy.value = true
// 同时锁住全局 busy避免登录拉模型的数秒内用户点别处「保存配置」等按钮
// 与本函数内部的 saveConfig 并发读写同一个 form。saveConfig 的 ownsBusy 机制会识别
// busy 已被外层置位而不重复开关,逻辑自洽。
busy.value = true
platformMessage.value = ''
platformMessageType.value = 'success'
try {
// 记录回填前的 embedding 模型/维度,回填保存后若发生变化需重建向量索引(旧向量属于不同向量空间)
const prevEmbeddingModel = String(form.retrieval?.embeddingModel || '').trim()
const prevEmbeddingDims = Number(form.retrieval?.embeddingDimensions || 0)
// 1. 先保存凭证
// Wails 把 Go 的 (bool, string) 多返回值序列化的形态不固定:可能是数组、标量 true 或对象,需归一化判断
const saveResult = await SavePlatformConfig(JSON.stringify(platformForm))
const saveOk = Array.isArray(saveResult) ? saveResult[0] : Boolean(saveResult)
const saveMsg = Array.isArray(saveResult) ? saveResult[1] : ''
if (!saveOk) {
throw new Error(saveMsg || '保存平台配置失败')
}
// 2. 登录获取 token
LogFrontend('info', `[万川平台] 开始登录 - URL: ${platformForm.baseUrl}`)
const loginResponse = await WanchuanLogin(platformForm.baseUrl, platformForm.username, platformForm.password)
const loginData = JSON.parse(loginResponse)
LogFrontend('info', `[万川平台] 登录响应: ${JSON.stringify(loginData)}`)
// 多路径提取 token
const token = loginData.token ||
loginData.data?.token ||
loginData.data?.access_token ||
loginData.access_token
if (!token) {
throw new Error('登录失败:未获取到 token')
}
LogFrontend('info', `[万川平台] 登录成功token 长度: ${token.length}`)
// 3. 获取 chat 模型(必拉)→ 回填 form.aibaseUrl/apiKey/model 作为统一网关)
LogFrontend('info', `[万川平台] 开始获取 chat 模型配置 (${WANCHUAN_CODE_CHAT})`)
const chatResponse = await WanchuanGetModel(platformForm.baseUrl, WANCHUAN_CODE_CHAT, token)
const chatData = JSON.parse(chatResponse)
LogFrontend('info', `[万川平台] chat 响应: ${JSON.stringify(chatData)}`)
const chatModel = parseModelConfig(chatData, WANCHUAN_CODE_CHAT)
if (!chatModel || !chatModel.modelName) {
throw new Error('获取聊天模型配置失败')
}
LogFrontend('info', `[万川平台] chat 解析结果 - modelName: ${chatModel.modelName}, endpointUrl: ${chatModel.endpointUrl}, apiKey 长度: ${chatModel.apiKey?.length || 0}`)
// 回填聊天模型(网关地址 + 密钥 + 模型名)
if (chatModel.endpointUrl) form.ai.baseUrl = chatModel.endpointUrl
if (chatModel.apiKey) form.ai.apiKey = chatModel.apiKey
form.ai.model = chatModel.modelName
// 图片识别模型先置空,再按 vision code 拉取覆盖:
// 平台 vision 是独立模型(独立 endpointUrl/apiKey下面 helper 拉到就显式回填。
// 万一平台下线该 codehelper 返回 null保持留空后端 visionRequestConfig 用
// fallbackString(visionModel, model) 运行时复用聊天模型,不锁死旧值。
form.ai.visionBaseUrl = ''
form.ai.visionApiKey = ''
form.ai.visionModel = ''
// 3.5 获取 vision 模型(独立 code「vision」拉到则回填独立网关拉不到回退复用 chat→ 回填 form.ai.vision*
const visionModel = await fetchOptionalWanchuanModel(WANCHUAN_CODE_VISION, 'vision', token)
if (visionModel) {
form.ai.visionModel = visionModel.modelName
form.ai.visionBaseUrl = visionModel.endpointUrl || ''
form.ai.visionApiKey = visionModel.apiKey || ''
} else {
LogFrontend('info', `[万川平台] 未获取到独立视觉模型,图片识别运行时复用聊天模型: ${chatModel.modelName}`)
}
// 4. 获取 text-embedding 模型(可选)→ 回填 form.retrieval模型名 + 独立 url/key
const embeddingModel = await fetchOptionalWanchuanModel(WANCHUAN_CODE_EMBEDDING, 'embedding', token)
if (embeddingModel) {
form.retrieval.embeddingModel = embeddingModel.modelName
form.retrieval.embeddingBaseUrl = embeddingModel.endpointUrl || ''
form.retrieval.embeddingApiKey = embeddingModel.apiKey || ''
}
// 5. 获取 rerank 模型(可选)→ 回填 form.retrieval模型名 + 独立 url/key
const rerankModel = await fetchOptionalWanchuanModel(WANCHUAN_CODE_RERANK, 'rerank', token)
if (rerankModel) {
form.retrieval.rerankModel = rerankModel.modelName
form.retrieval.rerankBaseUrl = rerankModel.endpointUrl || ''
form.retrieval.rerankApiKey = rerankModel.apiKey || ''
}
// 6. 获取 voice 模型(可选)→ 回填 form.ai.audio*(独立 url/key/model
// 先清空语音网关覆盖项:拉到了显式回填;拉不到则留空,运行时由后端 audioRequestConfig 回退复用聊天网关,
// 避免残留的 DashScope 默认 URL 与万川聊天 Key 错配。(语音模型名仍由 ApplyDefaults 兜底/缺失提示引导手动配)
form.ai.audioBaseUrl = ''
form.ai.audioApiKey = ''
const voiceModel = await fetchOptionalWanchuanModel(WANCHUAN_CODE_VOICE, 'voice', token)
if (voiceModel) {
form.ai.audioModel = voiceModel.modelName
form.ai.audioBaseUrl = voiceModel.endpointUrl || ''
form.ai.audioApiKey = voiceModel.apiKey || ''
}
console.log('[万川平台] 模型配置获取完成:')
console.log(' chat:', chatModel)
console.log(' vision:', visionModel)
console.log(' embedding:', embeddingModel)
console.log(' rerank:', rerankModel)
console.log(' voice:', voiceModel)
// 7. 落盘并通知 helper 重载saveConfig 内部调用 SaveAutoReplyConfig → /api/auto-reply/reload
const saved = await saveConfig('ai', true)
if (!saved) {
throw new Error('模型已获取但保存失败,请检查 AI 配置后重试')
}
const visionLogText = visionModel ? visionModel.modelName : `复用chat(${chatModel.modelName})`
LogFrontend('info', `[万川平台] 模型配置已回填并保存 - chat: ${chatModel.modelName}, vision: ${visionLogText}, embedding: ${embeddingModel?.modelName || '无'}, rerank: ${rerankModel?.modelName || '无'}, voice: ${voiceModel?.modelName || '无'}`)
// 7.1 若 embedding 模型/维度发生变化,磁盘旧向量属于不同向量空间,需重建索引,
// 否则向量检索会静默回退关键词。无知识库内容时重建会快速跳过,不影响。
let rebuildNote = ''
if (embeddingModel && embeddingModel.modelName) {
const newEmbeddingModel = String(form.retrieval?.embeddingModel || '').trim()
const newEmbeddingDims = Number(form.retrieval?.embeddingDimensions || 0)
const embeddingChanged =
newEmbeddingModel.toLowerCase() !== prevEmbeddingModel.toLowerCase() ||
newEmbeddingDims !== prevEmbeddingDims
if (embeddingChanged) {
try {
LogFrontend('info', `[万川平台] embedding 模型变更(${prevEmbeddingModel || '空'}${newEmbeddingModel}),开始重建向量索引`)
const rebuildResult = await RebuildKnowledgeIndex()
if (rebuildResult?.success) {
rebuildNote = ',向量索引已重建'
LogFrontend('info', '[万川平台] 向量索引重建成功')
} else {
rebuildNote = `,但向量索引重建失败(请到知识库页手动重建):${rebuildResult?.message || '未知错误'}`
LogFrontend('warn', `[万川平台] 向量索引重建失败: ${rebuildResult?.message || '未知错误'}`)
}
await loadStatus()
} catch (err) {
rebuildNote = `,但向量索引重建异常(请到知识库页手动重建):${err.message || err}`
LogFrontend('warn', `[万川平台] 向量索引重建异常: ${err.message || err}`)
}
}
}
// 提示仍需手动配置的模型(平台未提供对应 code
const missing = []
if (!embeddingModel) missing.push('向量(embedding)')
if (!rerankModel) missing.push('重排(rerank)')
if (!voiceModel) missing.push('语音(voice/ASR)')
platformMessage.value = `获取并保存成功!文本模型: ${chatModel.modelName}` +
`${visionModel ? ',图片识别: ' + visionModel.modelName : ',图片识别复用文本模型'}` +
`${embeddingModel ? ',向量: ' + embeddingModel.modelName : ''}` +
`${rerankModel ? ',重排: ' + rerankModel.modelName : ''}` +
`${voiceModel ? ',语音: ' + voiceModel.modelName : ''}` +
rebuildNote +
`${missing.length ? '。仍需手动配置:' + missing.join('、') : '。全部模型已就绪'}`
platformMessageType.value = rebuildNote.includes('失败') || rebuildNote.includes('异常') ? 'error' : 'success'
} catch (err) {
const errMsg = err.message || String(err)
LogFrontend('error', `[万川平台] 获取失败: ${errMsg}`)
platformMessage.value = `获取失败: ${errMsg}`
platformMessageType.value = 'error'
} finally {
platformBusy.value = false
busy.value = false
}
}
// 解析模型配置
function parseModelConfig(data, code) {
try {
if (!data || !data.data || !data.data.providerModels) {
throw new Error(`平台未返回模型[${code}]配置`)
}
const pm = data.data.providerModels
const modelName = pm.modelName
// encryptedConfig 是 JSON 字符串,需要二次解析
let config = {}
if (pm.encryptedConfig) {
config = JSON.parse(pm.encryptedConfig)
}
return {
modelName: modelName,
apiKey: config.apiKey || '',
endpointUrl: config.endpointUrl || ''
}
} catch (err) {
LogFrontend('error', `[万川平台] 解析模型[${code}]失败: ${err.message || err}`)
return null
}
}
// 重置:重新从平台拉取模型并覆盖回填(用于手动改过模型字段后恢复平台值)
async function resetPlatformConfig() {
if (!platformForm.baseUrl || !platformForm.username || !platformForm.password) {
platformMessage.value = '请先填写平台地址、账号和密码'
platformMessageType.value = 'error'
return
}
LogFrontend('info', '[万川平台] 重置:重新登录并拉取模型覆盖回填')
await loginAndFetchModels()
}
async function loadStatus() {
try {
const result = await GetAutoReplyStatus()
@@ -2305,6 +2653,7 @@ function scrollToSection(id) {
onMounted(async () => {
await loadConfig()
await loadPlatformConfig()
await loadStatus()
await loadIdentityOptions(true)
await loadGroupOptions(true)
@@ -2579,6 +2928,45 @@ button:disabled {
line-height: 1.45;
}
.wanchuan-platform-card {
background: #fff8e1;
border-color: #ffd54f;
}
.wanchuan-platform-card .config-subsection-header strong {
color: #f57c00;
}
/* 平台行:三个输入框 + 按钮组同排,按钮靠右并与输入框底部对齐 */
.wanchuan-form-row {
grid-template-columns: minmax(220px, 1.4fr) repeat(2, minmax(140px, 1fr)) auto;
align-items: end;
margin-bottom: 0;
}
.wanchuan-actions {
display: flex;
gap: 10px;
align-items: center;
white-space: nowrap;
}
.wanchuan-actions .ghost-btn {
flex-shrink: 0;
}
/* 窄屏回退:列数不够时按钮组换行但仍靠右 */
@media (max-width: 720px) {
.wanchuan-form-row {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.wanchuan-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
.advanced-toggle {
margin-top: 12px;
min-height: 32px;

View File

@@ -46,6 +46,8 @@ export function GetKingdeeMonitorStatus():Promise<any>;
export function GetPendingAfterSalesArchiveSummary():Promise<any>;
export function GetPlatformConfig():Promise<any>;
export function GetSystemMemoryUsage():Promise<number>;
export function GetWxWorkAccountList():Promise<any>;
@@ -90,6 +92,8 @@ export function SaveIssue(arg1:main.AfterSalesIssue):Promise<boolean|string>;
export function SaveKingdeeMonitorConfig(arg1:string):Promise<boolean|string>;
export function SavePlatformConfig(arg1:string):Promise<boolean|string>;
export function SendWxWorkData(arg1:string,arg2:string):Promise<boolean>;
export function SetAutoCollectTask(arg1:boolean):Promise<boolean|string>;
@@ -113,3 +117,7 @@ export function TestKingdeeMonitorConnection(arg1:string):Promise<any>;
export function TriggerManualCollect(arg1:string):Promise<boolean|string>;
export function UpdateAfterSalesKnowledgeCase(arg1:string,arg2:string):Promise<any>;
export function WanchuanGetModel(arg1:string,arg2:string,arg3:string):Promise<string>;
export function WanchuanLogin(arg1:string,arg2:string,arg3:string):Promise<string>;

View File

@@ -90,6 +90,10 @@ export function GetPendingAfterSalesArchiveSummary() {
return window['go']['main']['App']['GetPendingAfterSalesArchiveSummary']();
}
export function GetPlatformConfig() {
return window['go']['main']['App']['GetPlatformConfig']();
}
export function GetSystemMemoryUsage() {
return window['go']['main']['App']['GetSystemMemoryUsage']();
}
@@ -178,6 +182,10 @@ export function SaveKingdeeMonitorConfig(arg1) {
return window['go']['main']['App']['SaveKingdeeMonitorConfig'](arg1);
}
export function SavePlatformConfig(arg1) {
return window['go']['main']['App']['SavePlatformConfig'](arg1);
}
export function SendWxWorkData(arg1, arg2) {
return window['go']['main']['App']['SendWxWorkData'](arg1, arg2);
}
@@ -225,3 +233,11 @@ export function TriggerManualCollect(arg1) {
export function UpdateAfterSalesKnowledgeCase(arg1, arg2) {
return window['go']['main']['App']['UpdateAfterSalesKnowledgeCase'](arg1, arg2);
}
export function WanchuanGetModel(arg1, arg2, arg3) {
return window['go']['main']['App']['WanchuanGetModel'](arg1, arg2, arg3);
}
export function WanchuanLogin(arg1, arg2, arg3) {
return window['go']['main']['App']['WanchuanLogin'](arg1, arg2, arg3);
}

View File

@@ -1,902 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"qiweimanager/config"
)
type AIResult struct {
Answer string `json:"answer"`
RawSummary string `json:"rawSummary"`
DurationMS int64 `json:"durationMs"`
}
const (
aiPromptMaxHits = 8 // 增加到8个片段提供更多上下文
aiPromptMaxChunkRunes = 1200 // 增加到1200字保留更多细节
aiPromptMaxContextRune = 8000 // 增加到8000字支持更长的知识库内容
defaultAudioModel = "qwen3-asr-flash"
audioModeAuto = "auto"
audioModeOpenAIChat = "openai_audio_chat"
audioModeParaformer = "dashscope_paraformer"
audioModeTranscription = "local_openai_transcription"
audioModeCustomHTTP = "custom_http"
)
func (e *AutoReplyEngine) getConfig() config.AutoReplyConfig {
e.mu.Lock()
defer e.mu.Unlock()
cfg := e.config
if cfg.AI.TimeoutSeconds <= 0 {
cfg.AI.TimeoutSeconds = 20
}
if cfg.AI.MaxTokens <= 0 {
cfg.AI.MaxTokens = 700
}
if strings.TrimSpace(cfg.AI.ReplyDetail) == "" {
cfg.AI.ReplyDetail = "detailed"
}
if cfg.Knowledge.TopK <= 0 {
cfg.Knowledge.TopK = 3
}
if cfg.Knowledge.MinScore <= 0 {
cfg.Knowledge.MinScore = 0.40
}
if cfg.ReplyPolicy.UnknownAnswerToken == "" {
cfg.ReplyPolicy.UnknownAnswerToken = "NO_ANSWER"
}
return cfg
}
func (e *AutoReplyEngine) askAI(question string, hits []KnowledgeChunk, msg autoReplyMessage) (*AIResult, error) {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" {
return nil, fmt.Errorf("AI Base URL未配置")
}
if strings.TrimSpace(cfg.AI.Model) == "" {
return nil, fmt.Errorf("AI模型未配置")
}
systemPrompt := buildAutoReplySystemPrompt(cfg)
msg.ContextText = e.recentContextPrompt(msg, 6)
userPrompt := buildAutoReplyUserPrompt(question, hits, msg, cfg.ReplyPolicy.UnknownAnswerToken)
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
return callOllamaChat(cfg.AI, systemPrompt, userPrompt)
default:
return callOpenAICompatibleChat(cfg.AI, systemPrompt, userPrompt)
}
}
func (e *AutoReplyEngine) askGeneralAI(question string, msg autoReplyMessage) (*AIResult, error) {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" {
return nil, fmt.Errorf("AI Base URL未配置")
}
if strings.TrimSpace(cfg.AI.Model) == "" {
return nil, fmt.Errorf("AI模型未配置")
}
systemPrompt := buildGeneralAutoReplySystemPrompt(cfg)
msg.ContextText = e.recentContextPrompt(msg, 6)
userPrompt := buildGeneralAutoReplyUserPrompt(question, msg)
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
return callOllamaChat(cfg.AI, systemPrompt, userPrompt)
default:
return callOpenAICompatibleChat(cfg.AI, systemPrompt, userPrompt)
}
}
func (e *AutoReplyEngine) askNonTextAI(msg autoReplyMessage) (*AIResult, error) {
cfg := e.getConfig()
if strings.TrimSpace(cfg.AI.BaseURL) == "" {
return nil, fmt.Errorf("AI Base URL未配置")
}
if strings.TrimSpace(cfg.AI.Model) == "" {
return nil, fmt.Errorf("AI模型未配置")
}
systemPrompt := buildNonTextAutoReplySystemPrompt(cfg)
userPrompt := buildNonTextAutoReplyUserPrompt(msg)
switch strings.ToLower(strings.TrimSpace(cfg.AI.Provider)) {
case "local", "ollama":
return callOllamaChat(cfg.AI, systemPrompt, userPrompt)
default:
if mediaURL := strings.TrimSpace(msg.MediaURL); mediaURL != "" {
return callOpenAICompatibleVisionChat(cfg.AI, systemPrompt, userPrompt, mediaURL)
}
return callOpenAICompatibleChat(cfg.AI, systemPrompt, userPrompt)
}
}
func (e *AutoReplyEngine) testAIConnection() (*AIResult, error) {
testMsg := autoReplyMessage{
FromNickName: "测试客户",
ConversationID: "test",
}
hits := []KnowledgeChunk{{
Source: "test.md",
Content: "测试知识:自动客服连接测试时,请回复“连接正常”。",
Score: 1,
}}
return e.askAI("请回复连接正常", hits, testMsg)
}
func buildAutoReplySystemPrompt(cfg config.AutoReplyConfig) string {
token := cfg.ReplyPolicy.UnknownAnswerToken
if token == "" {
token = "NO_ANSWER"
}
return prependAISystemPrompt(cfg, "你是企业微信售后客服助手。只能根据提供的知识库片段回答客户问题。"+replyDetailInstruction(cfg)+"知识库不足以确定答案时,只输出 "+token+"。不要编造政策、价格、承诺、库存或物流时效。客户要求人工、投诉、退款、合同、发票、赔偿或价格特殊审批时,也只输出 "+token+"。")
}
func buildGeneralAutoReplySystemPrompt(cfg config.AutoReplyConfig) string {
token := cfg.ReplyPolicy.UnknownAnswerToken
if token == "" {
token = "NO_ANSWER"
}
return prependAISystemPrompt(cfg, "你是企业微信智能客服助手。请用中文自然、和蔼地回答普通问候、身份介绍和日常沟通问题。"+replyDetailInstruction(cfg)+"不要冒充真人,不要编造产品参数、价格、政策、库存、物流、合同、发票或售后结论。遇到需要公司专有资料、知识库、人工审批或无法确认的信息时,不要硬编,可以温和说明会按资料核对或请客户补充具体问题。不要输出 "+token+",除非客户明确要求停止回复。")
}
func buildNonTextAutoReplySystemPrompt(cfg config.AutoReplyConfig) string {
return prependAISystemPrompt(cfg, "你是企业微信客服岗位助手。用户发来非文本消息时,请根据消息类型和文字描述判断是否属于客服岗位可处理范围。范围内包括产品咨询、订单、售后、方案资料、使用问题、客户服务沟通;可回复时要自然、和蔼。"+replyDetailInstruction(cfg)+"不要编造图片里不存在的信息。若无法判断图片/表情内容,礼貌请客户补充文字说明。若明显超出客服岗位范围,只能回复:抱歉,你这问题超出我的岗位认知了,回答不了。不要主动转人工,除非客户明确要求人工。")
}
func buildVisionRecognitionSystemPrompt(cfg config.AutoReplyConfig) string {
return prependAISystemPrompt(cfg, "你是企业微信客服岗位的图片识别助手。请识别客户发来的图片/表情/封面中与客服沟通有关的内容,输出一句简洁中文描述;如果明显不是客服岗位可处理的内容,也请说明其大概内容。不要编造看不见的信息。")
}
func prependAISystemPrompt(cfg config.AutoReplyConfig, base string) string {
identity := strings.TrimSpace(cfg.AI.SystemPrompt)
if identity == "" {
identity = "你是一名企业微信智能客服。"
}
return identity + "\n" + base
}
func replyDetailInstruction(cfg config.AutoReplyConfig) string {
switch strings.ToLower(strings.TrimSpace(cfg.AI.ReplyDetail)) {
case "concise":
return "回复保持简洁通常1-2句约80-140个中文字符先回答结论必要时补一句下一步建议。"
case "medium":
return "回复详细程度适中通常2-4句约160-280个中文字符先回答结论再说明关键原因或注意事项最后给出下一步建议。"
default:
return "回复尽量详细但不要啰嗦通常3-6句约280-500个中文字符先明确回答客户问题再结合可用资料说明关键点、适用场景或限制最后给出具体下一步建议。"
}
}
func effectiveReplyMaxTokens(cfg config.AIConfig) int {
maxTokens := cfg.MaxTokens
switch strings.ToLower(strings.TrimSpace(cfg.ReplyDetail)) {
case "concise":
if maxTokens < 220 {
return 220
}
case "medium":
if maxTokens < 450 {
return 450
}
default:
if maxTokens < 700 {
return 700
}
}
return maxTokens
}
func buildGeneralAutoReplyUserPrompt(question string, msg autoReplyMessage) string {
var b strings.Builder
b.WriteString("客户昵称:")
if msg.FromNickName != "" {
b.WriteString(msg.FromNickName)
} else {
b.WriteString("未知")
}
b.WriteString("\n客户问题")
b.WriteString(question)
if contextText := strings.TrimSpace(msg.ContextText); contextText != "" {
b.WriteString("\n\n最近对话上下文\n")
b.WriteString(contextText)
}
b.WriteString("\n请直接给客户一条友好、可发送的回复。")
return b.String()
}
func buildNonTextAutoReplyUserPrompt(msg autoReplyMessage) string {
var b strings.Builder
b.WriteString("客户昵称:")
if msg.FromNickName != "" {
b.WriteString(msg.FromNickName)
} else {
b.WriteString("未知")
}
b.WriteString("\n消息类型")
b.WriteString(msg.MessageType)
b.WriteString("\n原始类型")
b.WriteString(fmt.Sprintf("%d", msg.RawType))
b.WriteString("\n消息描述")
if strings.TrimSpace(msg.Content) != "" {
b.WriteString(msg.Content)
} else {
b.WriteString("无文字描述")
}
if strings.TrimSpace(msg.MediaURL) != "" {
b.WriteString("\n媒体地址")
b.WriteString(msg.MediaURL)
}
b.WriteString("\n请直接给客户一条可发送的回复。")
return b.String()
}
func buildAutoReplyUserPrompt(question string, hits []KnowledgeChunk, msg autoReplyMessage, noAnswerToken string) string {
noAnswerToken = strings.TrimSpace(noAnswerToken)
if noAnswerToken == "" {
noAnswerToken = "NO_ANSWER"
}
var b strings.Builder
b.WriteString("客户昵称:")
if msg.FromNickName != "" {
b.WriteString(msg.FromNickName)
} else {
b.WriteString("未知")
}
b.WriteString("\n客户问题")
b.WriteString(question)
if contextText := strings.TrimSpace(msg.ContextText); contextText != "" {
b.WriteString("\n\n最近对话上下文\n")
b.WriteString(contextText)
}
b.WriteString("\n\n知识库片段\n")
for i, hit := range compactKnowledgeHitsForAI(hits) {
b.WriteString(fmt.Sprintf("[%d] 来源:%s 分数:%.3f\n%s\n\n", i+1, hit.Source, hit.Score, hit.Content))
}
b.WriteString("\u53ea\u80fd\u4f7f\u7528\u4e0a\u9762\u7247\u6bb5\u4e2d\u660e\u786e\u51fa\u73b0\u7684\u4e8b\u5b9e\u56de\u7b54\uff1b\u5982\u679c\u8be2\u95ee\u90e8\u95e8\u3001\u4f1a\u8bae\u65f6\u95f4\u3001\u6807\u51c6\u6216\u89c4\u5b9a\uff0c\u53ea\u80fd\u5217\u51fa\u7247\u6bb5\u91cc\u76f4\u63a5\u51fa\u73b0\u7684\u503c\uff0c\u4e0d\u5f97\u6839\u636e\u5e38\u8bc6\u8865\u5145\u5176\u4ed6\u90e8\u95e8\u6216\u65f6\u95f4\u3002\n")
if isGenericProductQuery(question) {
b.WriteString("客户在泛问产品时请优先按知识库列出具体产品或型号每项用一句话说明定位最后询问客户更关注硬件、模型还是AI应用。不要只概括为几大类。无法确认时只输出 ")
} else {
b.WriteString("请基于知识库片段回答客户。无法确认时只输出 ")
}
b.WriteString(noAnswerToken)
b.WriteString("。")
return b.String()
}
func compactKnowledgeHitsForAI(hits []KnowledgeChunk) []KnowledgeChunk {
if len(hits) == 0 {
return nil
}
limit := aiPromptMaxHits
if len(hits) < limit {
limit = len(hits)
}
result := make([]KnowledgeChunk, 0, limit)
totalRunes := 0
for i := 0; i < limit; i++ {
hit := hits[i]
content := strings.TrimSpace(hit.Content)
if content == "" {
continue
}
content = truncateTextForPrompt(content, aiPromptMaxChunkRunes)
remaining := aiPromptMaxContextRune - totalRunes
if remaining <= 0 {
break
}
if len([]rune(content)) > remaining {
content = truncateTextForPrompt(content, remaining)
}
hit.Content = content
totalRunes += len([]rune(content))
result = append(result, hit)
}
return result
}
func truncateTextForPrompt(text string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(text)
if len(runes) <= max {
return text
}
return string(runes[:max])
}
func callOpenAICompatibleChat(cfg config.AIConfig, systemPrompt string, userPrompt string) (*AIResult, error) {
url := strings.TrimRight(cfg.BaseURL, "/")
if !strings.HasSuffix(url, "/chat/completions") {
url += "/chat/completions"
}
payload := map[string]interface{}{
"model": cfg.Model,
"temperature": cfg.Temperature,
"max_tokens": effectiveReplyMaxTokens(cfg),
"enable_thinking": cfg.EnableThinking,
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
}
var response struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error interface{} `json:"error"`
}
result, err := doAIJSONRequest(cfg, url, payload, &response)
if err != nil {
return nil, err
}
if response.Error != nil {
return nil, fmt.Errorf("AI返回错误: %v", response.Error)
}
if len(response.Choices) == 0 {
return nil, fmt.Errorf("AI返回空choices")
}
answer := strings.TrimSpace(response.Choices[0].Message.Content)
result.Answer = answer
result.RawSummary = truncateText(answer, 160)
return result, nil
}
func callOpenAICompatibleVisionChat(cfg config.AIConfig, systemPrompt string, userPrompt string, imageURL string) (*AIResult, error) {
visionCfg := visionRequestConfig(cfg)
url := strings.TrimRight(visionCfg.BaseURL, "/")
if !strings.HasSuffix(url, "/chat/completions") {
url += "/chat/completions"
}
payload := map[string]interface{}{
"model": visionCfg.Model,
"temperature": visionCfg.Temperature,
"max_tokens": visionCfg.MaxTokens,
"enable_thinking": visionCfg.EnableThinking,
"messages": []map[string]interface{}{
{"role": "system", "content": systemPrompt},
{
"role": "user",
"content": []map[string]interface{}{
{"type": "text", "text": userPrompt},
{"type": "image_url", "image_url": map[string]string{"url": imageURL}},
},
},
},
}
var response struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error interface{} `json:"error"`
}
result, err := doAIJSONRequest(visionCfg, url, payload, &response)
if err != nil {
return nil, err
}
if response.Error != nil {
return nil, fmt.Errorf("AI返回错误: %v", response.Error)
}
if len(response.Choices) == 0 {
return nil, fmt.Errorf("AI返回空choices")
}
answer := strings.TrimSpace(response.Choices[0].Message.Content)
result.Answer = answer
result.RawSummary = truncateText(answer, 160)
return result, nil
}
func visionRequestConfig(cfg config.AIConfig) config.AIConfig {
visionCfg := cfg
visionCfg.Model = fallbackString(cfg.VisionModel, cfg.Model)
if strings.TrimSpace(cfg.VisionBaseURL) != "" {
visionCfg.BaseURL = strings.TrimSpace(cfg.VisionBaseURL)
}
visionKey := strings.TrimSpace(cfg.VisionAPIKey)
if visionKey != "" && !looksLikeURL(visionKey) {
visionCfg.APIKey = visionKey
}
return visionCfg
}
func callOpenAICompatibleAudioChatTranscription(cfg config.AIConfig, audioPath string) (string, error) {
audioCfg := audioRequestConfig(cfg)
audioDataURL, err := audioDataURLFromFile(audioPath)
if err != nil {
return "", err
}
url := strings.TrimRight(audioCfg.BaseURL, "/")
if !strings.HasSuffix(url, "/chat/completions") {
url += "/chat/completions"
}
model := fallbackString(audioCfg.Model, defaultAudioModel)
payload := map[string]interface{}{
"model": model,
"temperature": 0,
"max_tokens": audioCfg.MaxTokens,
"enable_thinking": false,
"messages": []map[string]interface{}{
{
"role": "user",
"content": audioChatContentForModel(model, audioDataURL),
},
},
}
var response struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error interface{} `json:"error"`
}
if _, err := doAIJSONRequest(audioCfg, url, payload, &response); err != nil {
return "", fmt.Errorf("audio chat transcription failed (model=%s endpoint=%s): %w", audioCfg.Model, url, err)
}
if response.Error != nil {
return "", fmt.Errorf("audio chat transcription failed (model=%s endpoint=%s): %v", audioCfg.Model, url, response.Error)
}
if len(response.Choices) == 0 {
return "", fmt.Errorf("audio chat transcription failed (model=%s endpoint=%s): empty choices", audioCfg.Model, url)
}
text := strings.TrimSpace(response.Choices[0].Message.Content)
if text == "" {
return "", fmt.Errorf("audio chat transcription failed (model=%s endpoint=%s): empty text", audioCfg.Model, url)
}
return text, nil
}
func audioChatContentForModel(model string, audioDataURL string) []map[string]interface{} {
if isQwenASRModel(model) {
return []map[string]interface{}{
{"type": "input_audio", "input_audio": audioDataURL},
}
}
return []map[string]interface{}{
{"type": "text", "text": "请把这段语音转写成简体中文文本,只输出转写内容,不要解释。"},
{"type": "input_audio", "input_audio": map[string]interface{}{"data": audioDataURL}},
}
}
func isQwenASRModel(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
return strings.HasPrefix(name, "qwen3-asr") || strings.HasPrefix(name, "qwen-asr")
}
func audioRequestConfig(cfg config.AIConfig) config.AIConfig {
audioCfg := cfg
audioCfg.Model = fallbackString(cfg.AudioModel, defaultAudioModel)
if strings.TrimSpace(cfg.AudioBaseURL) != "" {
audioCfg.BaseURL = strings.TrimSpace(cfg.AudioBaseURL)
}
audioKey := strings.TrimSpace(cfg.AudioAPIKey)
if audioKey != "" && !looksLikeURL(audioKey) {
audioCfg.APIKey = audioKey
}
audioCfg.EnableThinking = false
audioCfg.Temperature = 0
return audioCfg
}
func audioConfigWarning(cfg config.AIConfig) string {
if looksLikeURL(strings.TrimSpace(cfg.AudioAPIKey)) {
return "语音 API Key 误填为 URL已忽略该值并复用主 API Key"
}
return ""
}
func inferAudioMode(cfg config.AIConfig) string {
mode := normalizeAudioMode(cfg.AudioMode)
if mode != audioModeAuto {
return mode
}
provider := normalizeAudioMode(cfg.AudioProvider)
if provider != audioModeAuto {
return provider
}
model := strings.ToLower(strings.TrimSpace(cfg.AudioModel))
if strings.HasPrefix(model, "paraformer") {
return audioModeParaformer
}
if strings.Contains(model, "whisper") || strings.Contains(model, "transcribe") {
return audioModeTranscription
}
return audioModeOpenAIChat
}
func normalizeAudioMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", audioModeAuto:
return audioModeAuto
case "openai", "openai_chat", "audio_chat", "qwen_audio", "qwen3_asr", audioModeOpenAIChat:
return audioModeOpenAIChat
case "dashscope", "paraformer", audioModeParaformer:
return audioModeParaformer
case "transcription", "openai_transcription", "local", "local_asr", audioModeTranscription:
return audioModeTranscription
case "custom", audioModeCustomHTTP:
return audioModeCustomHTTP
default:
return audioModeAuto
}
}
func looksLikeURL(value string) bool {
value = strings.TrimSpace(value)
return strings.HasPrefix(strings.ToLower(value), "http://") || strings.HasPrefix(strings.ToLower(value), "https://")
}
func supportsSilkDirectly(cfg config.AIConfig) bool {
model := strings.ToLower(strings.TrimSpace(cfg.AudioModel))
mode := inferAudioMode(cfg)
if mode == audioModeParaformer || mode == audioModeTranscription || mode == audioModeCustomHTTP {
return false
}
return strings.Contains(model, "silk")
}
func dashScopeAPIBaseURL(cfg config.AIConfig) string {
base := strings.TrimSpace(cfg.AudioBaseURL)
if base == "" {
base = strings.TrimSpace(cfg.BaseURL)
}
if base == "" || strings.Contains(base, "/compatible-mode/") {
return "https://dashscope.aliyuncs.com/api/v1"
}
base = strings.TrimRight(base, "/")
if strings.HasSuffix(base, "/services/audio/asr/transcription") {
return strings.TrimSuffix(base, "/services/audio/asr/transcription")
}
if strings.Contains(base, "/api/v1/") {
return strings.Split(base, "/api/v1/")[0] + "/api/v1"
}
if strings.HasSuffix(base, "/api/v1") {
return base
}
return base
}
func callOpenAICompatibleAudioTranscription(cfg config.AIConfig, audioPath string) (string, error) {
cfg = audioRequestConfig(cfg)
url := strings.TrimRight(cfg.BaseURL, "/")
if !strings.HasSuffix(url, "/audio/transcriptions") {
url += "/audio/transcriptions"
}
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 20 * time.Second
}
file, err := os.Open(audioPath)
if err != nil {
return "", err
}
defer file.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
if err := writer.WriteField("model", cfg.Model); err != nil {
return "", err
}
part, err := writer.CreateFormFile("file", filepath.Base(audioPath))
if err != nil {
return "", err
}
if _, err := io.Copy(part, file); err != nil {
return "", err
}
if err := writer.Close(); err != nil {
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", url, body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
if strings.TrimSpace(cfg.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.APIKey))
}
resp, err := (&http.Client{Timeout: timeout}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("audio transcription failed (model=%s endpoint=%s): HTTP status %d, body=%s", cfg.Model, url, resp.StatusCode, truncateText(string(respBody), 240))
}
var parsed struct {
Text string `json:"text"`
Error interface{} `json:"error"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return "", fmt.Errorf("parse audio transcription failed (model=%s endpoint=%s): %v, body=%s", cfg.Model, url, err, truncateText(string(respBody), 240))
}
if parsed.Error != nil {
return "", fmt.Errorf("audio transcription failed (model=%s endpoint=%s): %v", cfg.Model, url, parsed.Error)
}
text := strings.TrimSpace(parsed.Text)
if text == "" {
return "", fmt.Errorf("audio transcription failed (model=%s endpoint=%s): empty text", cfg.Model, url)
}
return text, nil
}
func callDashScopeParaformerTranscription(cfg config.AIConfig, fileURL string) (string, error) {
cfg = audioRequestConfig(cfg)
fileURL = strings.TrimSpace(fileURL)
if fileURL == "" {
return "", fmt.Errorf("paraformer transcription failed (model=%s): 需要公网可访问的音频 URL本地文件不能直接提交给 Paraformer RESTful 接口", cfg.Model)
}
parsedURL, err := url.Parse(fileURL)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https" && parsedURL.Scheme != "oss") {
return "", fmt.Errorf("paraformer transcription failed (model=%s): 音频 URL 无效", cfg.Model)
}
base := dashScopeAPIBaseURL(cfg)
submitURL := strings.TrimRight(base, "/") + "/services/audio/asr/transcription"
payload := map[string]interface{}{
"model": fallbackString(cfg.Model, "paraformer-v2"),
"input": map[string]interface{}{
"file_urls": []string{fileURL},
},
"parameters": map[string]interface{}{
"channel_id": []int{0},
"language_hints": []string{"zh", "en"},
},
}
var submitResp struct {
Output struct {
TaskID string `json:"task_id"`
TaskStatus string `json:"task_status"`
} `json:"output"`
Code string `json:"code"`
Message string `json:"message"`
}
if err := doDashScopeJSONRequest(cfg, submitURL, "POST", payload, true, &submitResp); err != nil {
return "", fmt.Errorf("paraformer transcription submit failed (model=%s endpoint=%s): %w", cfg.Model, submitURL, err)
}
if submitResp.Code != "" || submitResp.Message != "" {
return "", fmt.Errorf("paraformer transcription submit failed (model=%s endpoint=%s): %s %s", cfg.Model, submitURL, submitResp.Code, submitResp.Message)
}
taskID := strings.TrimSpace(submitResp.Output.TaskID)
if taskID == "" {
return "", fmt.Errorf("paraformer transcription submit failed (model=%s endpoint=%s): empty task_id", cfg.Model, submitURL)
}
return waitDashScopeParaformerTask(cfg, base, taskID)
}
func waitDashScopeParaformerTask(cfg config.AIConfig, base string, taskID string) (string, error) {
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 20 * time.Second
}
deadline := time.Now().Add(timeout)
queryURL := strings.TrimRight(base, "/") + "/tasks/" + url.PathEscape(taskID)
var lastStatus string
for time.Now().Before(deadline) {
var queryResp struct {
Output struct {
TaskStatus string `json:"task_status"`
Results []struct {
FileURL string `json:"file_url"`
TranscriptionURL string `json:"transcription_url"`
SubtaskStatus string `json:"subtask_status"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"results"`
} `json:"output"`
Code string `json:"code"`
Message string `json:"message"`
}
if err := doDashScopeJSONRequest(cfg, queryURL, "GET", nil, false, &queryResp); err != nil {
return "", fmt.Errorf("paraformer transcription query failed (model=%s endpoint=%s task=%s): %w", cfg.Model, queryURL, taskID, err)
}
if queryResp.Code != "" || queryResp.Message != "" {
return "", fmt.Errorf("paraformer transcription query failed (model=%s endpoint=%s task=%s): %s %s", cfg.Model, queryURL, taskID, queryResp.Code, queryResp.Message)
}
lastStatus = strings.ToUpper(strings.TrimSpace(queryResp.Output.TaskStatus))
switch lastStatus {
case "SUCCEEDED":
for _, result := range queryResp.Output.Results {
if strings.EqualFold(result.SubtaskStatus, "SUCCEEDED") && strings.TrimSpace(result.TranscriptionURL) != "" {
return downloadDashScopeTranscriptionResult(cfg, result.TranscriptionURL)
}
if result.Code != "" || result.Message != "" {
return "", fmt.Errorf("paraformer transcription subtask failed (model=%s task=%s): %s %s", cfg.Model, taskID, result.Code, result.Message)
}
}
return "", fmt.Errorf("paraformer transcription finished without usable result (model=%s task=%s)", cfg.Model, taskID)
case "FAILED", "CANCELED", "UNKNOWN":
return "", fmt.Errorf("paraformer transcription task failed (model=%s task=%s status=%s)", cfg.Model, taskID, lastStatus)
}
time.Sleep(500 * time.Millisecond)
}
return "", fmt.Errorf("paraformer transcription timed out (model=%s task=%s last_status=%s)", cfg.Model, taskID, lastStatus)
}
func downloadDashScopeTranscriptionResult(cfg config.AIConfig, resultURL string) (string, error) {
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 20 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", resultURL, nil)
if err != nil {
return "", err
}
resp, err := (&http.Client{Timeout: timeout}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("download paraformer result failed: HTTP status %d, body=%s", resp.StatusCode, truncateText(string(respBody), 240))
}
var parsed struct {
Transcripts []struct {
Text string `json:"text"`
} `json:"transcripts"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return "", fmt.Errorf("parse paraformer result failed: %v, body=%s", err, truncateText(string(respBody), 240))
}
parts := make([]string, 0, len(parsed.Transcripts))
for _, transcript := range parsed.Transcripts {
if text := strings.TrimSpace(transcript.Text); text != "" {
parts = append(parts, text)
}
}
text := strings.TrimSpace(strings.Join(parts, "\n"))
if text == "" {
return "", fmt.Errorf("paraformer result returned empty text")
}
return text, nil
}
func doDashScopeJSONRequest(cfg config.AIConfig, endpoint string, method string, payload interface{}, async bool, out interface{}) error {
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 20 * time.Second
}
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewBuffer(data)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if async {
req.Header.Set("X-DashScope-Async", "enable")
}
if strings.TrimSpace(cfg.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.APIKey))
}
resp, err := (&http.Client{Timeout: timeout}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP status %d, body=%s", resp.StatusCode, truncateText(string(respBody), 240))
}
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("parse response failed: %v, body=%s", err, truncateText(string(respBody), 240))
}
return nil
}
func callOllamaChat(cfg config.AIConfig, systemPrompt string, userPrompt string) (*AIResult, error) {
url := strings.TrimRight(cfg.BaseURL, "/")
if !strings.HasSuffix(url, "/api/chat") {
url += "/api/chat"
}
payload := map[string]interface{}{
"model": cfg.Model,
"stream": false,
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
"options": map[string]interface{}{
"temperature": cfg.Temperature,
"num_predict": effectiveReplyMaxTokens(cfg),
},
}
var response struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Response string `json:"response"`
Error string `json:"error"`
}
result, err := doAIJSONRequest(cfg, url, payload, &response)
if err != nil {
return nil, err
}
if response.Error != "" {
return nil, fmt.Errorf("本地模型返回错误: %s", response.Error)
}
answer := strings.TrimSpace(response.Message.Content)
if answer == "" {
answer = strings.TrimSpace(response.Response)
}
if answer == "" {
return nil, fmt.Errorf("本地模型返回空内容")
}
result.Answer = answer
result.RawSummary = truncateText(answer, 160)
return result, nil
}
func doAIJSONRequest(cfg config.AIConfig, url string, payload interface{}, out interface{}) (*AIResult, error) {
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 20 * time.Second
}
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(cfg.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.APIKey))
}
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("AI HTTP状态码错误: %d, body=%s", resp.StatusCode, truncateText(string(respBody), 240))
}
if err := json.Unmarshal(respBody, out); err != nil {
return nil, fmt.Errorf("解析AI响应失败: %v, body=%s", err, truncateText(string(respBody), 240))
}
return &AIResult{DurationMS: time.Since(start).Milliseconds()}, nil
}

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

View File

@@ -3871,3 +3871,46 @@ func TestLongKnowledgeQueryFindsLateSectionByChinesePhrase(t *testing.T) {
t.Fatalf("expected neighbor section to be included, got %s", text)
}
}
// 磁盘索引与当前配置不一致(含旧版本无模型名的情况)应判为陈旧,触发清空向量回退关键词。
func TestEmbeddingIndexStaleReason(t *testing.T) {
cases := []struct {
name string
idx EmbeddingIndex
retrieval config.RetrievalConfig
wantStale bool
}{
{
name: "same model and dims",
idx: EmbeddingIndex{Model: "text-embedding-v4", Dimensions: 512},
retrieval: config.RetrievalConfig{EmbeddingModel: "text-embedding-v4", EmbeddingDimensions: 512},
wantStale: false,
},
{
name: "different model",
idx: EmbeddingIndex{Model: "text-embedding-v4", Dimensions: 512},
retrieval: config.RetrievalConfig{EmbeddingModel: "wanchuan-embed", EmbeddingDimensions: 512},
wantStale: true,
},
{
name: "legacy index without model name",
idx: EmbeddingIndex{Model: "", Dimensions: 0},
retrieval: config.RetrievalConfig{EmbeddingModel: "wanchuan-embed", EmbeddingDimensions: 1024},
wantStale: true,
},
{
name: "different dimensions same model",
idx: EmbeddingIndex{Model: "text-embedding-v4", Dimensions: 512},
retrieval: config.RetrievalConfig{EmbeddingModel: "text-embedding-v4", EmbeddingDimensions: 1024},
wantStale: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reason := embeddingIndexStaleReason(tc.idx, tc.retrieval)
if (reason != "") != tc.wantStale {
t.Fatalf("embeddingIndexStaleReason() = %q, wantStale=%v", reason, tc.wantStale)
}
})
}
}

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)
}
}
}