""" IM Bot 真实 LLM 调用。 原实现位于 channels.py(通用频道管理模块),该模块已移除。 为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。 行为(对齐 AI 聊天 — 2026-07-23 同步至最新): - Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由), 不再对"制作图片"这类请求只回文本。 - Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能, 多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。 - 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败; 媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。 - 2026-07-23:对齐 Web Chat 最新能力: * 模型 failover(Auto 模式自动切换备选模型) * 平台知识库注入(get_platform_knowledge,技能清单实时同步) """ import asyncio import json import logging import re as _re from datetime import datetime, timezone, timedelta from typing import Optional from urllib.parse import urlparse as _urlparse import httpx from app.core.llm_client import chat_complete # 统一模型层(07-24): web/IM 共用标准 OpenAI SDK 客户端 from sqlalchemy.orm import Session from ..capability_registry import resolve_auto_model from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid from app.core.observability import get_trace_id logger = logging.getLogger(__name__) # 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。 _HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495" def _proxy_for_url(url: str) -> str | None: """按目标域名选择是否走代理(与 chat.py 同逻辑)。 2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。 国内域名(尊重 NO_PROXY)直连,更稳定。 """ import os host = (_urlparse(url).hostname or "").lower() if "agnes-ai.com" in host or "agnes-ai.space" in host: return _HK_PROXY if "qihuyun.com" in host: return None _no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()] for _np in _no_proxy: if host == _np or host.endswith("." + _np) or _np in host: return None return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890" def _run_async(coro, timeout=None): """在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。 IM 个人微信在独立线程中调用(无事件循环)→ asyncio.run; IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。 timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。 """ try: loop = asyncio.get_running_loop() except RuntimeError: loop = None if timeout is not None: async def _timed(): return await asyncio.wait_for(coro, timeout=timeout) target = _timed() else: target = coro if loop is not None and loop.is_running(): import concurrent.futures with concurrent.futures.ThreadPoolExecutor(1) as ex: return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None) return asyncio.run(target) def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str): """IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。 此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里"查无此项"。 媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。 """ if not user_id or not ai_model: return try: base = 1200 if media_type == "video" else 400 est = base + (len(prompt or "") // 4) db.add(TokenUsageLog( id=gen_uuid(), user_id=user_id, model_id=ai_model.id, model_name=ai_model.name, trace_id=get_trace_id(), prompt_tokens=est, completion_tokens=0, total_tokens=est, created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None), )) db.commit() except Exception as e: try: db.rollback() except Exception: pass logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}") def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict": """调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。""" try: from app.api.chat import ( _generate_media, detect_media_intent, _discover_installed_skills, _build_system_prompt, _execute_skill_tool, ) # ===== 1) 媒体意图路由(Issue 7)===== intent = detect_media_intent(prompt) if intent: ai_model = resolve_auto_model(intent, db) if ai_model: try: result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300) except Exception: # 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示, # 绝不直接把原始异常(如 "Server disconnected")甩给用户 result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。" if result: # 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频 s = result.strip() if s.startswith("