"""
增强版 Chat API — Phase 2.5
包含: Skill ID统一(MarketApp映射), 真实Skill执行, Agent工作流注入,
多轮上下文保持, BM25 KB检索, Function Calling循环增强
"""
from fastapi import APIRouter, Depends, HTTPException, Query
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,
gen_uuid
)
from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
router = APIRouter()
logger = logging.getLogger(__name__)
def detect_media_intent(text: str) -> str | None:
"""根据用户输入判断是否为 图像/视频/语音 生成请求,返回对应能力或 None。
用于 Auto 模式:用户未指定具体模型时,依据意图自动匹配平台各类能力的默认模型。
疑问句(含「吗/呢/怎么/能否」或结尾问号)不触发,避免「你能生成视频吗」被误判。
"""
if not text:
return None
t = text.lower().strip()
# 疑问句不触发媒体生成
if t.endswith(("?", "?")) or any(q in t for q in ("吗", "呢", "怎么", "如何", "能否", "是否可以", "可以吗", "会不会", "why", "how")):
return None
video_kw = ("视频", "短片", "影片", "vide", "动画视频", "一段视频", "做个视频", "生成视频",
"视频生成", "来段视频", "拍个视频", "做段视频", "视频剪辑")
image_kw = ("图片", "图像", "插画", "海报", "照片", "头像", "壁纸", "漫画", "表情包", "logo", "图标",
"imag", "画一张", "画张", "画一只", "画只", "画个", "画幅",
"来张", "来一张", "来幅", "发张图", "发个图",
"生成图片", "生成一张", "生成一幅", "生成图", "图像生成",
"做一张", "做张图", "做张", "出张图", "出一张图",
"帮我画", "给我画", "请画", "绘制", "作画", "ai绘画", "ai画图",
"画个图", "画张图", "画一幅图", "一张图", "一幅画", "生成一幅")
audio_kw = ("语音", "配音", "朗读", "播报", "念一段", "有声", "tts", "音频", "生成语音",
"读一下", "念一下", "语音合成")
if any(k in t for k in video_kw):
return "video"
if any(k in t for k in image_kw):
return "image"
if any(k in t for k in audio_kw):
return "audio"
return None
def detect_ambiguous_creative_intent(text: str):
"""检测「模糊创作意图」:用户使用了创作类动词(制作/创作/做/生成/画/写/设计…),
但未明确产出形式(文案/图片/视频/其他)。此时应与用户确认方向,而不是直接调用大模型
(某些模型会对这类开放式创作请求返回 "high risk" 拒答)。
返回 None 表示无需确认;否则返回 {"options": [...]}。
"""
if not text:
return None
t = text.strip()
tl = t.lower()
# 已有明确媒体意图(图像/视频/语音)则无需确认
if detect_media_intent(t):
return None
# 疑问句不触发
if tl.endswith(("?", "?")) or any(q in tl for q in ("吗", "呢", "怎么", "如何", "能否", "是否可以", "可以吗", "会不会", "why", "how", "什么", "哪些")):
return None
# 已明确给出产出形式(文字类/其他类)则无需确认
explicit_medium = (
"文案", "文章", "诗歌", "诗", "小说", "故事", "代码", "程序", "脚本", "总结", "翻译",
"邮件", "报告", "段落", "正文", "文字", "写作", "ppt", "演示", "思维导图", "表格",
"图表", "数据",
)
if any(m in tl for m in explicit_medium):
return None
# 创作类动词
creative_verbs = (
"制作", "创作", "做个", "做一个", "搞一个", "产出", "构思", "设想",
"打造", "塑造", "生成", "帮我做", "帮我生成", "帮我画", "帮我写", "请制作",
"请创作", "请生成", "请画", "请写", "来一个", "来一张", "来一幅", "做一张",
"做张", "写一首", "写一段", "写个",
)
if not any(v in t for v in creative_verbs):
return None
# 模糊:有创作动词但无明确产出形式 → 提供方向选择
options = [
{"key": "text", "label": "文案", "desc": "文章 / 故事 / 诗歌等文字内容", "medium": "文案"},
{"key": "image", "label": "图片", "desc": "插画 / 海报 / 照片等图像", "medium": "图片"},
{"key": "video", "label": "视频", "desc": "短片 / 动画等视频", "medium": "视频"},
{"key": "other", "label": "其他", "desc": "语音 / 代码 / 其他形式", "medium": "其他"},
]
return {"options": options}
# CST timezone (UTC+8) for consistent time display
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"
def _proxy_for_url(url: str) -> str | None:
"""按目标域名选择是否走代理。
实测(2026-07-15):apihub.agnes-ai.com / platform-outputs.agnes-ai.space 为国内域名,
容器内直连稳定返回 200;而经智能路由代理 172.18.0.1:7890 当前返回 502,导致
httpx 报 "Server disconnected without sending a response"。
因此对 AGNES 等国内域名返回 None(直连),其余域名才走 _outbound_proxy()。
注意:httpx 默认 trust_env=True 会忽略 proxy=None 而改用环境变量 HTTP_PROXY,
故调用方必须同时传 trust_env=False 才能真正直连。
"""
from urllib.parse import urlparse
host = (urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host:
return None
return _outbound_proxy()
async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str:
"""图像/视频生成模型统一入口:按 capabilities 调用 AGNES 等媒体生成 API。
返回可直接在 AI 聊天中渲染的富文本(含
/