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() 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 * 清除缓存的 token
*/ */

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Copy, Check, Save, Link2, Loader, AlertCircle, ChevronDown, ChevronRight, RefreshCw, BookOpen, ExternalLink, FileText } from 'lucide-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 = [ 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 地址' }, // 话题分析用 ai_*(也是视觉/语音/报告的回退网关);报告生成用 summary_*。
{ key: 'ai_api_key', label: 'AI API Key', placeholder: 'sk-...', desc: '留空则 AI 功能不可用', type: 'password' }, const AI_GROUPS = [
{ key: 'ai_model', label: '话题分析模型', placeholder: 'qwen-plus', desc: '用于消息分类的模型' }, {
{ key: 'summary_model', label: '报告生成模型', placeholder: 'qwen-max', desc: '用于生成售后报告的模型' }, group: '话题分析模型',
{ key: 'vision_model', label: '视觉模型', placeholder: 'qwen-vl-plus', desc: '用于图片/视频描述' }, fields: [
{ key: 'voice_model', label: '语音模型', placeholder: 'paraformer-v2', desc: '用于语音转文字' }, { 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 = '例如:本群主要是某类设备售后群,请优先按设备部件、故障现象、处理进度来拆分话题;不要按客户名或日期拆分。' const TOPIC_PROMPT_PLACEHOLDER = '例如:本群主要是某类设备售后群,请优先按设备部件、故障现象、处理进度来拆分话题;不要按客户名或日期拆分。'
@@ -57,17 +85,37 @@ function CopyButton({ text }) {
) )
} }
function AISettingsForm() { function AISettingsForm({ refreshKey = 0 }) {
const [form, setForm] = useState({}) const [form, setForm] = useState({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [syncing, setSyncing] = useState(false)
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
useEffect(() => { const loadSettings = () =>
fetch('/api/settings') fetch('/api/settings')
.then(r => r.json()) .then(r => r.json())
.then(data => setForm(data)) .then(data => setForm(data))
.catch(() => {}) .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) => { const handleChange = (key, value) => {
setForm(prev => ({ ...prev, [key]: value })) setForm(prev => ({ ...prev, [key]: value }))
@@ -102,7 +150,7 @@ function AISettingsForm() {
AI 模型配置 AI 模型配置
</div> </div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 12 }}>
首次使用请填入你的 API Key 和接口地址保存后立即生效无需重启服务 已对接万川平台时登录后会自动从平台获取模型配置也可点从平台获取手动同步字段支持手动修改并保存
</div> </div>
{/* 未配置 API Key 时显示橙色警告横条 */} {/* 未配置 API Key 时显示橙色警告横条 */}
@@ -117,50 +165,55 @@ function AISettingsForm() {
</div> </div>
)} )}
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}> {AI_GROUPS.map((g) => (
{AI_FIELDS.map((field, i) => ( <div key={g.group} style={{ marginBottom: 14 }}>
<div <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)', marginBottom: 6 }}>{g.group}</div>
key={field.key} <div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
style={{ {g.fields.map((field, i) => (
display: 'flex', <div
alignItems: 'center', key={field.key}
padding: '12px 16px', style={{
borderBottom: i < AI_FIELDS.length - 1 ? '1px solid var(--border)' : 'none', display: 'flex',
gap: 12, alignItems: 'center',
}} padding: '12px 16px',
> borderBottom: i < g.fields.length - 1 ? '1px solid var(--border)' : 'none',
<div style={{ flex: '0 0 130px' }}> gap: 12,
<div style={{ fontSize: 13, fontWeight: 500 }}>{field.label}</div> }}
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{field.desc}</div> >
</div> <div style={{ flex: '0 0 130px' }}>
<input <div style={{ fontSize: 13, fontWeight: 500 }}>{field.label}</div>
type={field.type || 'text'} <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{field.desc}</div>
value={form[field.key] || ''} </div>
placeholder={field.placeholder} <input
onChange={(e) => handleChange(field.key, e.target.value)} type={field.type || 'text'}
style={{ value={form[field.key] || ''}
flex: 1, placeholder={field.placeholder}
fontSize: 13, onChange={(e) => handleChange(field.key, e.target.value)}
padding: '7px 12px', style={{
border: '1px solid var(--border)', flex: 1,
borderRadius: 6, fontSize: 13,
background: 'var(--surface-2)', padding: '7px 12px',
color: 'var(--text)', border: '1px solid var(--border)',
outline: 'none', 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, <div
background: form[field.key] ? 'var(--success, #10b981)' : '#ef4444', title={form[field.key] ? '已配置' : '未配置'}
boxShadow: form[field.key] ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)', 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> ))}
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>AI 话题分析提示词</div> <div style={{ fontSize: 13, fontWeight: 500, marginBottom: 4 }}>AI 话题分析提示词</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}> <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>
@@ -207,7 +260,27 @@ function AISettingsForm() {
<Save size={14} /> <Save size={14} />
{saving ? '保存中...' : '保存配置'} {saving ? '保存中...' : '保存配置'}
</button> </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>
</div> </div>
) )
@@ -217,7 +290,7 @@ function AISettingsForm() {
* 万川 AI 平台对接配置 * 万川 AI 平台对接配置
* 支持输入平台地址 + 账号密码,测试连接后可展示平台模块内容(预留) * 支持输入平台地址 + 账号密码,测试连接后可展示平台模块内容(预留)
*/ */
function WanchuanPlatformForm() { function WanchuanPlatformForm({ onModelSynced }) {
const [form, setForm] = useState({ platformUrl: '', username: '', password: '' }) const [form, setForm] = useState({ platformUrl: '', username: '', password: '' })
const [connecting, setConnecting] = useState(false) const [connecting, setConnecting] = useState(false)
const [connected, setConnected] = useState(false) const [connected, setConnected] = useState(false)
@@ -232,6 +305,9 @@ function WanchuanPlatformForm() {
const [kbLoading, setKbLoading] = useState(false) const [kbLoading, setKbLoading] = useState(false)
const [kbError, setKbError] = useState('') const [kbError, setKbError] = useState('')
const [kbExpanded, setKbExpanded] = useState(false) 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 }) fetchKnowledgeBases(saved.platformUrl, { allowRelogin: true, savedKbId: saved.selectedKbId, credsForRelogin: saved })
} else if (saved.username && saved.password) { } else if (saved.username && saved.password) {
autoLogin(saved).then(ok => { 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 } return () => { cancelled = true }
}, []) }, [])
@@ -364,6 +445,23 @@ function WanchuanPlatformForm() {
return uploadToKnowledgeBase(form.platformUrl, targetDatasetId, file) 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 () => { const handleTestConnection = async () => {
if (!form.platformUrl) { if (!form.platformUrl) {
setConnectError('请输入平台地址') setConnectError('请输入平台地址')
@@ -389,8 +487,9 @@ function WanchuanPlatformForm() {
setConnected(true) setConnected(true)
handleSave() handleSave()
// 登录成功后自动拉取岗位知识库列表 // 登录成功后自动拉取岗位知识库列表 + 同步平台模型配置
fetchKnowledgeBases(form.platformUrl) fetchKnowledgeBases(form.platformUrl)
syncModel(form.platformUrl)
} catch (e) { } catch (e) {
setConnectError(e.message || '登录失败') setConnectError(e.message || '登录失败')
} finally { } finally {
@@ -564,6 +663,11 @@ function WanchuanPlatformForm() {
<AlertCircle size={12} /> {connectError} <AlertCircle size={12} /> {connectError}
</span> </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 && ( {kbExpanded && !kbLoading && knowledgeBases.length === 0 && (
<div style={{ padding: '20px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}> <div style={{ padding: '20px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}>
暂无岗位知识库请先点击测试连接登录平台 暂无岗位知识库请先点击测试连接登录平台
@@ -714,6 +818,8 @@ function WanchuanPlatformForm() {
} }
export default function SettingsPage() { export default function SettingsPage() {
// 万川平台同步模型配置后递增此值,触发 AISettingsForm 重新读取后端 AI 配置
const [aiRefreshKey, setAiRefreshKey] = useState(0)
return ( return (
<div style={{ flex: 1, overflowY: 'auto', padding: '28px 36px' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '28px 36px' }}>
<div style={{ maxWidth: 720 }}> <div style={{ maxWidth: 720 }}>
@@ -722,11 +828,11 @@ export default function SettingsPage() {
系统各服务地址及 AI 配置管理 系统各服务地址及 AI 配置管理
</div> </div>
{/* AI 配置表单 */} {/* 万川 AI 平台对接:放最上面,登录后自动获取下方的 AI 模型配置 */}
<AISettingsForm /> <WanchuanPlatformForm onModelSynced={() => setAiRefreshKey(k => k + 1)} />
{/* 万川 AI 平台对接 */} {/* AI 配置表单:由上方万川平台登录后自动填充,也可手动修改 */}
<WanchuanPlatformForm /> <AISettingsForm refreshKey={aiRefreshKey} />
{CONFIG_ITEMS.map((group) => ( {CONFIG_ITEMS.map((group) => (
<div key={group.group} style={{ marginBottom: 28 }}> <div key={group.group} style={{ marginBottom: 28 }}>

View File

@@ -30,6 +30,14 @@ class Settings(BaseSettings):
summary_model: str = "" # 不设默认值,必须由用户在设置页配置 summary_model: str = "" # 不设默认值,必须由用户在设置页配置
voice_model: str = "" # 不设默认值,必须由用户在设置页配置 voice_model: str = "" # 不设默认值,必须由用户在设置页配置
vision_model: str = "" # 不设默认值,必须由用户在设置页配置 vision_model: str = "" # 不设默认值,必须由用户在设置页配置
# 语音/视觉/报告生成可使用与话题分析不同的网关与密钥(如万川平台不同 code
# 留空则回退到 ai_base_url / ai_api_key。
voice_base_url: str = ""
voice_api_key: str = ""
vision_base_url: str = ""
vision_api_key: str = ""
summary_base_url: str = ""
summary_api_key: str = ""
data_dir: str = _default_data_dir() data_dir: str = _default_data_dir()
static_dir: str = _default_static_dir() static_dir: str = _default_static_dir()
db_path: str = str(Path(_default_data_dir()) / "data" / "knowledge.db") db_path: str = str(Path(_default_data_dir()) / "data" / "knowledge.db")

View File

@@ -1,10 +1,10 @@
import aiosqlite import aiosqlite
import asyncio import asyncio
import httpx
import logging import logging
import time import time
from pathlib import Path from pathlib import Path
from config import settings from config import settings
from services.http_client import shared_client
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -44,35 +44,35 @@ async def get_current_wxid(force: bool = False):
return _resolved_wxid return _resolved_wxid
# 重新解析当前 wxid # 重新解析当前 wxid
base = settings.chatlog_base_url base = settings.chatlog_base_url
async with httpx.AsyncClient(trust_env=False, timeout=10.0) as client: client = shared_client()
try: try:
r = await client.get(f"{base}/api/v1/chatlog", params={"talker": "filehelper", "limit": 100, "time": "1970-01-01,2099-12-31", "format": "json"}) r = await client.get(f"{base}/api/v1/chatlog", params={"talker": "filehelper", "limit": 100, "time": "1970-01-01,2099-12-31", "format": "json"}, timeout=10.0)
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
for msg in data.get("items", []): for msg in data.get("items", []):
if msg.get("isSelf"): if msg.get("isSelf"):
_resolved_wxid = msg.get("sender") _resolved_wxid = msg.get("sender")
_wxid_last_resolved = time.time() _wxid_last_resolved = time.time()
return _resolved_wxid return _resolved_wxid
except Exception: except Exception:
pass pass
try: try:
r = await client.get(f"{base}/api/v1/chatroom", params={"limit": 10, "format": "json"}) r = await client.get(f"{base}/api/v1/chatroom", params={"limit": 10, "format": "json"}, timeout=10.0)
if r.status_code == 200: if r.status_code == 200:
rooms = r.json().get("items", []) rooms = r.json().get("items", [])
for room in rooms: for room in rooms:
room_id = room.get("name") room_id = room.get("name")
r2 = await client.get(f"{base}/api/v1/chatlog", params={"talker": room_id, "limit": 50, "time": "1970-01-01,2099-12-31", "format": "json"}) r2 = await client.get(f"{base}/api/v1/chatlog", params={"talker": room_id, "limit": 50, "time": "1970-01-01,2099-12-31", "format": "json"}, timeout=10.0)
if r2.status_code == 200: if r2.status_code == 200:
data2 = r2.json() data2 = r2.json()
for msg in data2.get("items", []): for msg in data2.get("items", []):
if msg.get("isSelf"): if msg.get("isSelf"):
_resolved_wxid = msg.get("sender") _resolved_wxid = msg.get("sender")
_wxid_last_resolved = time.time() _wxid_last_resolved = time.time()
return _resolved_wxid return _resolved_wxid
except Exception: except Exception:
pass pass
if force: if force:
reset_wxid_cache() reset_wxid_cache()
return "default" return "default"
@@ -85,6 +85,10 @@ async def update_db_path(force: bool = False):
log.info(f"Switching database to {new_path}") log.info(f"Switching database to {new_path}")
_current_db_path = new_path _current_db_path = new_path
await init_db(new_path) await init_db(new_path)
# 账号切换:清掉 chatlog 客户端的 contact 库路径与头像缓存,避免显示上一个账号的头像。
# 局部导入避免潜在的模块循环引用。
from services.chatlog_client import chatlog_client
chatlog_client.reset_account_cache()
return _current_db_path return _current_db_path
def get_active_db_path(): def get_active_db_path():

View File

@@ -7,7 +7,6 @@ from contextlib import asynccontextmanager
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
import httpx
from database import get_active_db_path, get_current_wxid, init_db, reset_wxid_cache, update_db_path from database import get_active_db_path, get_current_wxid, init_db, reset_wxid_cache, update_db_path
from scheduler import start_scheduler from scheduler import start_scheduler
from config import settings from config import settings
@@ -15,6 +14,7 @@ from routers import search, groups, topics, knowledge, ai, sse, files, chatlog_p
from routers import settings as settings_router from routers import settings as settings_router
from services.chatlog_context import get_chatlog_context, update_chatlog_context from services.chatlog_context import get_chatlog_context, update_chatlog_context
from services.media_resolver import diagnose_media from services.media_resolver import diagnose_media
from services.http_client import shared_client, close_shared_client
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -49,6 +49,7 @@ async def lifespan(app: FastAPI):
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
await close_shared_client()
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
@@ -79,11 +80,11 @@ async def health():
chatlog_ok = False chatlog_ok = False
chatlog_error = "" chatlog_error = ""
try: try:
async with httpx.AsyncClient(timeout=3.0, trust_env=False) as client: client = shared_client()
resp = await client.get(f"{settings.chatlog_base_url}/api/v1/session", params={"limit": 1, "format": "json"}) resp = await client.get(f"{settings.chatlog_base_url}/api/v1/session", params={"limit": 1, "format": "json"}, timeout=3.0)
chatlog_ok = resp.status_code == 200 chatlog_ok = resp.status_code == 200
if not chatlog_ok: if not chatlog_ok:
chatlog_error = f"HTTP {resp.status_code}" chatlog_error = f"HTTP {resp.status_code}"
except Exception as e: except Exception as e:
chatlog_error = str(e) chatlog_error = str(e)
@@ -101,6 +102,9 @@ async def health():
@app.post("/api/system/refresh-account") @app.post("/api/system/refresh-account")
async def refresh_account(): async def refresh_account():
reset_wxid_cache() reset_wxid_cache()
# 用户主动点“重新识别账号”=要最新数据,无条件清掉头像/contact 库缓存
from services.chatlog_client import chatlog_client
chatlog_client.reset_account_cache()
db_path = await update_db_path(force=True) db_path = await update_db_path(force=True)
wxid = await get_current_wxid() wxid = await get_current_wxid()
return {"ok": True, "wxid": wxid, "db_path": db_path} return {"ok": True, "wxid": wxid, "db_path": db_path}

View File

@@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import Response, StreamingResponse from fastapi.responses import Response, StreamingResponse
from config import settings from config import settings
from services.http_client import shared_client
router = APIRouter(tags=["chatlog-proxy"]) router = APIRouter(tags=["chatlog-proxy"])
@@ -41,13 +42,14 @@ async def _proxy_chatlog(request: Request, upstream_path: str) -> Response:
if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host" if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
} }
async with httpx.AsyncClient(timeout=None, trust_env=False, follow_redirects=True) as client: client = shared_client()
upstream = await client.request( upstream = await client.request(
request.method, request.method,
target, target,
content=body if body else None, content=body if body else None,
headers=headers, headers=headers,
) timeout=None,
)
response_headers = _copy_headers(upstream.headers) response_headers = _copy_headers(upstream.headers)
return StreamingResponse( return StreamingResponse(

View File

@@ -7,12 +7,12 @@ import tempfile
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
import httpx
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from config import settings from config import settings
from services.chatlog_client import chatlog_client from services.chatlog_client import chatlog_client
from services.http_client import shared_client
router = APIRouter(prefix="/api/files", tags=["files"]) router = APIRouter(prefix="/api/files", tags=["files"])
@@ -61,8 +61,8 @@ def _guess_media_type(filename: str, fallback: str = "") -> str:
async def _proxy_chatlog_file(md5: str, filename: str = ""): async def _proxy_chatlog_file(md5: str, filename: str = ""):
url = f"{settings.chatlog_base_url}/file/{quote(md5, safe='')}" url = f"{settings.chatlog_base_url}/file/{quote(md5, safe='')}"
try: try:
async with httpx.AsyncClient(timeout=30, trust_env=False, follow_redirects=True) as client: client = shared_client()
resp = await client.get(url) resp = await client.get(url, timeout=30)
except Exception: except Exception:
return None return None

View File

@@ -10,8 +10,13 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
EDITABLE_KEYS = [ EDITABLE_KEYS = [
"ai_base_url", "ai_api_key", "ai_model", "summary_model", "ai_base_url", "ai_api_key", "ai_model", "summary_model",
"vision_model", "voice_model", "topic_analysis_prompt", "vision_model", "voice_model", "topic_analysis_prompt",
"voice_base_url", "voice_api_key", "vision_base_url", "vision_api_key",
"summary_base_url", "summary_api_key",
] ]
# 需要脱敏GET 返回打码)并在 PUT 时跳过含 `*` 占位值的密钥字段。
SECRET_KEYS = {"ai_api_key", "voice_api_key", "vision_api_key", "summary_api_key"}
# 万川 AI 平台对接配置整体作为一条 JSON 存储,独立于上面的 AI 模型配置。 # 万川 AI 平台对接配置整体作为一条 JSON 存储,独立于上面的 AI 模型配置。
# 存到后端 SQLiteapp_settings 表)后,配置不再依赖前端 localStorage 的 origin # 存到后端 SQLiteapp_settings 表)后,配置不再依赖前端 localStorage 的 origin
# 桌面应用exe即便后端端口/origin 变化也能跨次启动恢复平台地址、账号、密码与已选知识库。 # 桌面应用exe即便后端端口/origin 变化也能跨次启动恢复平台地址、账号、密码与已选知识库。
@@ -35,7 +40,7 @@ async def get_settings(db: aiosqlite.Connection = Depends(get_db)):
rows = await cur.fetchall() rows = await cur.fetchall()
for row in rows: for row in rows:
k, v = row["key"], row["value"] k, v = row["key"], row["value"]
result[k] = _mask_key(v) if k == "ai_api_key" else v result[k] = _mask_key(v) if k in SECRET_KEYS else v
for k in EDITABLE_KEYS: for k in EDITABLE_KEYS:
if k not in result: if k not in result:
result[k] = "" result[k] = ""
@@ -50,6 +55,12 @@ class SettingsUpdate(BaseModel):
vision_model: Optional[str] = None vision_model: Optional[str] = None
voice_model: Optional[str] = None voice_model: Optional[str] = None
topic_analysis_prompt: Optional[str] = None topic_analysis_prompt: Optional[str] = None
voice_base_url: Optional[str] = None
voice_api_key: Optional[str] = None
vision_base_url: Optional[str] = None
vision_api_key: Optional[str] = None
summary_base_url: Optional[str] = None
summary_api_key: Optional[str] = None
@router.put("") @router.put("")
@@ -58,7 +69,8 @@ async def update_settings(body: SettingsUpdate, db: aiosqlite.Connection = Depen
for k, v in updates.items(): for k, v in updates.items():
if k not in EDITABLE_KEYS: if k not in EDITABLE_KEYS:
continue continue
if k == "ai_api_key" and "*" in v: # 密钥字段含 `*` 说明是 GET 返回的打码值,未被用户真正修改,跳过避免覆盖真实值
if k in SECRET_KEYS and "*" in v:
continue continue
await db.execute( await db.execute(
"INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", "INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",

View File

@@ -3,29 +3,44 @@ from openai import AsyncOpenAI
from services.runtime_settings import get_ai_settings from services.runtime_settings import get_ai_settings
# 按 (base_url, api_key) 缓存客户端:聊天/视觉/语音可能指向不同网关与密钥,
# 各自一个 pair最多累积 3 个有界。配置变更settings PUT 会 invalidate
# runtime_settings 缓存)后,新的 pair 会自然生成新客户端;旧的留存无副作用。
_client_cache: dict[tuple[str, str], AsyncOpenAI] = {} _client_cache: dict[tuple[str, str], AsyncOpenAI] = {}
_http_client_cache: dict[tuple[str, str], httpx.AsyncClient] = {} _http_client_cache: dict[tuple[str, str], httpx.AsyncClient] = {}
async def get_openai_client() -> tuple[AsyncOpenAI, dict]: def _get_client(base_url: str, api_key: str) -> AsyncOpenAI:
settings = await get_ai_settings() cache_key = (base_url or "", api_key or "")
cache_key = (
settings.get("ai_base_url") or "",
settings.get("ai_api_key") or "",
)
if cache_key not in _client_cache: if cache_key not in _client_cache:
for http_client in _http_client_cache.values():
await http_client.aclose()
_client_cache.clear()
_http_client_cache.clear()
http_client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0)) http_client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0))
_http_client_cache[cache_key] = http_client _http_client_cache[cache_key] = http_client
_client_cache[cache_key] = AsyncOpenAI( _client_cache[cache_key] = AsyncOpenAI(
api_key=settings.get("ai_api_key") or "missing", api_key=api_key or "missing",
base_url=settings.get("ai_base_url"), base_url=base_url or None,
http_client=http_client, http_client=http_client,
) )
return _client_cache[cache_key]
return _client_cache[cache_key], settings
async def get_openai_client() -> tuple[AsyncOpenAI, dict]:
"""聊天调用方(话题/报告/总结/对话)复用:用全局 ai_base_url / ai_api_key。"""
settings = await get_ai_settings()
client = _get_client(
settings.get("ai_base_url") or "",
settings.get("ai_api_key") or "",
)
return client, settings
async def get_client_for(purpose: str) -> tuple[AsyncOpenAI, dict]:
"""按用途取客户端purpose 为 'voice' / 'vision'
优先用 {purpose}_base_url / {purpose}_api_key为空则回退到全局
ai_base_url / ai_api_key单网关场景无需重复配置
"""
settings = await get_ai_settings()
base_url = settings.get(f"{purpose}_base_url") or settings.get("ai_base_url") or ""
api_key = settings.get(f"{purpose}_api_key") or settings.get("ai_api_key") or ""
client = _get_client(base_url, api_key)
return client, settings

View File

@@ -2,6 +2,7 @@ import httpx
import asyncio import asyncio
from typing import List from typing import List
from config import settings from config import settings
from services.http_client import shared_client
class ChatlogHTTPError(RuntimeError): class ChatlogHTTPError(RuntimeError):
@@ -21,13 +22,21 @@ class ChatlogClient:
def __init__(self): def __init__(self):
self.base = settings.chatlog_base_url self.base = settings.chatlog_base_url
self._contact_db_file = None self._contact_db_file = None
# 进程级头像缓存wxid -> url。同一账号下同一 wxid 只查一次 chatlog SQL
# 避免打开群聊时几十个发言人各打一次 /api/v1/db/query 头像查询。
self._avatar_cache: dict[str, str] = {}
def reset_account_cache(self):
"""账号切换时调用:清掉 contact 库路径与头像缓存,避免显示上一个账号的数据。"""
self._contact_db_file = None
self._avatar_cache.clear()
async def _get(self, path: str, params: dict, timeout: float = 30.0) -> dict: async def _get(self, path: str, params: dict, timeout: float = 30.0) -> dict:
try: try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: client = shared_client()
r = await client.get(f"{self.base}{path}", params=params) r = await client.get(f"{self.base}{path}", params=params, timeout=timeout)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except httpx.TimeoutException: except httpx.TimeoutException:
raise RuntimeError(f"chatlog timeout: GET {path}") raise RuntimeError(f"chatlog timeout: GET {path}")
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
@@ -38,10 +47,10 @@ class ChatlogClient:
async def _post(self, path: str, body: dict, timeout: float = 30.0) -> dict: async def _post(self, path: str, body: dict, timeout: float = 30.0) -> dict:
try: try:
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: client = shared_client()
r = await client.post(f"{self.base}{path}", json=body) r = await client.post(f"{self.base}{path}", json=body, timeout=timeout)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except httpx.TimeoutException: except httpx.TimeoutException:
raise RuntimeError(f"chatlog timeout: POST {path}") raise RuntimeError(f"chatlog timeout: POST {path}")
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
@@ -128,15 +137,16 @@ class ChatlogClient:
async def get_message(self, talker: str, seq: int) -> dict | None: async def get_message(self, talker: str, seq: int) -> dict | None:
try: try:
async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: client = shared_client()
r = await client.get( r = await client.get(
f"{self.base}/api/v1/chatlog/message", f"{self.base}/api/v1/chatlog/message",
params={"talker": talker, "seq": seq}, params={"talker": talker, "seq": seq},
) timeout=10.0,
if r.status_code == 404: )
return None if r.status_code == 404:
r.raise_for_status() return None
return r.json() r.raise_for_status()
return r.json()
except httpx.TimeoutException: except httpx.TimeoutException:
raise RuntimeError("chatlog timeout: get_message") raise RuntimeError("chatlog timeout: get_message")
except Exception as e: except Exception as e:
@@ -174,6 +184,11 @@ class ChatlogClient:
async def get_avatar_url(self, wxid: str) -> str: async def get_avatar_url(self, wxid: str) -> str:
if not wxid:
return ""
cached = self._avatar_cache.get(wxid)
if cached is not None:
return cached
if self._contact_db_file is None: if self._contact_db_file is None:
try: try:
db_list = await self._get("/api/v1/db", {}) db_list = await self._get("/api/v1/db", {})
@@ -185,15 +200,17 @@ class ChatlogClient:
safe_wxid = wxid.replace("'", "''") safe_wxid = wxid.replace("'", "''")
sql = f"SELECT small_head_url, big_head_url FROM contact WHERE username='{safe_wxid}' LIMIT 1" sql = f"SELECT small_head_url, big_head_url FROM contact WHERE username='{safe_wxid}' LIMIT 1"
params = {"group": "contact", "file": self._contact_db_file, "sql": sql} params = {"group": "contact", "file": self._contact_db_file, "sql": sql}
url = ""
try: try:
rows = await self._get("/api/v1/db/query", params, timeout=5.0) rows = await self._get("/api/v1/db/query", params, timeout=5.0)
if rows: if rows:
url = rows[0].get("small_head_url") or rows[0].get("big_head_url") or "" url = rows[0].get("small_head_url") or rows[0].get("big_head_url") or ""
if url:
return url
except Exception: except Exception:
pass # 查询失败不写缓存,下次仍可重试
return "" return ""
# 命中(含确定无头像的空串)都缓存,避免重复查询
self._avatar_cache[wxid] = url
return url
async def get_db_paths(self) -> dict: async def get_db_paths(self) -> dict:
data = await self._get("/api/v1/db", {}, timeout=10.0) data = await self._get("/api/v1/db", {}, timeout=10.0)

View File

@@ -0,0 +1,46 @@
"""共享的 httpx.AsyncClient。
历史问题:后端每次访问 chatlog.exe127.0.0.1:5030都新建一个 AsyncClient
用完即关,没有 keep-alive。打开一个群聊会瞬间产生几十条短连接图片代理、
头像查询等),在 Windows 上会堆积 TIME_WAIT / 耗尽临时端口,导致“用一会儿就卡”。
这里改为全后端共享一个带连接池的 client复用 keep-alive 连接,连接建立开销
和端口占用都大幅下降。在 lifespan 关闭时统一释放。
"""
from __future__ import annotations
import httpx
_client: httpx.AsyncClient | None = None
def shared_client() -> httpx.AsyncClient:
"""返回进程级共享的 AsyncClient惰性创建
- trust_env=False与原各处调用保持一致不读系统代理避免本地回环被代理拦截。
- follow_redirects=True媒体/文件接口需要;普通 api 调用无重定向,无副作用。
- limits保持 keep-alive 连接,避免每请求新建连接。
单次请求可通过 client.get(..., timeout=...) 覆盖超时。
"""
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
trust_env=False,
follow_redirects=True,
timeout=httpx.Timeout(30.0, connect=5.0),
limits=httpx.Limits(
max_keepalive_connections=32,
max_connections=128,
keepalive_expiry=30.0,
),
)
return _client
async def close_shared_client() -> None:
"""在应用关闭时释放共享 client。"""
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None

View File

@@ -1,18 +1,19 @@
import asyncio
import base64 import base64
import logging import logging
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
from services.ai_client import get_openai_client from services.ai_client import get_client_for
from services.media_resolver import resolve_media from services.media_resolver import resolve_media
from services.runtime_settings import get_ai_settings from services.runtime_settings import get_ai_settings
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# 语音异步 ASR 默认网关阿里云。voice_base_url 为空时回退到此;
async def _get_ai_client(): # 提交任务/轮询的子路径由代码自动拼接,配置只需填到 .../api/v1 这一层。
return await get_openai_client() DEFAULT_ASR_BASE_URL = "https://dashscope.aliyuncs.com/api/v1"
async def parse_media(kind: str, key: str) -> dict: async def parse_media(kind: str, key: str) -> dict:
@@ -28,12 +29,17 @@ async def parse_media(kind: str, key: str) -> dict:
raise HTTPException(400, "媒体 key 不能为空") raise HTTPException(400, "媒体 key 不能为空")
ai = await get_ai_settings() ai = await get_ai_settings()
if not ai.get("ai_api_key"): # voice/vision 各自有独立 url/key为空则回退全局 ai_api_key
raise HTTPException(503, "AI 服务未配置,请在设置页填写 AI API Key") if kind == "voice":
if kind == "voice" and not ai.get("voice_model"): if not (ai.get("voice_api_key") or ai.get("ai_api_key")):
raise HTTPException(503, "语音模型未配置,请在设置页填写语音模型名称,例如 paraformer-v2") raise HTTPException(503, "AI 服务未配置,请在设置页填写语音密钥或 AI API Key")
if kind in ("image", "video") and not ai.get("vision_model"): if not ai.get("voice_model"):
raise HTTPException(503, "视觉模型未配置,请在设置页填写视觉模型名称,例如 qwen-vl-plus") raise HTTPException(503, "语音模型未配置,请在设置页填写语音模型名称,例如 paraformer-v2")
if kind in ("image", "video"):
if not (ai.get("vision_api_key") or ai.get("ai_api_key")):
raise HTTPException(503, "AI 服务未配置,请在设置页填写视觉密钥或 AI API Key")
if not ai.get("vision_model"):
raise HTTPException(503, "视觉模型未配置,请在设置页填写视觉模型名称,例如 qwen-vl-plus")
media = await resolve_media(kind, key) media = await resolve_media(kind, key)
if kind == "voice": if kind == "voice":
@@ -41,56 +47,90 @@ async def parse_media(kind: str, key: str) -> dict:
return {"text": await _parse_visual(kind, media.bytes, media.content_type)} return {"text": await _parse_visual(kind, media.bytes, media.content_type)}
async def _parse_voice(media_bytes: bytes, content_type: str) -> str: def _audio_mime(content_type: str) -> str:
b64_audio = base64.b64encode(media_bytes).decode() """由 chatlog 返回的 content_type 推断音频 MIME用于 data URI"""
audio_ct = content_type.lower() ct = content_type.lower()
if "silk" in audio_ct or "x-silk" in audio_ct: if "silk" in ct or "x-silk" in ct:
audio_mime = "audio/silk" return "audio/silk"
elif "amr" in audio_ct: if "amr" in ct:
audio_mime = "audio/amr" return "audio/amr"
elif "ogg" in audio_ct or "opus" in audio_ct: if "ogg" in ct or "opus" in ct:
audio_mime = "audio/ogg" return "audio/ogg"
elif "wav" in audio_ct: if "wav" in ct:
audio_mime = "audio/wav" return "audio/wav"
else: return "audio/mpeg"
audio_mime = "audio/mpeg"
data_uri = f"data:{audio_mime};base64,{b64_audio}"
_, ai = await _get_ai_client() def _asr_json(resp: httpx.Response, url: str) -> dict:
"""安全解析 ASR 响应为 JSON。
响应非 JSON空响应 / HTML 错误页 / 网关 404原来直接 .json() 会抛
JSONDecodeError把真实原因HTTP 状态码 + 正文)掩盖掉。这里改成抛出
带状态码与正文片段的 HTTPException便于排查如地址填成 compatible-mode/v1
"""
try:
return resp.json()
except Exception:
body = (resp.text or "").strip()[:300]
raise HTTPException(
500,
f"ASR 接口返回非 JSON (HTTP {resp.status_code}) @ {url}{body or '(空响应)'}"
"请检查语音接口地址是否为异步 ASR 网关(如 .../api/v1及密钥是否正确。",
)
async def _parse_voice(media_bytes: bytes, content_type: str) -> str:
"""语音转文字:阿里云异步 ASR 协议(提交任务 → 轮询 → 取结果)。
接口地址动态base = voice_base_url为空直接用默认阿里云原生网关不回退 ai_base_url
提交端点 = {base}/services/audio/asr/transcription轮询 = {base}/tasks/{id}
子路径由代码自动拼接,配置只需填到 .../api/v1 这一层。
密钥 = voice_api_key为空回退 ai_api_key
"""
ai = await get_ai_settings()
# strip 防止配置/同步带入首尾空格(实测出现过 api_key 前导空格导致鉴权失败)
# 注意:异步 ASR 走原生网关 /api/v1与 ai_base_urlOpenAI 兼容的 chat 端点
# .../compatible-mode/...是两套服务不能混用。voice_base_url 为空时应回退到
# DEFAULT_ASR_BASE_URL绝不能回退到 ai_base_url否则会拼成 .../compatible-mode/.../asr 而 404。
base = (ai.get("voice_base_url") or DEFAULT_ASR_BASE_URL).strip().rstrip("/")
api_key = (ai.get("voice_api_key") or ai.get("ai_api_key") or "").strip()
voice_model = (ai.get("voice_model") or "").strip()
b64_audio = base64.b64encode(media_bytes).decode()
data_uri = f"data:{_audio_mime(content_type)};base64,{b64_audio}"
asr_headers = { asr_headers = {
"Authorization": f"Bearer {ai['ai_api_key']}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
submit_url = f"{base}/services/audio/asr/transcription"
async with httpx.AsyncClient(timeout=60) as http: async with httpx.AsyncClient(timeout=60) as http:
submit = await http.post( submit = await http.post(
"https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription", submit_url,
headers={**asr_headers, "X-DashScope-Async": "enable"}, headers={**asr_headers, "X-DashScope-Async": "enable"},
json={ json={
"model": ai["voice_model"], "model": voice_model,
"input": {"file_urls": [data_uri]}, "input": {"file_urls": [data_uri]},
"parameters": {"language_hints": ["zh", "en"]}, "parameters": {"language_hints": ["zh", "en"]},
}, },
timeout=30, timeout=30,
) )
submit_data = submit.json() submit_data = _asr_json(submit, submit_url)
if submit.status_code not in (200, 201): if submit.status_code not in (200, 201):
raise HTTPException(500, f"提交识别任务失败: {submit_data.get('message', submit_data)}") raise HTTPException(500, f"提交识别任务失败 (HTTP {submit.status_code}): {submit_data.get('message', submit_data)}")
task_id = submit_data.get("output", {}).get("task_id") task_id = submit_data.get("output", {}).get("task_id")
if not task_id: if not task_id:
raise HTTPException(500, f"未获取到 task_id: {submit_data}") raise HTTPException(500, f"未获取到 task_id: {submit_data}")
for _ in range(30): for _ in range(30):
import asyncio
await asyncio.sleep(1) await asyncio.sleep(1)
poll = await http.get( poll = await http.get(
f"https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}", f"{base}/tasks/{task_id}",
headers=asr_headers, headers=asr_headers,
timeout=10, timeout=10,
) )
poll_data = poll.json() poll_data = _asr_json(poll, f"{base}/tasks/{task_id}")
status = poll_data.get("output", {}).get("task_status", "") status = poll_data.get("output", {}).get("task_status", "")
if status == "SUCCEEDED": if status == "SUCCEEDED":
results = poll_data.get("output", {}).get("results", []) results = poll_data.get("output", {}).get("results", [])
@@ -125,7 +165,7 @@ async def _parse_visual(kind: str, media_bytes: bytes, content_type: str) -> str
data_url = f"data:{mime};base64,{b64}" data_url = f"data:{mime};base64,{b64}"
prompt = "请用中文简洁描述这张图片的内容。" if kind == "image" else "请用中文简洁描述这个视频截图的内容。" prompt = "请用中文简洁描述这张图片的内容。" if kind == "image" else "请用中文简洁描述这个视频截图的内容。"
client, ai = await _get_ai_client() client, ai = await get_client_for("vision")
resp_ai = await client.chat.completions.create( resp_ai = await client.chat.completions.create(
model=ai["vision_model"], model=ai["vision_model"],
messages=[ messages=[

View File

@@ -10,6 +10,7 @@ from fastapi import HTTPException
from config import settings from config import settings
from services.chatlog_context import get_chatlog_context from services.chatlog_context import get_chatlog_context
from services.http_client import shared_client
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -100,25 +101,25 @@ async def diagnose_media(kind: str, key: str) -> dict:
"chatlog_context": get_chatlog_context(), "chatlog_context": get_chatlog_context(),
} }
async with httpx.AsyncClient(timeout=20, trust_env=False, follow_redirects=True) as client: client = shared_client()
try: try:
resp = await client.get(url) resp = await client.get(url, timeout=20)
content_type = resp.headers.get("content-type", "") content_type = resp.headers.get("content-type", "")
result.update( result.update(
{ {
"status_code": resp.status_code, "status_code": resp.status_code,
"content_type": content_type, "content_type": content_type,
"content_length": len(resp.content or b""), "content_length": len(resp.content or b""),
"ok": resp.status_code < 400 and bool(resp.content), "ok": resp.status_code < 400 and bool(resp.content),
} }
) )
if resp.status_code >= 400: if resp.status_code >= 400:
result["error"] = _download_failure_message(kind, key, resp.status_code, resp.text[:500]) result["error"] = _download_failure_message(kind, key, resp.status_code, resp.text[:500])
result["response_preview"] = resp.text[:500] result["response_preview"] = resp.text[:500]
elif not resp.content: elif not resp.content:
result["error"] = "chatlog 返回了空媒体文件" result["error"] = "chatlog 返回了空媒体文件"
except Exception as exc: except Exception as exc:
result.update({"error": f"无法连接 chatlog 媒体接口: {exc}", "exception": str(exc)}) result.update({"error": f"无法连接 chatlog 媒体接口: {exc}", "exception": str(exc)})
if kind == "voice": if kind == "voice":
result["resource_db"] = _read_voice_resource_status(key) result["resource_db"] = _read_voice_resource_status(key)
@@ -132,30 +133,30 @@ async def resolve_media(kind: str, key: str) -> ResolvedMedia:
raise HTTPException(400, "媒体 key 不能为空") raise HTTPException(400, "媒体 key 不能为空")
url = _media_url(kind, key, thumb=kind in {"image", "video"}) url = _media_url(kind, key, thumb=kind in {"image", "video"})
async with httpx.AsyncClient(timeout=60, trust_env=False, follow_redirects=True) as client: client = shared_client()
try: try:
resp = await client.get(url) resp = await client.get(url, timeout=60)
resp.raise_for_status() resp.raise_for_status()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
diagnostics = await diagnose_media(kind, key) diagnostics = await diagnose_media(kind, key)
log.warning("[media_resolver] media download failed: %s", diagnostics) log.warning("[media_resolver] media download failed: %s", diagnostics)
raise HTTPException( raise HTTPException(
502, 502,
{ {
"message": _download_failure_message(kind, key, exc.response.status_code, exc.response.text[:500]), "message": _download_failure_message(kind, key, exc.response.status_code, exc.response.text[:500]),
"diagnostics": diagnostics, "diagnostics": diagnostics,
}, },
) )
except Exception as exc: except Exception as exc:
diagnostics = await diagnose_media(kind, key) diagnostics = await diagnose_media(kind, key)
log.warning("[media_resolver] media download exception: %s", diagnostics) log.warning("[media_resolver] media download exception: %s", diagnostics)
raise HTTPException( raise HTTPException(
502, 502,
{ {
"message": _download_failure_message(kind, key, None, str(exc)), "message": _download_failure_message(kind, key, None, str(exc)),
"diagnostics": diagnostics, "diagnostics": diagnostics,
}, },
) )
if not resp.content: if not resp.content:
diagnostics = await diagnose_media(kind, key) diagnostics = await diagnose_media(kind, key)

View File

@@ -27,6 +27,13 @@ async def get_ai_settings() -> dict:
"vision_model": "", "vision_model": "",
"voice_model": "", "voice_model": "",
"topic_analysis_prompt": "", "topic_analysis_prompt": "",
# 语音/视觉/报告生成独立网关与密钥;留空则由调用方回退到 ai_base_url / ai_api_key
"voice_base_url": "",
"voice_api_key": "",
"vision_base_url": "",
"vision_api_key": "",
"summary_base_url": "",
"summary_api_key": "",
} }
try: try:

View File

@@ -13,7 +13,7 @@ import aiosqlite
from urllib.parse import quote from urllib.parse import quote
from database import get_active_db_path from database import get_active_db_path
from services.ai_client import get_openai_client from services.ai_client import get_client_for
from services.fts import tokenize from services.fts import tokenize
from services.message_formatter import append_quote_text, extract_contents, extract_quote from services.message_formatter import append_quote_text, extract_contents, extract_quote
from services.report_learning import build_report_learning_context from services.report_learning import build_report_learning_context
@@ -25,7 +25,8 @@ SUMMARY_LLM_TIMEOUT_SECONDS = 300
async def _get_client(): async def _get_client():
return await get_openai_client() # 报告生成走独立网关 summary_base_url/summary_api_key为空回退 ai_*
return await get_client_for("summary")
def _message_line(item: dict, fallback_seq: int = 0) -> tuple[int, str] | None: def _message_line(item: dict, fallback_seq: int = 0) -> tuple[int, str] | None:

View File

@@ -159,7 +159,8 @@
height: 660px; height: 660px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
pointer-events: none; pointer-events: none;
filter: drop-shadow(0 48px 55px rgba(0,0,0,0.78)); /* 原本的 drop-shadow 滤镜会随手臂无限动画每帧重算整块阴影,极耗 GPU。
SVG 内部已有一个静态阴影椭圆,这里移除滤镜即可。 */
} }
.robot { .robot {
@@ -203,9 +204,9 @@
padding: 26px; padding: 26px;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 24px; border-radius: 24px;
background: linear-gradient(180deg, rgba(27,29,34,0.94), rgba(15,16,19,0.9)); /* 面板背景已接近不透明,去掉 backdrop-filter:blur 避免 GPU 每帧重模糊(动画背景下尤其卡) */
background: linear-gradient(180deg, rgba(29,31,37,0.97), rgba(16,17,21,0.96));
box-shadow: var(--shadow); box-shadow: var(--shadow);
backdrop-filter: blur(14px);
} }
.panel-title { .panel-title {
@@ -467,8 +468,8 @@
z-index: 100; z-index: 100;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(0, 0, 0, 0.72); /* 去掉 backdrop-filter:blur改用更深的纯遮罩避免弹窗出现时整屏每帧重模糊 */
backdrop-filter: blur(8px); background: rgba(0, 0, 0, 0.82);
} }
.modal-overlay.show { .modal-overlay.show {
@@ -751,13 +752,26 @@
let startingAll = false; let startingAll = false;
let refreshingAccount = false; let refreshingAccount = false;
let robotRafPending = false
let robotNextX = 0
let robotNextY = 0
launcher.addEventListener('mousemove', (event) => { launcher.addEventListener('mousemove', (event) => {
const rect = launcher.getBoundingClientRect(); // 缓存目标坐标,用 rAF 合并到每帧最多一次写入,避免每个 mousemove 事件
const x = ((event.clientX - rect.left) / rect.width - 0.5) * 2; // 都触发 getBoundingClientRect()(强制同步布局)和 robotHead 滤镜重算。
const y = ((event.clientY - rect.top) / rect.height - 0.5) * 2; robotNextX = event.clientX
const clampedX = Math.max(-1, Math.min(1, x)); robotNextY = event.clientY
const clampedY = Math.max(-1, Math.min(1, y)); if (robotRafPending) return
robotHead.style.transform = `translate(${clampedX * 9}px, ${clampedY * 6}px) rotate(${clampedX * 7}deg)`; robotRafPending = true
requestAnimationFrame(() => {
robotRafPending = false
const rect = launcher.getBoundingClientRect()
const x = ((robotNextX - rect.left) / rect.width - 0.5) * 2
const y = ((robotNextY - rect.top) / rect.height - 0.5) * 2
const clampedX = Math.max(-1, Math.min(1, x))
const clampedY = Math.max(-1, Math.min(1, y))
robotHead.style.transform = `translate(${clampedX * 9}px, ${clampedY * 6}px) rotate(${clampedX * 7}deg)`
})
}); });
launcher.addEventListener('mouseleave', () => { launcher.addEventListener('mouseleave', () => {

View File

@@ -9,6 +9,7 @@ E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backen
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-datetime... E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-datetime...
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-debug-l1... E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-debug-l1...
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-errorhan... E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-errorhan...
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-fibers-l...
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-file-l1-... E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-file-l1-...
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-file-l1-... E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-file-l1-...
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-file-l2-... E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\backend\_internal\api-ms-win-core-file-l2-...
@@ -13337,7 +13338,7 @@ E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\fronte
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\frontend\favicon.svg E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\frontend\favicon.svg
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\frontend\icons.svg E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\frontend\icons.svg
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\frontend\index.html E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\frontend\index.html
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\frontend\assets\index-DJ9XFjAS.js E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\frontend\assets\index-Cv_1I5Jx.js
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\frontend\assets\index-DtmrFONE.css E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\frontend\assets\index-DtmrFONE.css
E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\lib\windows_x64\wx_key.dll E:\yh-ai\project\lzwcai-szyg\get_wechat\electron-launcher\build-resources\lib\windows_x64\wx_key.dll

View File

@@ -0,0 +1,217 @@
# 自动定时生成报告 — 方案设计
> **需求(已锁定)**:前端提供「手动 / 自动」两种模式。
> - **手动**:保持现状不变 —— 用户选时间段,点一下,跑 AI 分析 + 生成报告。
> - **自动**:用户设置一个间隔(如每 30 分钟 / 每小时 / 每天),系统到点**自动**跑「AI 分析 + 生成报告」全流程,无需人工点。
---
## 一、关键结论:地基已经存在,不用从零搭
调研现有代码后发现,自动模式的基础设施**已经埋好了,只是之前被主动关掉**
| 已有的东西 | 位置 | 现状 |
|-----------|------|------|
| 后台调度器 APScheduler | [scheduler.py](chatlog_fastAPI/scheduler.py) | 已在 `lifespan` 启动([main.py:43](chatlog_fastAPI/main.py#L43)),目前只做账号/数据库切换检测 |
| `groups.poll_interval` 字段 | [database.py:117](chatlog_fastAPI/database.py#L117) | 默认 300 秒,建群时可传([groups.py:12](chatlog_fastAPI/routers/groups.py#L12)),但**目前未被使用** —— 正好用来存「多久自动一次」 |
| `groups.cursor_seq` 字段 | [database.py:115](chatlog_fastAPI/database.py#L115) | 为增量游标预留,**目前未被使用** |
| `register_poll_job` 轮询注册 | [scheduler.py:16](chatlog_fastAPI/scheduler.py#L16) | **已废弃成空函数** |
**最关键的一条线索** —— [scheduler.py](chatlog_fastAPI/scheduler.py) 顶部注释:
> APScheduler — 仅保留 wxid/数据库切换检测。
> (不再运行任何 AI 分类轮询AI 分析改为用户手动按时间窗口触发)
这说明自动轮询曾经跑过,后来被撤掉、改成了现在的「手动」模式。**撤掉的原因,正是新自动模式必须解决的坑。**
---
## 二、手动模式现在是怎么跑的(自动模式要复用它)
理解现状是设计自动模式的前提。手动模式的完整链路:
```
前端 ChatlogPage 选时间段 → POST /api/groups/{id}/init {start_time, end_time}
└─ run_classify_window(group_id, start_ts, end_ts) [topic_engine.py:960]
1. 全量拉取该时间段的消息
2. _delete_ai_topics 删掉旧 AI 话题 ← 注意这里是「全删重建」
3. 分批 LLM 分类 → 合并 → 补充分配 → 落库 topics
(报告仍需用户在前端对每个话题点「生成」)
└─ run_summarize(topic_id) [summary_engine.py:471]
从 chatlog 拉回话题消息原文 → LLM 生成 Markdown → 写 knowledge_docs
```
> 注意:手动模式目前**分类和报告是分两步**的。分类出话题后,报告要用户在前端逐个点。
> 自动模式的「全自动」就体现在:分类完,自动把需要的话题也一并生成报告,不用人点。
---
## 三、必须正视的三个约束(也是当年撤掉自动轮询的原因)
### 约束 ① 分类是全局串行的,一次只能跑一个群
[topic_engine.py:26-27](chatlog_fastAPI/services/topic_engine.py#L26-L27)
```python
_classify_lock = asyncio.Lock()
_classifying_group: int | None = None
```
[groups.py:70-72](chatlog_fastAPI/routers/groups.py#L70-L72):只要有群在分析,手动 `/init` 直接返回 `409 已有群正在分析`
**影响**:多个群各自定时,会互相撞锁。
**对策**:自动调度**串成一条队列**,并且**自动任务要让位于手动**(用户手点时不被自动任务卡住)。
### 约束 ② 分类是「全量重跑」,不是增量
[topic_engine.py:1033](chatlog_fastAPI/services/topic_engine.py#L1033):每次分类先 `_delete_ai_topics` 把旧 AI 话题**全删重建**。
**影响**:自动模式如果每轮都对一个大窗口(如最近 7 天)重跑,每轮都把这些消息**全部重新喂 LLM**。间隔越短,成本越爆。**这几乎可以肯定是当年撤掉自动轮询的主因。**
**对策**:见第四节的窗口策略 —— 这是本方案唯一需要你拍板的关键点。
### 约束 ③ 自动跑报告 = 话题数 × LLM 调用
全自动生成报告时,每个有更新的话题都要调一次 LLM。
**对策**:报告只对**本轮真正有新消息变动的话题**生成/更新,不要把所有历史话题每轮重刷。
---
## 四、唯一需要你拍板的点:自动模式每轮分析「哪段消息」
这是整个方案的核心,直接决定**成本**和**改动量**。三个候选:
### 方案 A自上次运行至今增量按时间—— **推荐**
每轮窗口 = `[上次运行时间, 现在]`,只分析这段时间的新消息。
- **成本**:低且稳定,只跟「新消息量」相关,跟历史总量无关。
- **要解决的冲突**:现有 `run_classify_window``_delete_ai_topics` 全删。增量模式下**不能删历史话题**,要把新消息**补充归并**到已有话题或新建话题(复用现有 `_supplement_assignments` / `_coalesce_device_issue_topics` 的思路)。
- **改动量**:中。是本方案最核心的改造。
### 方案 B固定回看窗口每轮全量重跑最近 N 天)
每轮窗口 = `[现在 - N 天, 现在]`,直接复用现有 `run_classify_window`(含全删重建)。
- **成本**:随间隔频率线性上升,且每轮重复分析窗口内所有消息。
- **优点**:几乎不用改分类逻辑,最快跑通。
- **代价**:① 成本高;② 超出窗口的历史话题会被删掉(因为全删重建只覆盖窗口内消息)。**不适合做知识库沉淀。**
### 方案 C混合增量为主 + 每天全量重跑一次)
平时走方案 A 增量;每天凌晨全量重跑一次,重组当天话题、提升分类精度。
- **成本**:介于 A、B 之间。
- **优点**:兼顾成本与分类质量(增量归并精度略低于全量重组,每天校正一次)。
- **改动量**最大A 的全部 + 一个定时全量任务)。
> **我的建议**:先按**方案 A** 落地(成本可控、能沉淀知识库、符合"定时增量出报告"的直觉)。
> 若后续发现增量归并的分类精度不够,再加方案 C 的每日全量校正。
> **方案 B 不建议**,除非你只想快速验证、且能接受历史被覆盖。
---
## 五、推荐架构(按方案 A增量 + 全自动 + 每群独立间隔)
核心思路:**一个 tick 调度器扫表,挑到期的群丢进串行队列,逐个增量处理并出报告。**
```
APScheduler 每 60 秒 tick 一次(单个 job不是每群一个
├─ 扫描所有 groups挑出满足条件的群
│ auto_enabled = 1 且 now - last_run_at >= poll_interval
└─ 把到期的群按顺序逐个 await 处理(串行,复用 _classify_lock
└─ 对每个群 run_auto_analyze(group_id)
1. 读 groups.cursor_seq只拉游标之后的新消息get_messages 已支持 min_seq
2. 无新消息 → 更新 last_run_at结束
3. 增量分类:把新消息归并进已有话题 / 新建话题(不删历史)
4. 对「本轮有新增消息的话题」逐个 run_summarize 生成/更新报告
5. 更新 cursor_seq = 本轮最大 seqlast_run_at = now
```
### 为什么这样设计
| 设计点 | 原因 |
|--------|------|
| **单 tick job 扫表**,而非每群一个 APScheduler job | 新增/删群、改间隔都不用动调度器;天然串行,避开并发撞锁 |
| **用 `cursor_seq` 增量** | 字段现成,从根本上解决「全量重跑」的成本问题 |
| **串行队列 + 自动让位手动** | 符合现有 `_classify_lock` 全局串行约束,用户手点时优先 |
| **报告只生成有变动的话题** | 避免每轮把所有历史话题重刷 LLM |
| **间隔存 `groups.poll_interval`** | 字段现成,天然支持「每群单独设多久自动一次」 |
---
## 六、具体改动清单
### 6.1 数据库 schema[database.py](chatlog_fastAPI/database.py)
`groups` 表加一个字段(用现有 `PRAGMA table_info` 迁移模式补列):
```sql
auto_enabled INTEGER DEFAULT 0 -- 是否开启该群的自动分析
last_run_at DATETIME -- 上次自动跑的时间(判断是否到期)
```
`poll_interval`(间隔)、`cursor_seq`(增量游标)已存在,直接复用。
### 6.2 调度器([scheduler.py](chatlog_fastAPI/scheduler.py)
- 新增 `_auto_analyze_tick()` job`interval` 60 秒。
- tick 内扫 `groups`,挑 `auto_enabled=1 AND (last_run_at IS NULL OR now - last_run_at >= poll_interval)`
- 到期的群**逐个 `await`** 处理(串行,不用 `create_task` 并发)。
- 处理前检查 `get_classifying_group()`,若手动任务在跑则本轮跳过,下轮再来(让位手动)。
### 6.3 增量分析逻辑([topic_engine.py](chatlog_fastAPI/services/topic_engine.py)
新增 `run_auto_analyze(group_id)`(与现有 `run_classify_window` 并存,不破坏手动):
-`cursor_seq`,调 `chatlog_client.get_messages(talker, min_seq=cursor_seq+1, ...)` 只拉新消息(`get_messages` 已支持 `min_seq`,见 [chatlog_client.py:77](chatlog_fastAPI/services/chatlog_client.py#L77))。
- **不调用** `_delete_ai_topics`(这是和手动模式的关键区别)。
- 把新消息走「补充分配」逻辑归入已有话题或新建话题(复用 `_supplement_assignments``_coalesce_device_issue_topics`)。
- 收集本轮被改动 / 新建的 `topic_id` 列表。
- 更新 `cursor_seq``last_run_at`
### 6.4 自动报告([summary_engine.py](chatlog_fastAPI/services/summary_engine.py)
- `run_auto_analyze` 分类完后,对「本轮有变动的话题」逐个 `await run_summarize(topic_id, topic)`
- `run_summarize` 已支持「已存在则更新」([summary_engine.py:444-454](chatlog_fastAPI/services/summary_engine.py#L444-L454)),直接复用。
### 6.5 接口([groups.py](chatlog_fastAPI/routers/groups.py)
- `GroupPatch` 增加 `auto_enabled``poll_interval``patch_group` 支持更新。
- (可选)`GET /api/groups/{id}/auto-status`:返回 `auto_enabled / poll_interval / last_run_at / 下次预计运行时间`,给前端展示状态。
### 6.6 前端(群管理 / [SettingsPage.jsx](chatlab-web/frontend/src/pages/SettingsPage.jsx)
- 每个群一个「自动分析」开关 + 间隔下拉(如 30 分钟 / 1 小时 / 3 小时 / 每天)。
- 手动模式 UI 保持不变(现有「查询时间段 + 手动分析」照旧)。
- 调用 `patchGroup(groupId, { auto_enabled, poll_interval })`[api/index.js:232](chatlab-web/frontend/src/api/index.js#L232) 已有 `patchGroup`)。
- (可选)展示「上次自动分析时间 / 下次预计时间」。
---
## 七、风险与注意点
1. **LLM 成本是头号风险**:间隔越短、群越多,成本越高。方案 A 增量 + 报告只刷变动话题已是最省的组合;前端间隔下拉**建议最小 30 分钟**,不要给「每 5 分钟」这类档位。
2. **自动要让位手动**tick 处理前必须检查 `get_classifying_group()`,避免占着锁让用户手动分析报 409。
3. **chatlog 服务可能未就绪**tick 要捕获 `MessageIndexNotReady`[chatlog_client.py:17](chatlog_fastAPI/services/chatlog_client.py#L17)),跳过本轮、**不更新游标**,下轮重试。
4. **账号切换**`cursor_seq``groups` 表存,数据库随微信账号切换([database.py:80](chatlog_fastAPI/database.py#L80)),游标天然隔离。但要确认 tick 用的是 `get_active_db_path()` 当前库。
5. **增量分类精度权衡**:增量归并(新消息往已有话题靠)比全量重组精度略低。若要求高,用方案 C 每日全量校正一次。
6. **首次开启自动的冷启动**:群刚开自动且 `cursor_seq=0` 时,第一轮会把全部历史拉进来分析。建议开启自动时让用户先手动跑一次(或把 `cursor_seq` 初始化为当前最新 seq避免首轮巨量分析。
---
## 八、建议的落地顺序
1. **先确认第四节的窗口策略**(推荐方案 A
2. schema 加字段 + `groups.py` 接口打通(小改动,先让「开关 + 间隔」能存能读)。
3. 调度器接 tick + 串行队列骨架(先只 `log`「本应处理 group X」不真跑 LLM验证调度和让位逻辑正确
4. 接增量分类 `run_auto_analyze`(先不出报告,验证话题增量归并正确)。
5. 接自动报告。
6. 前端配置 UI自动开关 + 间隔下拉)。
7. 小间隔实测 LLM 成本与分类质量,再决定前端开放哪些间隔档位、是否需要方案 C。
---
*本文档为方案设计,未改动任何代码。确认第四节窗口策略后即可进入实现。*