```
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:
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 模型配置。
|
||||||
# 存到后端 SQLite(app_settings 表)后,配置不再依赖前端 localStorage 的 origin,
|
# 存到后端 SQLite(app_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 = ?",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
46
chatlog_fastAPI/services/http_client.py
Normal file
46
chatlog_fastAPI/services/http_client.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""共享的 httpx.AsyncClient。
|
||||||
|
|
||||||
|
历史问题:后端每次访问 chatlog.exe(127.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
|
||||||
@@ -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_url(OpenAI 兼容的 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=[
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
217
自动定时生成报告-方案设计.md
Normal file
217
自动定时生成报告-方案设计.md
Normal 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 = 本轮最大 seq,last_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。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本文档为方案设计,未改动任何代码。确认第四节窗口策略后即可进入实现。*
|
||||||
Reference in New Issue
Block a user