# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). # Source of truth: scripts/phase1_extract.py. Do not hand-merge back. from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy.orm import Session from sqlalchemy import and_, desc, asc, func from pydantic import BaseModel, Field from typing import Optional import uuid from datetime import datetime, timezone, timedelta import re import json import asyncio import math import httpx import os import logging from ..database import get_db, SessionLocal from ..models import ( User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, MCPServer, gen_uuid ) from ..auth import get_current_user from ..platform_knowledge import get_platform_knowledge from ..mcp_client import list_mcp_tools, call_mcp_tool from ..middleware.log_service import LogService from ..capability_registry import resolve_auto_model logger = logging.getLogger(__name__) CST_TZ = timezone(timedelta(hours=8)) def cst_now(): """Get current time in CST timezone""" return datetime.now(CST_TZ) def _html_escape(s: str) -> str: return (str(s).replace("&", "&").replace("<", "<").replace(">", ">") .replace('"', """).replace("'", "'")) def _outbound_proxy() -> str: """出网代理地址(兜底)。默认经 172.18.0.1:7890 智能路由代理; 运行时若未注入代理环境变量则回退到该地址。""" import os return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890" _HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495" def _proxy_for_url(url: str) -> str | None: """按目标域名选择是否走代理。 2026-07-15 调整:AGNES(apihub.agnes-ai.com / platform-outputs.agnes-ai.space) 经用户实测走香港代理 103.242.14.208:11495 延迟更低、更稳定,故改为走 HK 代理 (带鉴权),不再直连,也不经智能路由代理(172.18.0.1:7890 对 AGNES 返 502)。 其余国内/平台域名直连,真正的境外域名走 _outbound_proxy()。 注意:httpx 默认 trust_env=True 会忽略 proxy 参数而改用环境变量 HTTP_PROXY, 故调用方必须同时传 trust_env=False 才能真正走此处指定的代理。 """ import os from urllib.parse import urlparse host = (urlparse(url).hostname or "").lower() # 文生图 / 视频生成走直连:智能路由代理(172.18.0.1:7890)与 HK 代理对这类长耗时、 # 大响应体的生成连接会偶发重置(Server disconnected),而各图像/视频供应商均为公网 # API,直连更稳定(已实测 gpt-image-2 / agnes 直连均返回 200)。故媒体端点统一直连。 _lower = (url or "").lower() if "/images/" in _lower or "/videos/" in _lower or "/v1/images" in _lower or "/v1/videos" in _lower: return None if "agnes-ai.com" in host or "agnes-ai.space" in host: return _HK_PROXY # 平台自有域名(含自建 Dify/n8n 等)必须直连,不经出网代理。 if "qihuyun.com" in host: return None # 尊重 NO_PROXY:国内域名(硅基流动/DeepSeek/小米MoE 等)直连更稳定, # 避免经易波动的智能路由代理 172.18.0.1:7890(对国内域名偶发连接重置)。 _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 _outbound_proxy() def _extract_text_from_json(obj) -> str: """从 n8n / 通用 webhook 的任意 JSON 响应中尽力抽取文本回答。""" if obj is None: return "" if isinstance(obj, str): return obj if isinstance(obj, (int, float, bool)): return str(obj) if isinstance(obj, list): # 取第一个可抽取到文本的元素 for it in obj: t = _extract_text_from_json(it) if t: return t return "" if isinstance(obj, dict): # 常见回答字段优先级 for k in ("output", "text", "answer", "response", "reply", "message", "content", "result", "data"): if k in obj: t = _extract_text_from_json(obj[k]) if t: return t # 兜底:整体序列化 try: return json.dumps(obj, ensure_ascii=False) except Exception: return str(obj) return str(obj) class CreateConversationRequest(BaseModel): title: str = Field(default="新对话") model_id: Optional[str] = None class UpdateConversationRequest(BaseModel): title: Optional[str] = None status: Optional[str] = None # active, archived pinned: Optional[bool] = None agent_ids: Optional[list] = None skill_ids: Optional[list] = None knowledge_base_ids: Optional[list] = None class SendMessageRequest(BaseModel): content: str model_id: Optional[str] = None skill_ids: Optional[list] = None agent_ids: Optional[list] = None knowledge_base_ids: Optional[list] = None mcp_ids: Optional[list] = None attachments: Optional[list] = None use_tools: Optional[bool] = False