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 函数按用途获取对应客户端 - 支持语音、视觉、报告等不同用途使用独立网关和密钥 ```
221 lines
8.8 KiB
Python
221 lines
8.8 KiB
Python
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()
|