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

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