feat(api): 添加万川平台模型配置获取和同步功能

- 新增 getWanchuanModelConfig 函数,按模型编码获取平台模型配置
- 新增 syncWanchuanModelToSettings 函数,从万川平台拉取模型配置并写入后端 AI 设置
- 支持按用途分多个模型编码(generic/vision/voice)分别同步配置
- 配置失败时跳过对应字段,不影响其他模型同步

feat(settings): 重构AI模型配置界面支持多模块分组

- 将AI配置按话题分析、报告生成、视觉、语音四个模块分组展示
- 每个模块独立配置接口地址、密钥和模型名称
- 添加从万川平台获取配置的按钮和同步功能
- 优化配置状态指示和错误提示信息

refactor(config): 扩展AI配置支持独立的语音视觉报告网关

- 新增 voice_base_url/voice_api_key 配置项
- 新增 vision_base_url/vision_api_key 配置项
- 新增 summary_base_url/summary_api_key 配置项
- 留空时回退到 ai_base_url/ai_api_key 兼容单网关场景

refactor(http): 统一使用共享HTTP客户端减少连接开销

- 替换各处 httpx.AsyncClient 为 shared_client
- 在 lifespan 中正确关闭共享客户端资源
- 优化 get_current_wxid 和 health 检查中的HTTP请求

refactor(ai): 按用途缓存AI客户端支持不同网关配置

- 重构 get_openai_client 支持按(base_url, api_key)缓存
- 新增 get_client_for 函数按用途获取对应客户端
- 支持语音、视觉、报告等不同用途使用独立网关和密钥
```
This commit is contained in:
2026-06-24 20:34:10 +08:00
parent eecbe4172e
commit 646efa132e
18 changed files with 839 additions and 238 deletions

View File

@@ -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")

View File

@@ -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():

View File

@@ -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}

View File

@@ -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(

View File

@@ -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

View File

@@ -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 模型配置。
# 存到后端 SQLiteapp_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 = ?",

View File

@@ -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

View File

@@ -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)

View File

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

View File

@@ -1,18 +1,19 @@
import asyncio
import base64
import 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_urlOpenAI 兼容的 chat 端点
# .../compatible-mode/...是两套服务不能混用。voice_base_url 为空时应回退到
# DEFAULT_ASR_BASE_URL绝不能回退到 ai_base_url否则会拼成 .../compatible-mode/.../asr 而 404。
base = (ai.get("voice_base_url") or DEFAULT_ASR_BASE_URL).strip().rstrip("/")
api_key = (ai.get("voice_api_key") or ai.get("ai_api_key") or "").strip()
voice_model = (ai.get("voice_model") or "").strip()
b64_audio = base64.b64encode(media_bytes).decode()
data_uri = f"data:{_audio_mime(content_type)};base64,{b64_audio}"
asr_headers = {
"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=[

View File

@@ -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)

View File

@@ -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:

View File

@@ -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: