Files
get_wechat/chatlog_fastAPI/services/chatlog_client.py
yuanzhipeng 646efa132e ```
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 函数按用途获取对应客户端
- 支持语音、视觉、报告等不同用途使用独立网关和密钥
```
2026-06-24 20:34:10 +08:00

221 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import httpx
import asyncio
from typing import List
from config import settings
from services.http_client import shared_client
class ChatlogHTTPError(RuntimeError):
def __init__(self, status_code: int, method: str, path: str, detail: str):
self.status_code = status_code
self.method = method
self.path = path
self.detail = detail
super().__init__(f"chatlog HTTP {status_code}: {method} {path} body={detail!r}")
class MessageIndexNotReady(RuntimeError):
"""Raised when chatlog has sessions but its message time index is not usable yet."""
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:
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:
detail = self._response_detail(e.response)
raise ChatlogHTTPError(e.response.status_code, "GET", path, detail)
except Exception as e:
raise RuntimeError(f"chatlog request failed: {e}")
async def _post(self, path: str, body: dict, timeout: float = 30.0) -> dict:
try:
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:
detail = self._response_detail(e.response)
raise ChatlogHTTPError(e.response.status_code, "POST", path, detail)
except Exception as e:
raise RuntimeError(f"chatlog request failed: {e}")
def _response_detail(self, response: httpx.Response) -> str:
try:
body = response.json()
if isinstance(body, dict):
return str(body.get("error") or body.get("detail") or body)
return str(body)
except Exception:
return response.text
async def get_messages(
self,
talker: str,
time: str = "",
sender: str = "",
keyword: str = "",
min_seq: int = 0,
limit: int = 100,
offset: int = 0,
) -> dict:
params: dict = {
"talker": talker,
"limit": limit,
"offset": offset,
"format": "json",
}
if time:
params["time"] = time
else:
params["time"] = "1970-01-01,2099-12-31"
if sender:
params["sender"] = sender
if keyword:
params["keyword"] = keyword
if min_seq > 0:
params["min_seq"] = min_seq
try:
data = await self._get("/api/v1/chatlog", params)
except ChatlogHTTPError as e:
detail = e.detail.lower()
if e.status_code == 404 and "time range not found" in detail:
await asyncio.sleep(0.2)
try:
data = await self._get("/api/v1/chatlog", params)
except ChatlogHTTPError as retry_error:
if (
retry_error.status_code == 404
and "time range not found" in retry_error.detail.lower()
):
raise MessageIndexNotReady(
"自动解密仍在处理消息库,请稍后刷新聊天记录;如果长时间为空,请在微信里打开该聊天并翻看历史消息。"
) from retry_error
raise
elif e.status_code == 404 and "not found" in detail:
# chatlog sometimes reports a valid date window as missing while it is warming/querying.
await asyncio.sleep(0.2)
try:
data = await self._get("/api/v1/chatlog", params)
except ChatlogHTTPError as retry_error:
retry_detail = retry_error.detail.lower()
if (
retry_error.status_code == 404
and "time range not found" in retry_detail
):
raise MessageIndexNotReady(
"自动解密仍在处理消息库,请稍后刷新聊天记录;如果长时间为空,请在微信里打开该聊天并翻看历史消息。"
) from retry_error
if retry_error.status_code == 404 and "not found" in retry_detail:
return {"total": 0, "items": []}
raise
else:
raise
if isinstance(data, dict):
return data
return {"total": len(data), "items": data}
async def get_message(self, talker: str, seq: int) -> dict | None:
try:
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:
raise RuntimeError(f"chatlog request failed: {e}")
async def get_messages_batch(self, talker: str, seqs: List[int]) -> dict:
return await self._post("/api/v1/chatlog/batch", {"talker": talker, "seqs": seqs})
async def get_chatrooms(self, keyword: str = "", limit: int = 100, offset: int = 0) -> dict:
params: dict = {"limit": limit, "offset": offset, "format": "json"}
if keyword:
params["keyword"] = keyword
return await self._get("/api/v1/chatroom", params, timeout=10.0)
async def get_contacts(self, keyword: str = "", limit: int = 100, offset: int = 0) -> dict:
params: dict = {"limit": limit, "offset": offset, "format": "json"}
if keyword:
params["keyword"] = keyword
return await self._get("/api/v1/contact", params, timeout=10.0)
async def get_chatroom_members(self, talker: str, time: str = "") -> dict:
params: dict = {"talker": talker}
if time:
params["time"] = time
return await self._get("/api/v1/chatroom/members", params)
async def get_sessions(self, keyword: str = "", limit: int = 500) -> list:
params: dict = {"limit": limit, "format": "json"}
if keyword:
params["keyword"] = keyword
data = await self._get("/api/v1/session", params, timeout=15.0)
if isinstance(data, list):
return data
return data.get("items", data.get("data", []))
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", {})
self._contact_db_file = (db_list.get("contact") or [""])[0]
except Exception:
self._contact_db_file = ""
if not self._contact_db_file:
return ""
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 ""
except Exception:
# 查询失败不写缓存,下次仍可重试
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)
return data if isinstance(data, dict) else {}
chatlog_client = ChatlogClient()