From 646efa132ecdc7b64afe8d6fdcde6304cb2e4238 Mon Sep 17 00:00:00 2001 From: yuanzhipeng <2501363769@qq.com> Date: Wed, 24 Jun 2026 20:34:10 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(api):=20=E6=B7=BB=E5=8A=A0=E4=B8=87?= =?UTF-8?q?=E5=B7=9D=E5=B9=B3=E5=8F=B0=E6=A8=A1=E5=9E=8B=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E5=92=8C=E5=90=8C=E6=AD=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 函数按用途获取对应客户端 - 支持语音、视觉、报告等不同用途使用独立网关和密钥 ``` --- chatlab-web/frontend/src/api/wanchuan.js | 106 ++++++++ .../frontend/src/pages/SettingsPage.jsx | 232 +++++++++++++----- chatlog_fastAPI/config.py | 8 + chatlog_fastAPI/database.py | 62 ++--- chatlog_fastAPI/main.py | 16 +- chatlog_fastAPI/routers/chatlog_proxy.py | 16 +- chatlog_fastAPI/routers/files.py | 6 +- chatlog_fastAPI/routers/settings.py | 16 +- chatlog_fastAPI/services/ai_client.py | 45 ++-- chatlog_fastAPI/services/chatlog_client.py | 59 +++-- chatlog_fastAPI/services/http_client.py | 46 ++++ chatlog_fastAPI/services/media_parser.py | 110 ++++++--- chatlog_fastAPI/services/media_resolver.py | 87 +++---- chatlog_fastAPI/services/runtime_settings.py | 7 + chatlog_fastAPI/services/summary_engine.py | 5 +- electron-launcher/index.html | 36 ++- release/manifest.txt | 3 +- 自动定时生成报告-方案设计.md | 217 ++++++++++++++++ 18 files changed, 839 insertions(+), 238 deletions(-) create mode 100644 chatlog_fastAPI/services/http_client.py create mode 100644 自动定时生成报告-方案设计.md diff --git a/chatlab-web/frontend/src/api/wanchuan.js b/chatlab-web/frontend/src/api/wanchuan.js index c66d533..f146275 100644 --- a/chatlab-web/frontend/src/api/wanchuan.js +++ b/chatlab-web/frontend/src/api/wanchuan.js @@ -99,6 +99,112 @@ export async function uploadToKnowledgeBase(baseUrl, datasetId, file, secretLeve return resp.json() } +/** + * 按模型编码(code)获取平台模型配置 + * 接口:GET ${baseUrl}/api/system/model/getByCode/{code} + * 返回结构:data.providerModels.{ modelName, encryptedConfig:"{apiKey,endpointUrl}" } + * @param {string} baseUrl - 平台地址 + * @param {string} code - 模型编码,默认 generic(通用模型) + * @returns {Promise<{modelName: string, apiKey: string, endpointUrl: string}>} + */ +export async function getWanchuanModelConfig(baseUrl, code = 'generic') { + const resp = await fetch(`${baseUrl}/api/system/model/getByCode/${encodeURIComponent(code)}`, { + method: 'GET', + headers: authHeaders(baseUrl), + }) + if (!resp.ok) throw new Error(`获取模型[${code}]失败 HTTP ${resp.status}`) + const result = await resp.json() + const pm = result?.data?.providerModels + if (!pm) throw new Error(`平台未返回模型[${code}]配置${result?.msg ? `:${result.msg}` : ''}`) + + // encryptedConfig 是 JSON 字符串:{ apiKey, endpointUrl, ... } + let cfg = {} + if (pm.encryptedConfig) { + try { cfg = JSON.parse(pm.encryptedConfig) } catch { cfg = {} } + } + return { + modelName: pm.modelName || '', + apiKey: cfg.apiKey || '', + endpointUrl: cfg.endpointUrl || '', + } +} + +/** + * 从万川平台拉取模型配置并写入后端 AI 设置(/api/settings)。 + * + * 平台按用途分多个模型编码(code): + * - generic:通用聊天模型 → 话题分析(ai_model) + 报告生成(summary_model), + * 并提供共享的接口地址(ai_base_url) 与密钥(ai_api_key) + * - vision :视觉模型 → vision_model + * - voice :语音模型 → voice_model + * + * 仅写入拉取到的非空字段;vision/voice 拉取失败时跳过对应字段(不影响聊天模型同步), + * 避免某个 code 在平台未配置时把整次同步打断或清空已有配置。 + * + * 后端 voice/vision 各有独立的 base_url / api_key / model;为空时后端回退到 + * generic 的 ai_base_url / ai_api_key。这里按各自 code 分别拉取并写入对应字段。 + * + * @param {string} [baseUrl] - 平台地址,缺省时从后端万川配置读取 + * @param {{chat?: string, vision?: string, voice?: string}} [codes] - 各用途模型编码 + * @returns {Promise} 实际写入 /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} */ + 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 */ diff --git a/chatlab-web/frontend/src/pages/SettingsPage.jsx b/chatlab-web/frontend/src/pages/SettingsPage.jsx index a05fb08..119fa44 100644 --- a/chatlab-web/frontend/src/pages/SettingsPage.jsx +++ b/chatlab-web/frontend/src/pages/SettingsPage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { Copy, Check, Save, Link2, Loader, AlertCircle, ChevronDown, ChevronRight, RefreshCw, BookOpen, ExternalLink, FileText } from 'lucide-react' -import { wanchuanLogin, getWanchuanKnowledgeBases, uploadToKnowledgeBase, clearWanchuanToken, getWanchuanConfig, saveWanchuanConfig } from '../api/wanchuan' +import { wanchuanLogin, getWanchuanKnowledgeBases, uploadToKnowledgeBase, clearWanchuanToken, getWanchuanConfig, saveWanchuanConfig, syncWanchuanModelToSettings } from '../api/wanchuan' const CONFIG_ITEMS = [ { @@ -27,13 +27,41 @@ const CONFIG_ITEMS = [ }, ] -const AI_FIELDS = [ - { key: 'ai_base_url', label: 'AI 接口地址', placeholder: 'https://dashscope.aliyuncs.com/compatible-mode/v1', desc: '兼容 OpenAI 格式的 API 地址' }, - { key: 'ai_api_key', label: 'AI API Key', placeholder: 'sk-...', desc: '留空则 AI 功能不可用', type: 'password' }, - { key: 'ai_model', label: '话题分析模型', placeholder: 'qwen-plus', desc: '用于消息分类的模型' }, - { key: 'summary_model', label: '报告生成模型', placeholder: 'qwen-max', desc: '用于生成售后报告的模型' }, - { key: 'vision_model', label: '视觉模型', placeholder: 'qwen-vl-plus', desc: '用于图片/视频描述' }, - { key: 'voice_model', label: '语音模型', placeholder: 'paraformer-v2', desc: '用于语音转文字' }, +// 按模块分组:每个模块独立展示自己的接口地址 + 密钥 + 模型。 +// 话题分析用 ai_*(也是视觉/语音/报告的回退网关);报告生成用 summary_*。 +const AI_GROUPS = [ + { + group: '话题分析模型', + fields: [ + { key: 'ai_base_url', label: '接口地址', placeholder: 'https://dashscope.aliyuncs.com/compatible-mode/v1', desc: '兼容 OpenAI 格式的 API 地址' }, + { key: 'ai_api_key', label: '密钥', placeholder: 'sk-...', desc: '留空则 AI 功能不可用', type: 'password' }, + { key: 'ai_model', label: '模型', placeholder: 'qwen-plus', desc: '用于消息分类的模型' }, + ], + }, + { + group: '报告生成模型', + fields: [ + { key: 'summary_base_url', label: '接口地址', placeholder: '留空则用话题分析地址', desc: '独立网关;留空回退话题分析接口地址' }, + { key: 'summary_api_key', label: '密钥', placeholder: '留空则用话题分析密钥', desc: '留空回退话题分析密钥', type: 'password' }, + { key: 'summary_model', label: '模型', placeholder: 'qwen-max', desc: '用于生成售后报告的模型' }, + ], + }, + { + group: '视觉模型(图片 / 视频描述)', + fields: [ + { key: 'vision_base_url', label: '接口地址', placeholder: '留空则用话题分析地址', desc: '独立网关;留空回退话题分析接口地址' }, + { key: 'vision_api_key', label: '密钥', placeholder: '留空则用话题分析密钥', desc: '留空回退话题分析密钥', type: 'password' }, + { key: 'vision_model', label: '模型', placeholder: 'qwen-vl-plus', desc: '用于图片/视频描述' }, + ], + }, + { + group: '语音模型(语音转文字)', + fields: [ + { key: 'voice_base_url', label: '接口地址', placeholder: '留空则用话题分析地址', desc: '异步 ASR 网关,填到 .../api/v1;留空回退话题分析地址' }, + { key: 'voice_api_key', label: '密钥', placeholder: '留空则用话题分析密钥', desc: '留空回退话题分析密钥', type: 'password' }, + { key: 'voice_model', label: '模型', placeholder: 'paraformer-v2', desc: '用于语音转文字' }, + ], + }, ] const TOPIC_PROMPT_PLACEHOLDER = '例如:本群主要是某类设备售后群,请优先按设备部件、故障现象、处理进度来拆分话题;不要按客户名或日期拆分。' @@ -57,17 +85,37 @@ function CopyButton({ text }) { ) } -function AISettingsForm() { +function AISettingsForm({ refreshKey = 0 }) { const [form, setForm] = useState({}) const [saving, setSaving] = useState(false) + const [syncing, setSyncing] = useState(false) const [msg, setMsg] = useState('') - useEffect(() => { + const loadSettings = () => fetch('/api/settings') .then(r => r.json()) .then(data => setForm(data)) .catch(() => {}) - }, []) + + // refreshKey 由父级在万川平台登录/同步后递增,触发重新读取已写入的 AI 配置 + useEffect(() => { + loadSettings() + }, [refreshKey]) + + // 手动从万川平台拉取模型配置(base url / api key / 各模型),写入后端并回显 + const handleSyncFromPlatform = async () => { + setSyncing(true) + setMsg('') + try { + await syncWanchuanModelToSettings() + await loadSettings() + setMsg('已从平台获取') + setTimeout(() => setMsg(''), 2000) + } catch (e) { + setMsg(e.message || '获取失败') + } + setSyncing(false) + } const handleChange = (key, value) => { setForm(prev => ({ ...prev, [key]: value })) @@ -102,7 +150,7 @@ function AISettingsForm() { AI 模型配置
- 首次使用请填入你的 API Key 和接口地址,保存后立即生效,无需重启服务。 + 已对接万川平台时,登录后会自动从平台获取模型配置;也可点「从平台获取」手动同步。字段支持手动修改并保存。
{/* 未配置 API Key 时显示橙色警告横条 */} @@ -117,50 +165,55 @@ function AISettingsForm() { )} -
- {AI_FIELDS.map((field, i) => ( -
-
-
{field.label}
-
{field.desc}
-
- handleChange(field.key, e.target.value)} - style={{ - flex: 1, - fontSize: 13, - padding: '7px 12px', - border: '1px solid var(--border)', - borderRadius: 6, - background: 'var(--surface-2)', - color: 'var(--text)', - outline: 'none', - }} - /> - {/* 配置状态指示点:绿色=已配置,红色=未配置 */} -
+ {AI_GROUPS.map((g) => ( +
+
{g.group}
+
+ {g.fields.map((field, i) => ( +
+
+
{field.label}
+
{field.desc}
+
+ handleChange(field.key, e.target.value)} + style={{ + flex: 1, + fontSize: 13, + padding: '7px 12px', + border: '1px solid var(--border)', + borderRadius: 6, + background: 'var(--surface-2)', + color: 'var(--text)', + outline: 'none', + }} + /> + {/* 配置状态指示点:绿色=已配置,红色=未配置 */} +
+
+ ))}
- ))} -
+
+ ))}
AI 话题分析提示词
@@ -207,7 +260,27 @@ function AISettingsForm() { {saving ? '保存中...' : '保存配置'} - {msg && {msg}} + + {msg && {msg}}
) @@ -217,7 +290,7 @@ function AISettingsForm() { * 万川 AI 平台对接配置 * 支持输入平台地址 + 账号密码,测试连接后可展示平台模块内容(预留) */ -function WanchuanPlatformForm() { +function WanchuanPlatformForm({ onModelSynced }) { const [form, setForm] = useState({ platformUrl: '', username: '', password: '' }) const [connecting, setConnecting] = useState(false) const [connected, setConnected] = useState(false) @@ -232,6 +305,9 @@ function WanchuanPlatformForm() { const [kbLoading, setKbLoading] = useState(false) const [kbError, setKbError] = useState('') const [kbExpanded, setKbExpanded] = useState(false) + // 模型同步状态:成功提示已回填,失败展示原因(便于排查路径/token/平台未配模型) + const [modelSyncMsg, setModelSyncMsg] = useState('') + const [modelSyncError, setModelSyncError] = useState(false) // 后端为唯一数据源:打开设置页时拉取已保存配置并填入表单, // 然后按已保存凭证自动恢复会话(拉取知识库列表)。 @@ -256,9 +332,14 @@ function WanchuanPlatformForm() { fetchKnowledgeBases(saved.platformUrl, { allowRelogin: true, savedKbId: saved.selectedKbId, credsForRelogin: saved }) } else if (saved.username && saved.password) { autoLogin(saved).then(ok => { - if (ok) fetchKnowledgeBases(saved.platformUrl, { savedKbId: saved.selectedKbId }) + if (ok) { + fetchKnowledgeBases(saved.platformUrl, { savedKbId: saved.selectedKbId }) + } }) } + // 注意:挂载时不自动 syncModel。自动同步会用平台配置覆盖用户在「AI 模型配置」里 + // 手动改过并保存的值(曾导致"改了保存后重进页面又被重置")。模型同步只在用户 + // 显式操作时进行:点「测试连接」或「从平台获取」。 }) return () => { cancelled = true } }, []) @@ -364,6 +445,23 @@ function WanchuanPlatformForm() { return uploadToKnowledgeBase(form.platformUrl, targetDatasetId, file) } + // 登录成功后从平台拉取模型配置并写入后端 AI 设置;成功后通知父级刷新 AI 表单。 + // 同步结果用 modelSyncMsg/Error 展示,便于排查(路径错=404、token 失效=401、平台未配模型等); + // 不阻断主流程(知识库等仍可用)。 + const syncModel = async (baseUrl) => { + setModelSyncMsg('') + setModelSyncError(false) + try { + const written = await syncWanchuanModelToSettings(baseUrl) + onModelSynced?.() + const n = Object.keys(written || {}).length + setModelSyncMsg(`已从平台同步模型配置(${n} 项),已回填到 AI 模型配置`) + } catch (e) { + setModelSyncError(true) + setModelSyncMsg(`模型配置同步失败:${e.message || '未知错误'}`) + } + } + const handleTestConnection = async () => { if (!form.platformUrl) { setConnectError('请输入平台地址') @@ -389,8 +487,9 @@ function WanchuanPlatformForm() { setConnected(true) handleSave() - // 登录成功后自动拉取岗位知识库列表 + // 登录成功后自动拉取岗位知识库列表 + 同步平台模型配置 fetchKnowledgeBases(form.platformUrl) + syncModel(form.platformUrl) } catch (e) { setConnectError(e.message || '登录失败') } finally { @@ -564,6 +663,11 @@ function WanchuanPlatformForm() { {connectError} )} + {modelSyncMsg && ( + + {modelSyncError ? : } {modelSyncMsg} + + )} {kbExpanded && !kbLoading && knowledgeBases.length === 0 && (
暂无岗位知识库,请先点击「测试连接」登录平台 @@ -714,6 +818,8 @@ function WanchuanPlatformForm() { } export default function SettingsPage() { + // 万川平台同步模型配置后递增此值,触发 AISettingsForm 重新读取后端 AI 配置 + const [aiRefreshKey, setAiRefreshKey] = useState(0) return (
@@ -722,11 +828,11 @@ export default function SettingsPage() { 系统各服务地址及 AI 配置管理。
- {/* AI 配置表单 */} - + {/* 万川 AI 平台对接:放最上面,登录后自动获取下方的 AI 模型配置 */} + setAiRefreshKey(k => k + 1)} /> - {/* 万川 AI 平台对接 */} - + {/* AI 配置表单:由上方万川平台登录后自动填充,也可手动修改 */} + {CONFIG_ITEMS.map((group) => (
diff --git a/chatlog_fastAPI/config.py b/chatlog_fastAPI/config.py index 0869c66..4faf4c0 100644 --- a/chatlog_fastAPI/config.py +++ b/chatlog_fastAPI/config.py @@ -30,6 +30,14 @@ class Settings(BaseSettings): summary_model: str = "" # 不设默认值,必须由用户在设置页配置 voice_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() static_dir: str = _default_static_dir() db_path: str = str(Path(_default_data_dir()) / "data" / "knowledge.db") diff --git a/chatlog_fastAPI/database.py b/chatlog_fastAPI/database.py index f6c96ee..10320d2 100644 --- a/chatlog_fastAPI/database.py +++ b/chatlog_fastAPI/database.py @@ -1,10 +1,10 @@ import aiosqlite import asyncio -import httpx import logging import time from pathlib import Path from config import settings +from services.http_client import shared_client log = logging.getLogger(__name__) @@ -44,35 +44,35 @@ async def get_current_wxid(force: bool = False): return _resolved_wxid # 重新解析当前 wxid base = settings.chatlog_base_url - async with httpx.AsyncClient(trust_env=False, timeout=10.0) as client: - try: - r = await client.get(f"{base}/api/v1/chatlog", params={"talker": "filehelper", "limit": 100, "time": "1970-01-01,2099-12-31", "format": "json"}) - if r.status_code == 200: - data = r.json() - for msg in data.get("items", []): - if msg.get("isSelf"): - _resolved_wxid = msg.get("sender") - _wxid_last_resolved = time.time() - return _resolved_wxid - except Exception: - pass + client = shared_client() + try: + 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: + data = r.json() + for msg in data.get("items", []): + if msg.get("isSelf"): + _resolved_wxid = msg.get("sender") + _wxid_last_resolved = time.time() + return _resolved_wxid + except Exception: + pass - try: - r = await client.get(f"{base}/api/v1/chatroom", params={"limit": 10, "format": "json"}) - if r.status_code == 200: - rooms = r.json().get("items", []) - for room in rooms: - 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"}) - if r2.status_code == 200: - data2 = r2.json() - for msg in data2.get("items", []): - if msg.get("isSelf"): - _resolved_wxid = msg.get("sender") - _wxid_last_resolved = time.time() - return _resolved_wxid - except Exception: - pass + try: + r = await client.get(f"{base}/api/v1/chatroom", params={"limit": 10, "format": "json"}, timeout=10.0) + if r.status_code == 200: + rooms = r.json().get("items", []) + for room in rooms: + 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"}, timeout=10.0) + if r2.status_code == 200: + data2 = r2.json() + for msg in data2.get("items", []): + if msg.get("isSelf"): + _resolved_wxid = msg.get("sender") + _wxid_last_resolved = time.time() + return _resolved_wxid + except Exception: + pass if force: reset_wxid_cache() return "default" @@ -85,6 +85,10 @@ async def update_db_path(force: bool = False): log.info(f"Switching database to {new_path}") _current_db_path = 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 def get_active_db_path(): diff --git a/chatlog_fastAPI/main.py b/chatlog_fastAPI/main.py index d48d663..bd83ab1 100644 --- a/chatlog_fastAPI/main.py +++ b/chatlog_fastAPI/main.py @@ -7,7 +7,6 @@ from contextlib import asynccontextmanager import asyncio import logging 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 scheduler import start_scheduler 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 services.chatlog_context import get_chatlog_context, update_chatlog_context from services.media_resolver import diagnose_media +from services.http_client import shared_client, close_shared_client log = logging.getLogger(__name__) @@ -49,6 +49,7 @@ async def lifespan(app: FastAPI): await task except asyncio.CancelledError: pass + await close_shared_client() app = FastAPI(lifespan=lifespan) @@ -79,11 +80,11 @@ async def health(): chatlog_ok = False chatlog_error = "" try: - async with httpx.AsyncClient(timeout=3.0, trust_env=False) as client: - resp = await client.get(f"{settings.chatlog_base_url}/api/v1/session", params={"limit": 1, "format": "json"}) - chatlog_ok = resp.status_code == 200 - if not chatlog_ok: - chatlog_error = f"HTTP {resp.status_code}" + client = shared_client() + 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 + if not chatlog_ok: + chatlog_error = f"HTTP {resp.status_code}" except Exception as e: chatlog_error = str(e) @@ -101,6 +102,9 @@ async def health(): @app.post("/api/system/refresh-account") async def refresh_account(): reset_wxid_cache() + # 用户主动点“重新识别账号”=要最新数据,无条件清掉头像/contact 库缓存 + from services.chatlog_client import chatlog_client + chatlog_client.reset_account_cache() db_path = await update_db_path(force=True) wxid = await get_current_wxid() return {"ok": True, "wxid": wxid, "db_path": db_path} diff --git a/chatlog_fastAPI/routers/chatlog_proxy.py b/chatlog_fastAPI/routers/chatlog_proxy.py index 40628f2..6d918ff 100644 --- a/chatlog_fastAPI/routers/chatlog_proxy.py +++ b/chatlog_fastAPI/routers/chatlog_proxy.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Request from fastapi.responses import Response, StreamingResponse from config import settings +from services.http_client import shared_client 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" } - async with httpx.AsyncClient(timeout=None, trust_env=False, follow_redirects=True) as client: - upstream = await client.request( - request.method, - target, - content=body if body else None, - headers=headers, - ) + client = shared_client() + upstream = await client.request( + request.method, + target, + content=body if body else None, + headers=headers, + timeout=None, + ) response_headers = _copy_headers(upstream.headers) return StreamingResponse( diff --git a/chatlog_fastAPI/routers/files.py b/chatlog_fastAPI/routers/files.py index dd4400e..82845cc 100644 --- a/chatlog_fastAPI/routers/files.py +++ b/chatlog_fastAPI/routers/files.py @@ -7,12 +7,12 @@ import tempfile from pathlib import Path from urllib.parse import quote -import httpx from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse, StreamingResponse from config import settings from services.chatlog_client import chatlog_client +from services.http_client import shared_client 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 = ""): url = f"{settings.chatlog_base_url}/file/{quote(md5, safe='')}" try: - async with httpx.AsyncClient(timeout=30, trust_env=False, follow_redirects=True) as client: - resp = await client.get(url) + client = shared_client() + resp = await client.get(url, timeout=30) except Exception: return None diff --git a/chatlog_fastAPI/routers/settings.py b/chatlog_fastAPI/routers/settings.py index 8b71b13..589de07 100644 --- a/chatlog_fastAPI/routers/settings.py +++ b/chatlog_fastAPI/routers/settings.py @@ -10,8 +10,13 @@ router = APIRouter(prefix="/api/settings", tags=["settings"]) EDITABLE_KEYS = [ "ai_base_url", "ai_api_key", "ai_model", "summary_model", "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 模型配置。 # 存到后端 SQLite(app_settings 表)后,配置不再依赖前端 localStorage 的 origin, # 桌面应用(exe)即便后端端口/origin 变化也能跨次启动恢复平台地址、账号、密码与已选知识库。 @@ -35,7 +40,7 @@ async def get_settings(db: aiosqlite.Connection = Depends(get_db)): rows = await cur.fetchall() for row in rows: 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: if k not in result: result[k] = "" @@ -50,6 +55,12 @@ class SettingsUpdate(BaseModel): vision_model: Optional[str] = None voice_model: 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("") @@ -58,7 +69,8 @@ async def update_settings(body: SettingsUpdate, db: aiosqlite.Connection = Depen for k, v in updates.items(): if k not in EDITABLE_KEYS: continue - if k == "ai_api_key" and "*" in v: + # 密钥字段含 `*` 说明是 GET 返回的打码值,未被用户真正修改,跳过避免覆盖真实值 + if k in SECRET_KEYS and "*" in v: continue await db.execute( "INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", diff --git a/chatlog_fastAPI/services/ai_client.py b/chatlog_fastAPI/services/ai_client.py index 5df70ff..3ec57f2 100644 --- a/chatlog_fastAPI/services/ai_client.py +++ b/chatlog_fastAPI/services/ai_client.py @@ -3,29 +3,44 @@ from openai import AsyncOpenAI 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] = {} _http_client_cache: dict[tuple[str, str], httpx.AsyncClient] = {} -async def get_openai_client() -> tuple[AsyncOpenAI, dict]: - settings = await get_ai_settings() - cache_key = ( - settings.get("ai_base_url") or "", - settings.get("ai_api_key") or "", - ) - +def _get_client(base_url: str, api_key: str) -> AsyncOpenAI: + cache_key = (base_url or "", api_key or "") 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_cache[cache_key] = http_client _client_cache[cache_key] = AsyncOpenAI( - api_key=settings.get("ai_api_key") or "missing", - base_url=settings.get("ai_base_url"), + api_key=api_key or "missing", + base_url=base_url or None, 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 diff --git a/chatlog_fastAPI/services/chatlog_client.py b/chatlog_fastAPI/services/chatlog_client.py index f188c7a..4a362a0 100644 --- a/chatlog_fastAPI/services/chatlog_client.py +++ b/chatlog_fastAPI/services/chatlog_client.py @@ -2,6 +2,7 @@ import httpx import asyncio from typing import List from config import settings +from services.http_client import shared_client class ChatlogHTTPError(RuntimeError): @@ -21,13 +22,21 @@ class ChatlogClient: def __init__(self): self.base = settings.chatlog_base_url 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: try: - async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: - r = await client.get(f"{self.base}{path}", params=params) - r.raise_for_status() - return r.json() + client = shared_client() + r = await client.get(f"{self.base}{path}", params=params, timeout=timeout) + r.raise_for_status() + return r.json() except httpx.TimeoutException: raise RuntimeError(f"chatlog timeout: GET {path}") except httpx.HTTPStatusError as e: @@ -38,10 +47,10 @@ class ChatlogClient: async def _post(self, path: str, body: dict, timeout: float = 30.0) -> dict: try: - async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: - r = await client.post(f"{self.base}{path}", json=body) - r.raise_for_status() - return r.json() + client = shared_client() + r = await client.post(f"{self.base}{path}", json=body, timeout=timeout) + r.raise_for_status() + return r.json() except httpx.TimeoutException: raise RuntimeError(f"chatlog timeout: POST {path}") except httpx.HTTPStatusError as e: @@ -128,15 +137,16 @@ class ChatlogClient: async def get_message(self, talker: str, seq: int) -> dict | None: try: - async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: - r = await client.get( - f"{self.base}/api/v1/chatlog/message", - params={"talker": talker, "seq": seq}, - ) - if r.status_code == 404: - return None - r.raise_for_status() - return r.json() + client = shared_client() + r = await client.get( + f"{self.base}/api/v1/chatlog/message", + params={"talker": talker, "seq": seq}, + timeout=10.0, + ) + if r.status_code == 404: + return None + r.raise_for_status() + return r.json() except httpx.TimeoutException: raise RuntimeError("chatlog timeout: get_message") except Exception as e: @@ -174,6 +184,11 @@ class ChatlogClient: 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: try: db_list = await self._get("/api/v1/db", {}) @@ -185,15 +200,17 @@ class ChatlogClient: safe_wxid = wxid.replace("'", "''") 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} + url = "" try: rows = await self._get("/api/v1/db/query", params, timeout=5.0) if rows: url = rows[0].get("small_head_url") or rows[0].get("big_head_url") or "" - if url: - return url except Exception: - pass - return "" + # 查询失败不写缓存,下次仍可重试 + return "" + # 命中(含确定无头像的空串)都缓存,避免重复查询 + self._avatar_cache[wxid] = url + return url async def get_db_paths(self) -> dict: data = await self._get("/api/v1/db", {}, timeout=10.0) diff --git a/chatlog_fastAPI/services/http_client.py b/chatlog_fastAPI/services/http_client.py new file mode 100644 index 0000000..64fac42 --- /dev/null +++ b/chatlog_fastAPI/services/http_client.py @@ -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 diff --git a/chatlog_fastAPI/services/media_parser.py b/chatlog_fastAPI/services/media_parser.py index b32b351..ffdf772 100644 --- a/chatlog_fastAPI/services/media_parser.py +++ b/chatlog_fastAPI/services/media_parser.py @@ -1,18 +1,19 @@ +import asyncio import base64 import logging import httpx 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.runtime_settings import get_ai_settings log = logging.getLogger(__name__) - -async def _get_ai_client(): - return await get_openai_client() +# 语音异步 ASR 默认网关(阿里云)。voice_base_url 为空时回退到此; +# 提交任务/轮询的子路径由代码自动拼接,配置只需填到 .../api/v1 这一层。 +DEFAULT_ASR_BASE_URL = "https://dashscope.aliyuncs.com/api/v1" 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 不能为空") ai = await get_ai_settings() - if not ai.get("ai_api_key"): - raise HTTPException(503, "AI 服务未配置,请在设置页填写 AI API Key") - if kind == "voice" and not ai.get("voice_model"): - raise HTTPException(503, "语音模型未配置,请在设置页填写语音模型名称,例如 paraformer-v2") - if kind in ("image", "video") and not ai.get("vision_model"): - raise HTTPException(503, "视觉模型未配置,请在设置页填写视觉模型名称,例如 qwen-vl-plus") + # voice/vision 各自有独立 url/key,为空则回退全局 ai_api_key + if kind == "voice": + if not (ai.get("voice_api_key") or ai.get("ai_api_key")): + raise HTTPException(503, "AI 服务未配置,请在设置页填写语音密钥或 AI API Key") + if not ai.get("voice_model"): + 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) 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)} -async def _parse_voice(media_bytes: bytes, content_type: str) -> str: - b64_audio = base64.b64encode(media_bytes).decode() - audio_ct = content_type.lower() - if "silk" in audio_ct or "x-silk" in audio_ct: - audio_mime = "audio/silk" - elif "amr" in audio_ct: - audio_mime = "audio/amr" - elif "ogg" in audio_ct or "opus" in audio_ct: - audio_mime = "audio/ogg" - elif "wav" in audio_ct: - audio_mime = "audio/wav" - else: - audio_mime = "audio/mpeg" +def _audio_mime(content_type: str) -> str: + """由 chatlog 返回的 content_type 推断音频 MIME(用于 data URI)。""" + ct = content_type.lower() + if "silk" in ct or "x-silk" in ct: + return "audio/silk" + if "amr" in ct: + return "audio/amr" + if "ogg" in ct or "opus" in ct: + return "audio/ogg" + if "wav" in ct: + return "audio/wav" + return "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 = { - "Authorization": f"Bearer {ai['ai_api_key']}", + "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } + submit_url = f"{base}/services/audio/asr/transcription" async with httpx.AsyncClient(timeout=60) as http: submit = await http.post( - "https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription", + submit_url, headers={**asr_headers, "X-DashScope-Async": "enable"}, json={ - "model": ai["voice_model"], + "model": voice_model, "input": {"file_urls": [data_uri]}, "parameters": {"language_hints": ["zh", "en"]}, }, timeout=30, ) - submit_data = submit.json() + submit_data = _asr_json(submit, submit_url) 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") if not task_id: raise HTTPException(500, f"未获取到 task_id: {submit_data}") for _ in range(30): - import asyncio - await asyncio.sleep(1) poll = await http.get( - f"https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}", + f"{base}/tasks/{task_id}", headers=asr_headers, timeout=10, ) - poll_data = poll.json() + poll_data = _asr_json(poll, f"{base}/tasks/{task_id}") status = poll_data.get("output", {}).get("task_status", "") if status == "SUCCEEDED": 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}" 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( model=ai["vision_model"], messages=[ diff --git a/chatlog_fastAPI/services/media_resolver.py b/chatlog_fastAPI/services/media_resolver.py index 3b8d373..84fc969 100644 --- a/chatlog_fastAPI/services/media_resolver.py +++ b/chatlog_fastAPI/services/media_resolver.py @@ -10,6 +10,7 @@ from fastapi import HTTPException from config import settings from services.chatlog_context import get_chatlog_context +from services.http_client import shared_client log = logging.getLogger(__name__) @@ -100,25 +101,25 @@ async def diagnose_media(kind: str, key: str) -> dict: "chatlog_context": get_chatlog_context(), } - async with httpx.AsyncClient(timeout=20, trust_env=False, follow_redirects=True) as client: - try: - resp = await client.get(url) - content_type = resp.headers.get("content-type", "") - result.update( - { - "status_code": resp.status_code, - "content_type": content_type, - "content_length": len(resp.content or b""), - "ok": resp.status_code < 400 and bool(resp.content), - } - ) - if resp.status_code >= 400: - result["error"] = _download_failure_message(kind, key, resp.status_code, resp.text[:500]) - result["response_preview"] = resp.text[:500] - elif not resp.content: - result["error"] = "chatlog 返回了空媒体文件" - except Exception as exc: - result.update({"error": f"无法连接 chatlog 媒体接口: {exc}", "exception": str(exc)}) + client = shared_client() + try: + resp = await client.get(url, timeout=20) + content_type = resp.headers.get("content-type", "") + result.update( + { + "status_code": resp.status_code, + "content_type": content_type, + "content_length": len(resp.content or b""), + "ok": resp.status_code < 400 and bool(resp.content), + } + ) + if resp.status_code >= 400: + result["error"] = _download_failure_message(kind, key, resp.status_code, resp.text[:500]) + result["response_preview"] = resp.text[:500] + elif not resp.content: + result["error"] = "chatlog 返回了空媒体文件" + except Exception as exc: + result.update({"error": f"无法连接 chatlog 媒体接口: {exc}", "exception": str(exc)}) if kind == "voice": 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 不能为空") url = _media_url(kind, key, thumb=kind in {"image", "video"}) - async with httpx.AsyncClient(timeout=60, trust_env=False, follow_redirects=True) as client: - try: - resp = await client.get(url) - resp.raise_for_status() - except httpx.HTTPStatusError as exc: - diagnostics = await diagnose_media(kind, key) - log.warning("[media_resolver] media download failed: %s", diagnostics) - raise HTTPException( - 502, - { - "message": _download_failure_message(kind, key, exc.response.status_code, exc.response.text[:500]), - "diagnostics": diagnostics, - }, - ) - except Exception as exc: - diagnostics = await diagnose_media(kind, key) - log.warning("[media_resolver] media download exception: %s", diagnostics) - raise HTTPException( - 502, - { - "message": _download_failure_message(kind, key, None, str(exc)), - "diagnostics": diagnostics, - }, - ) + client = shared_client() + try: + resp = await client.get(url, timeout=60) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + diagnostics = await diagnose_media(kind, key) + log.warning("[media_resolver] media download failed: %s", diagnostics) + raise HTTPException( + 502, + { + "message": _download_failure_message(kind, key, exc.response.status_code, exc.response.text[:500]), + "diagnostics": diagnostics, + }, + ) + except Exception as exc: + diagnostics = await diagnose_media(kind, key) + log.warning("[media_resolver] media download exception: %s", diagnostics) + raise HTTPException( + 502, + { + "message": _download_failure_message(kind, key, None, str(exc)), + "diagnostics": diagnostics, + }, + ) if not resp.content: diagnostics = await diagnose_media(kind, key) diff --git a/chatlog_fastAPI/services/runtime_settings.py b/chatlog_fastAPI/services/runtime_settings.py index 3a10b5c..dc351cd 100644 --- a/chatlog_fastAPI/services/runtime_settings.py +++ b/chatlog_fastAPI/services/runtime_settings.py @@ -27,6 +27,13 @@ async def get_ai_settings() -> dict: "vision_model": "", "voice_model": "", "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: diff --git a/chatlog_fastAPI/services/summary_engine.py b/chatlog_fastAPI/services/summary_engine.py index bed9e71..fa3287d 100644 --- a/chatlog_fastAPI/services/summary_engine.py +++ b/chatlog_fastAPI/services/summary_engine.py @@ -13,7 +13,7 @@ import aiosqlite from urllib.parse import quote 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.message_formatter import append_quote_text, extract_contents, extract_quote from services.report_learning import build_report_learning_context @@ -25,7 +25,8 @@ SUMMARY_LLM_TIMEOUT_SECONDS = 300 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: diff --git a/electron-launcher/index.html b/electron-launcher/index.html index 4506c8b..7629820 100644 --- a/electron-launcher/index.html +++ b/electron-launcher/index.html @@ -159,7 +159,8 @@ height: 660px; transform: translate(-50%, -50%); pointer-events: none; - filter: drop-shadow(0 48px 55px rgba(0,0,0,0.78)); + /* 原本的 drop-shadow 滤镜会随手臂无限动画每帧重算整块阴影,极耗 GPU。 + SVG 内部已有一个静态阴影椭圆,这里移除滤镜即可。 */ } .robot { @@ -203,9 +204,9 @@ padding: 26px; border: 1px solid var(--line-strong); 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); - backdrop-filter: blur(14px); } .panel-title { @@ -467,8 +468,8 @@ z-index: 100; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.72); - backdrop-filter: blur(8px); + /* 去掉 backdrop-filter:blur,改用更深的纯遮罩,避免弹窗出现时整屏每帧重模糊 */ + background: rgba(0, 0, 0, 0.82); } .modal-overlay.show { @@ -751,13 +752,26 @@ let startingAll = false; let refreshingAccount = false; + let robotRafPending = false + let robotNextX = 0 + let robotNextY = 0 + launcher.addEventListener('mousemove', (event) => { - const rect = launcher.getBoundingClientRect(); - const x = ((event.clientX - rect.left) / rect.width - 0.5) * 2; - const y = ((event.clientY - 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)`; + // 缓存目标坐标,用 rAF 合并到每帧最多一次写入,避免每个 mousemove 事件 + // 都触发 getBoundingClientRect()(强制同步布局)和 robotHead 滤镜重算。 + robotNextX = event.clientX + robotNextY = event.clientY + if (robotRafPending) return + 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', () => { diff --git a/release/manifest.txt b/release/manifest.txt index 4000e01..f6cd04f 100644 --- a/release/manifest.txt +++ b/release/manifest.txt @@ -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-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-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-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\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\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\lib\windows_x64\wx_key.dll diff --git a/自动定时生成报告-方案设计.md b/自动定时生成报告-方案设计.md new file mode 100644 index 0000000..77e539c --- /dev/null +++ b/自动定时生成报告-方案设计.md @@ -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。 + +--- + +*本文档为方案设计,未改动任何代码。确认第四节窗口策略后即可进入实现。*