feat(api): 添加万川平台模型配置获取和同步功能

- 新增 getWanchuanModelConfig 函数,按模型编码获取平台模型配置
- 新增 syncWanchuanModelToSettings 函数,从万川平台拉取模型配置并写入后端 AI 设置
- 支持按用途分多个模型编码(generic/vision/voice)分别同步配置
- 配置失败时跳过对应字段,不影响其他模型同步

feat(settings): 重构AI模型配置界面支持多模块分组

- 将AI配置按话题分析、报告生成、视觉、语音四个模块分组展示
- 每个模块独立配置接口地址、密钥和模型名称
- 添加从万川平台获取配置的按钮和同步功能
- 优化配置状态指示和错误提示信息

refactor(config): 扩展AI配置支持独立的语音视觉报告网关

- 新增 voice_base_url/voice_api_key 配置项
- 新增 vision_base_url/vision_api_key 配置项
- 新增 summary_base_url/summary_api_key 配置项
- 留空时回退到 ai_base_url/ai_api_key 兼容单网关场景

refactor(http): 统一使用共享HTTP客户端减少连接开销

- 替换各处 httpx.AsyncClient 为 shared_client
- 在 lifespan 中正确关闭共享客户端资源
- 优化 get_current_wxid 和 health 检查中的HTTP请求

refactor(ai): 按用途缓存AI客户端支持不同网关配置

- 重构 get_openai_client 支持按(base_url, api_key)缓存
- 新增 get_client_for 函数按用途获取对应客户端
- 支持语音、视觉、报告等不同用途使用独立网关和密钥
```
This commit is contained in:
2026-06-24 20:34:10 +08:00
parent eecbe4172e
commit 646efa132e
18 changed files with 839 additions and 238 deletions

View File

@@ -99,6 +99,112 @@ export async function uploadToKnowledgeBase(baseUrl, datasetId, file, secretLeve
return resp.json()
}
/**
* 按模型编码code获取平台模型配置
* 接口GET ${baseUrl}/api/system/model/getByCode/{code}
* 返回结构data.providerModels.{ modelName, encryptedConfig:"{apiKey,endpointUrl}" }
* @param {string} baseUrl - 平台地址
* @param {string} code - 模型编码,默认 generic通用模型
* @returns {Promise<{modelName: string, apiKey: string, endpointUrl: string}>}
*/
export async function getWanchuanModelConfig(baseUrl, code = 'generic') {
const resp = await fetch(`${baseUrl}/api/system/model/getByCode/${encodeURIComponent(code)}`, {
method: 'GET',
headers: authHeaders(baseUrl),
})
if (!resp.ok) throw new Error(`获取模型[${code}]失败 HTTP ${resp.status}`)
const result = await resp.json()
const pm = result?.data?.providerModels
if (!pm) throw new Error(`平台未返回模型[${code}]配置${result?.msg ? `${result.msg}` : ''}`)
// encryptedConfig 是 JSON 字符串:{ apiKey, endpointUrl, ... }
let cfg = {}
if (pm.encryptedConfig) {
try { cfg = JSON.parse(pm.encryptedConfig) } catch { cfg = {} }
}
return {
modelName: pm.modelName || '',
apiKey: cfg.apiKey || '',
endpointUrl: cfg.endpointUrl || '',
}
}
/**
* 从万川平台拉取模型配置并写入后端 AI 设置(/api/settings
*
* 平台按用途分多个模型编码code
* - generic通用聊天模型 → 话题分析(ai_model) + 报告生成(summary_model)
* 并提供共享的接口地址(ai_base_url) 与密钥(ai_api_key)
* - vision :视觉模型 → vision_model
* - voice :语音模型 → voice_model
*
* 仅写入拉取到的非空字段vision/voice 拉取失败时跳过对应字段(不影响聊天模型同步),
* 避免某个 code 在平台未配置时把整次同步打断或清空已有配置。
*
* 后端 voice/vision 各有独立的 base_url / api_key / model为空时后端回退到
* generic 的 ai_base_url / ai_api_key。这里按各自 code 分别拉取并写入对应字段。
*
* @param {string} [baseUrl] - 平台地址,缺省时从后端万川配置读取
* @param {{chat?: string, vision?: string, voice?: string}} [codes] - 各用途模型编码
* @returns {Promise<object>} 实际写入 /api/settings 的字段
*/
export async function syncWanchuanModelToSettings(baseUrl, codes = {}) {
const { chat = 'generic', vision = 'vision', voice = 'voice' } = codes
let url = baseUrl
if (!url) {
const cfg = await getWanchuanConfig()
url = cfg.platformUrl
}
if (!url) throw new Error('未配置平台地址')
// 通用聊天模型:必拉,提供共享地址/密钥与两个聊天模型。
// 话题分析用 ai_*;报告生成单独存 summary_*(同 generic 网关,便于各组独立回显与覆盖)。
const generic = await getWanchuanModelConfig(url, chat)
/** @type {Record<string, string>} */
const payload = {}
if (generic.endpointUrl) {
payload.ai_base_url = generic.endpointUrl
payload.summary_base_url = generic.endpointUrl
}
if (generic.apiKey) {
payload.ai_api_key = generic.apiKey
payload.summary_api_key = generic.apiKey
}
if (generic.modelName) {
payload.ai_model = generic.modelName
payload.summary_model = generic.modelName
}
// 视觉 / 语音模型:可选,各自按 code 拉取独立地址/密钥/模型名;失败则跳过
await Promise.all([
getWanchuanModelConfig(url, vision)
.then(v => {
if (v.endpointUrl) payload.vision_base_url = v.endpointUrl
if (v.apiKey) payload.vision_api_key = v.apiKey
if (v.modelName) payload.vision_model = v.modelName
})
.catch(() => {}),
getWanchuanModelConfig(url, voice)
.then(v => {
if (v.endpointUrl) payload.voice_base_url = v.endpointUrl
if (v.apiKey) payload.voice_api_key = v.apiKey
if (v.modelName) payload.voice_model = v.modelName
})
.catch(() => {}),
])
if (Object.keys(payload).length === 0) throw new Error('平台模型配置为空')
const resp = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!resp.ok) throw new Error('保存 AI 设置失败')
return payload
}
/**
* 清除缓存的 token
*/

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { Copy, Check, Save, Link2, Loader, AlertCircle, ChevronDown, ChevronRight, RefreshCw, BookOpen, ExternalLink, FileText } from 'lucide-react'
import { wanchuanLogin, getWanchuanKnowledgeBases, uploadToKnowledgeBase, clearWanchuanToken, getWanchuanConfig, saveWanchuanConfig } from '../api/wanchuan'
import { wanchuanLogin, getWanchuanKnowledgeBases, uploadToKnowledgeBase, clearWanchuanToken, getWanchuanConfig, saveWanchuanConfig, syncWanchuanModelToSettings } from '../api/wanchuan'
const CONFIG_ITEMS = [
{
@@ -27,13 +27,41 @@ const CONFIG_ITEMS = [
},
]
const AI_FIELDS = [
{ key: 'ai_base_url', label: 'AI 接口地址', placeholder: 'https://dashscope.aliyuncs.com/compatible-mode/v1', desc: '兼容 OpenAI 格式的 API 地址' },
{ key: 'ai_api_key', label: 'AI API Key', placeholder: 'sk-...', desc: '留空则 AI 功能不可用', type: 'password' },
{ key: 'ai_model', label: '话题分析模型', placeholder: 'qwen-plus', desc: '用于消息分类的模型' },
{ key: 'summary_model', label: '报告生成模型', placeholder: 'qwen-max', desc: '用于生成售后报告的模型' },
{ key: 'vision_model', label: '视觉模型', placeholder: 'qwen-vl-plus', desc: '用于图片/视频描述' },
{ key: 'voice_model', label: '语音模型', placeholder: 'paraformer-v2', desc: '用于语音转文字' },
// 按模块分组:每个模块独立展示自己的接口地址 + 密钥 + 模型。
// 话题分析用 ai_*(也是视觉/语音/报告的回退网关);报告生成用 summary_*。
const AI_GROUPS = [
{
group: '话题分析模型',
fields: [
{ key: 'ai_base_url', label: '接口地址', placeholder: 'https://dashscope.aliyuncs.com/compatible-mode/v1', desc: '兼容 OpenAI 格式的 API 地址' },
{ key: 'ai_api_key', label: '密钥', placeholder: 'sk-...', desc: '留空则 AI 功能不可用', type: 'password' },
{ key: 'ai_model', label: '模型', placeholder: 'qwen-plus', desc: '用于消息分类的模型' },
],
},
{
group: '报告生成模型',
fields: [
{ key: 'summary_base_url', label: '接口地址', placeholder: '留空则用话题分析地址', desc: '独立网关;留空回退话题分析接口地址' },
{ key: 'summary_api_key', label: '密钥', placeholder: '留空则用话题分析密钥', desc: '留空回退话题分析密钥', type: 'password' },
{ key: 'summary_model', label: '模型', placeholder: 'qwen-max', desc: '用于生成售后报告的模型' },
],
},
{
group: '视觉模型(图片 / 视频描述)',
fields: [
{ key: 'vision_base_url', label: '接口地址', placeholder: '留空则用话题分析地址', desc: '独立网关;留空回退话题分析接口地址' },
{ key: 'vision_api_key', label: '密钥', placeholder: '留空则用话题分析密钥', desc: '留空回退话题分析密钥', type: 'password' },
{ key: 'vision_model', label: '模型', placeholder: 'qwen-vl-plus', desc: '用于图片/视频描述' },
],
},
{
group: '语音模型(语音转文字)',
fields: [
{ key: 'voice_base_url', label: '接口地址', placeholder: '留空则用话题分析地址', desc: '异步 ASR 网关,填到 .../api/v1留空回退话题分析地址' },
{ key: 'voice_api_key', label: '密钥', placeholder: '留空则用话题分析密钥', desc: '留空回退话题分析密钥', type: 'password' },
{ key: 'voice_model', label: '模型', placeholder: 'paraformer-v2', desc: '用于语音转文字' },
],
},
]
const TOPIC_PROMPT_PLACEHOLDER = '例如:本群主要是某类设备售后群,请优先按设备部件、故障现象、处理进度来拆分话题;不要按客户名或日期拆分。'
@@ -57,17 +85,37 @@ function CopyButton({ text }) {
)
}
function AISettingsForm() {
function AISettingsForm({ refreshKey = 0 }) {
const [form, setForm] = useState({})
const [saving, setSaving] = useState(false)
const [syncing, setSyncing] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => {
const loadSettings = () =>
fetch('/api/settings')
.then(r => r.json())
.then(data => setForm(data))
.catch(() => {})
}, [])
// refreshKey 由父级在万川平台登录/同步后递增,触发重新读取已写入的 AI 配置
useEffect(() => {
loadSettings()
}, [refreshKey])
// 手动从万川平台拉取模型配置base url / api key / 各模型),写入后端并回显
const handleSyncFromPlatform = async () => {
setSyncing(true)
setMsg('')
try {
await syncWanchuanModelToSettings()
await loadSettings()
setMsg('已从平台获取')
setTimeout(() => setMsg(''), 2000)
} catch (e) {
setMsg(e.message || '获取失败')
}
setSyncing(false)
}
const handleChange = (key, value) => {
setForm(prev => ({ ...prev, [key]: value }))
@@ -102,7 +150,7 @@ function AISettingsForm() {
AI 模型配置
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 12 }}>
首次使用请填入你的 API Key 和接口地址保存后立即生效无需重启服务
已对接万川平台时登录后会自动从平台获取模型配置也可点从平台获取手动同步字段支持手动修改并保存
</div>
{/* 未配置 API Key 时显示橙色警告横条 */}
@@ -117,50 +165,55 @@ function AISettingsForm() {
</div>
)}
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{AI_FIELDS.map((field, i) => (
<div
key={field.key}
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: i < AI_FIELDS.length - 1 ? '1px solid var(--border)' : 'none',
gap: 12,
}}
>
<div style={{ flex: '0 0 130px' }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{field.label}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{field.desc}</div>
</div>
<input
type={field.type || 'text'}
value={form[field.key] || ''}
placeholder={field.placeholder}
onChange={(e) => handleChange(field.key, e.target.value)}
style={{
flex: 1,
fontSize: 13,
padding: '7px 12px',
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--surface-2)',
color: 'var(--text)',
outline: 'none',
}}
/>
{/* 配置状态指示点:绿色=已配置,红色=未配置 */}
<div
title={form[field.key] ? '已配置' : '未配置'}
style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: form[field.key] ? 'var(--success, #10b981)' : '#ef4444',
boxShadow: form[field.key] ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)',
}}
/>
{AI_GROUPS.map((g) => (
<div key={g.group} style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)', marginBottom: 6 }}>{g.group}</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{g.fields.map((field, i) => (
<div
key={field.key}
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: i < g.fields.length - 1 ? '1px solid var(--border)' : 'none',
gap: 12,
}}
>
<div style={{ flex: '0 0 130px' }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{field.label}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{field.desc}</div>
</div>
<input
type={field.type || 'text'}
value={form[field.key] || ''}
placeholder={field.placeholder}
onChange={(e) => handleChange(field.key, e.target.value)}
style={{
flex: 1,
fontSize: 13,
padding: '7px 12px',
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--surface-2)',
color: 'var(--text)',
outline: 'none',
}}
/>
{/* 配置状态指示点:绿色=已配置,红色=未配置 */}
<div
title={form[field.key] ? '已配置' : '未配置'}
style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: form[field.key] ? 'var(--success, #10b981)' : '#ef4444',
boxShadow: form[field.key] ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)',
}}
/>
</div>
))}
</div>
))}
</div>
</div>
))}
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>AI 话题分析提示词</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>
@@ -207,7 +260,27 @@ function AISettingsForm() {
<Save size={14} />
{saving ? '保存中...' : '保存配置'}
</button>
{msg && <span style={{ fontSize: 12, color: msg === '已保存' ? 'var(--success, #10b981)' : '#ef4444' }}>{msg}</span>}
<button
onClick={handleSyncFromPlatform}
disabled={syncing}
title="从已对接的万川平台拉取模型配置并填入"
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 16px',
background: 'transparent',
color: 'var(--accent, #6366f1)',
border: '1px solid var(--accent, #6366f1)',
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
cursor: syncing ? 'not-allowed' : 'pointer',
opacity: syncing ? 0.6 : 1,
}}
>
{syncing ? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <RefreshCw size={14} />}
{syncing ? '获取中...' : '从平台获取'}
</button>
{msg && <span style={{ fontSize: 12, color: (msg === '已保存' || msg === '已从平台获取') ? 'var(--success, #10b981)' : '#ef4444' }}>{msg}</span>}
</div>
</div>
)
@@ -217,7 +290,7 @@ function AISettingsForm() {
* 万川 AI 平台对接配置
* 支持输入平台地址 + 账号密码,测试连接后可展示平台模块内容(预留)
*/
function WanchuanPlatformForm() {
function WanchuanPlatformForm({ onModelSynced }) {
const [form, setForm] = useState({ platformUrl: '', username: '', password: '' })
const [connecting, setConnecting] = useState(false)
const [connected, setConnected] = useState(false)
@@ -232,6 +305,9 @@ function WanchuanPlatformForm() {
const [kbLoading, setKbLoading] = useState(false)
const [kbError, setKbError] = useState('')
const [kbExpanded, setKbExpanded] = useState(false)
// 模型同步状态:成功提示已回填,失败展示原因(便于排查路径/token/平台未配模型)
const [modelSyncMsg, setModelSyncMsg] = useState('')
const [modelSyncError, setModelSyncError] = useState(false)
// 后端为唯一数据源:打开设置页时拉取已保存配置并填入表单,
// 然后按已保存凭证自动恢复会话(拉取知识库列表)。
@@ -256,9 +332,14 @@ function WanchuanPlatformForm() {
fetchKnowledgeBases(saved.platformUrl, { allowRelogin: true, savedKbId: saved.selectedKbId, credsForRelogin: saved })
} else if (saved.username && saved.password) {
autoLogin(saved).then(ok => {
if (ok) fetchKnowledgeBases(saved.platformUrl, { savedKbId: saved.selectedKbId })
if (ok) {
fetchKnowledgeBases(saved.platformUrl, { savedKbId: saved.selectedKbId })
}
})
}
// 注意:挂载时不自动 syncModel。自动同步会用平台配置覆盖用户在「AI 模型配置」里
// 手动改过并保存的值(曾导致"改了保存后重进页面又被重置")。模型同步只在用户
// 显式操作时进行:点「测试连接」或「从平台获取」。
})
return () => { cancelled = true }
}, [])
@@ -364,6 +445,23 @@ function WanchuanPlatformForm() {
return uploadToKnowledgeBase(form.platformUrl, targetDatasetId, file)
}
// 登录成功后从平台拉取模型配置并写入后端 AI 设置;成功后通知父级刷新 AI 表单。
// 同步结果用 modelSyncMsg/Error 展示,便于排查(路径错=404、token 失效=401、平台未配模型等
// 不阻断主流程(知识库等仍可用)。
const syncModel = async (baseUrl) => {
setModelSyncMsg('')
setModelSyncError(false)
try {
const written = await syncWanchuanModelToSettings(baseUrl)
onModelSynced?.()
const n = Object.keys(written || {}).length
setModelSyncMsg(`已从平台同步模型配置(${n} 项),已回填到 AI 模型配置`)
} catch (e) {
setModelSyncError(true)
setModelSyncMsg(`模型配置同步失败:${e.message || '未知错误'}`)
}
}
const handleTestConnection = async () => {
if (!form.platformUrl) {
setConnectError('请输入平台地址')
@@ -389,8 +487,9 @@ function WanchuanPlatformForm() {
setConnected(true)
handleSave()
// 登录成功后自动拉取岗位知识库列表
// 登录成功后自动拉取岗位知识库列表 + 同步平台模型配置
fetchKnowledgeBases(form.platformUrl)
syncModel(form.platformUrl)
} catch (e) {
setConnectError(e.message || '登录失败')
} finally {
@@ -564,6 +663,11 @@ function WanchuanPlatformForm() {
<AlertCircle size={12} /> {connectError}
</span>
)}
{modelSyncMsg && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: modelSyncError ? 'var(--danger, #dc2626)' : 'var(--success, #10b981)' }}>
{modelSyncError ? <AlertCircle size={12} /> : <Check size={12} />} {modelSyncMsg}
</span>
)}
{kbExpanded && !kbLoading && knowledgeBases.length === 0 && (
<div style={{ padding: '20px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}>
暂无岗位知识库请先点击测试连接登录平台
@@ -714,6 +818,8 @@ function WanchuanPlatformForm() {
}
export default function SettingsPage() {
// 万川平台同步模型配置后递增此值,触发 AISettingsForm 重新读取后端 AI 配置
const [aiRefreshKey, setAiRefreshKey] = useState(0)
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '28px 36px' }}>
<div style={{ maxWidth: 720 }}>
@@ -722,11 +828,11 @@ export default function SettingsPage() {
系统各服务地址及 AI 配置管理
</div>
{/* AI 配置表单 */}
<AISettingsForm />
{/* 万川 AI 平台对接:放最上面,登录后自动获取下方的 AI 模型配置 */}
<WanchuanPlatformForm onModelSynced={() => setAiRefreshKey(k => k + 1)} />
{/* 万川 AI 平台对接 */}
<WanchuanPlatformForm />
{/* AI 配置表单:由上方万川平台登录后自动填充,也可手动修改 */}
<AISettingsForm refreshKey={aiRefreshKey} />
{CONFIG_ITEMS.map((group) => (
<div key={group.group} style={{ marginBottom: 28 }}>