# 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 from app.api.chat_utils import (_html_escape, _proxy_for_url, cst_now, logger) from app.core.observability import span, get_trace_id 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} async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> str: """使用平台文本模型对用户原始输入做「媒体提示词增强优化」。 AGNES 图像/视频模型对 prompt 质量高度敏感: - 用户在多轮对话中说"帮我生成一张刚才讨论的产品图"包含大量会话噪音 - AGNES 需要的是**详细的、描述性的、英文优先或中英混合**的视觉提示词 - 缺乏构图/风格/光影/质量等指导词时,生成结果与预期差异巨大 本函数把用户原文交给文本 LLM 精炼为符合 AGNES 预期的优质 prompt。 失败时回退到原始输入(不做额外引导,保证必达)。 """ if not raw_prompt or len(raw_prompt.strip()) < 2: return raw_prompt # 如果原始输入已经很长(>200 字)且英文为主,可能本身就是一个不错的 prompt,直接返回 stripped = raw_prompt.strip() ascii_chars = sum(1 for c in stripped if ord(c) < 128) if len(stripped) > 200 and ascii_chars / max(len(stripped), 1) > 0.6: return stripped # 用文本 LLM 做翻译增强 try: from ..capability_registry import resolve_auto_model as _resolve_auto from ..models import ModelProvider as MP from ..database import SessionLocal as SL import httpx as _httpx # 独立数据库会话,避免与主流程竞争 _db = db _should_close = False if _db is None: _db = SL() _should_close = True try: text_model = _resolve_auto("text", _db) if not text_model: return stripped provider = _db.query(MP).filter(MP.id == text_model.provider_id).first() api_key = text_model.api_key or (provider.api_key if provider else None) api_url = text_model.endpoint if not api_url and provider and provider.api_base_url: api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" if not (api_key and api_url): return stripped prompt_zh = ( "你是一个专业的AI图像/视频提示词工程师。请将以下用户需求提炼成" f"一段高质量的{'图像' if media_type == 'image' else '视频'}生成" "提示词。要求:\n" "1. 用英文书写(AGNES模型对英文响应最佳),核心关键词可保留中文;\n" "2. 添加构图/风格/光影/色彩/质量描述(如 highly detailed, 8K, " "professional lighting, masterpiece, cinematic等);\n" "3. 去掉与视觉无关的对话噪音;\n" "4. 只输出最终prompt文本,不要任何解释、不要引号包裹、不要\"Prompt:\"前缀。\n\n" f"用户需求:{stripped}" ) messages = [ {"role": "system", "content": "You are a professional prompt engineer. Output ONLY the final prompt text, no explanations."}, {"role": "user", "content": prompt_zh}, ] # max_tokens 调大到 1024:mimo 等推理模型会把 token 消耗在思考链(reasoning_content) # 上,512 时 content 常被截断为空;并关闭推理(enable_thinking=False),让模型直接输出提示词。 # 对不支持该参数的模型至多优化失败 → 下方 except 兜底退化为原始提示词,不阻断生成。 body = {"model": text_model.name, "messages": messages, "max_tokens": 1024, "temperature": 0.7, "enable_thinking": False} async with _httpx.AsyncClient( timeout=30.0, proxy=_proxy_for_url(api_url), trust_env=False ) as client: resp = await client.post( api_url, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, json=body, ) if resp.status_code == 200: data = resp.json() opt = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() if opt and len(opt) > 5: return opt finally: if _should_close: _db.close() except Exception: pass return stripped def _friendly_media_err(kind: str, raw: str) -> str: """把 AGNES 原始错误转译为对用户友好的中文。 AGNES 常见错误:429 限频(每分钟1次视频) / Server disconnected(连接重置) / publish_video_queue_failed + context deadline exceeded(队列超时)。这些都不是 平台代码问题,统一给出可操作的提示,不把原始 HTTP/JSON 甩给用户。 """ r = (raw or "").lower() if "429" in raw or "rate limit" in r: return (f"⚠️ {kind}生成请求过于频繁(AGNES 限制每分钟 1 次)," f"请稍等 1 分钟后再试。") if "server disconnected" in r or "连接" in raw or "reset" in r: return f"⚠️ {kind}生成服务暂时连接失败,请稍后重试。" if "publish_video" in r or "queue" in r or "deadline" in r or "context" in r: return f"⚠️ {kind}生成队列繁忙(AGNES 处理超时),请稍后重试。" return f"⚠️ {kind}生成失败(请稍后重试):{(raw or '')[:120]}" async def _agnes_post_json(api_url: str, headers: dict, payload: dict, timeout) -> tuple: """直连 AGNES 发 POST,对 5xx / 连接中断做指数退避重试。 AGNES 视频/图像队列极不稳定:经常返回 500 publish_video_queue_failed / context deadline exceeded,或连接被重置(Server disconnected)。这类错误是 AGNES 侧瞬时抖动,重试通常立刻成功。4xx(参数错误)不重试,直接返回。 返回 (resp, err):成功(resp.status_code<500) 时 err=None;耗尽重试时 resp=None。 """ last_err = None for attempt in range(3): try: async with httpx.AsyncClient( timeout=timeout, proxy=_proxy_for_url(api_url), trust_env=False ) as client: resp = await client.post(api_url, headers=headers, json=payload) if resp.status_code < 500: return resp, None last_err = f"HTTP {resp.status_code}: {resp.text[:200]}" except Exception as e: last_err = str(e)[:200] if attempt < 2: await asyncio.sleep(2 * (attempt + 1)) return None, last_err def _media_api_url(ai_model, provider, media_kind: str) -> str: """推导图像/视频生成地址,避免把 chat/completions 端点误用作媒体端点。 - 若模型 endpoint 已是对应的媒体端点(含 /images/generations 或 /videos),直接使用; - 否则从 chat endpoint 反推 base,再拼 /v1/images|videos/generations; - 若 endpoint 为空,则回退供应商 api_base_url + 媒体路径。 """ suffix = "/v1/images/generations" if media_kind == "image" else "/v1/videos" chat_suffixes = ["/v1/chat/completions", "/chat/completions"] ep = (ai_model.endpoint or "").strip() if ep: if suffix in ep: return ep base = ep for cs in chat_suffixes: if base.endswith(cs): base = base[: -len(cs)] break else: if "/v1" in base: base = base.rsplit("/v1", 1)[0] return f"{base.rstrip('/')}{suffix}" if provider and provider.api_base_url: return f"{provider.api_base_url.rstrip('/')}{suffix}" return "" def _build_media_route(db, capability: str, preferred_id: str = None): """构造媒体生成模型路由顺序: 1) 按能力默认模型(后台"能力默认模型"或全局默认) 作为第一顺位 2) 调用方显式指定的模型(若不同于默认) 次位 3) 其余启用且具备该能力的模型,按 is_default 降序、created_at 升序 排列 去重后返回 AIModel 列表。 """ from ..models import AIModel as _AM selected = [] seen = set() def _add(m): if m and m.id not in seen: selected.append(m) seen.add(m.id) default_model = resolve_auto_model(capability, db) if default_model: _add(default_model) if preferred_id: pref = db.query(_AM).filter(_AM.id == preferred_id, _AM.is_enabled == True).first() _add(pref) others = db.query(_AM).filter( _AM.is_enabled == True, _AM.capabilities.op("JSON_CONTAINS")(_AM.capabilities, f'"{capability}"'), ).order_by(_AM.is_default.desc(), _AM.created_at.asc()).all() for m in others: _add(m) return selected async def _run_media_route(db, route, prompt, user_id, media_kind): """依次尝试路由中的模型生成媒体;首个成功(非 ⚠️ 开头)即返回,否则尝试下一个。""" last_err = None for idx, m in enumerate(route, 1): logger.info(f"[_run_media_route] ({idx}/{len(route)}) 尝试模型 {m.name} ({m.id})") try: res = await _generate_media(db, m, prompt, user_id) except Exception as e: last_err = f"模型 {m.name} 异常: {str(e)[:160]}" logger.warning(f"[_run_media_route] 模型 {m.name} 异常: {e}") continue if res and not res.startswith("⚠️"): return res last_err = res logger.warning(f"[_run_media_route] 模型 {m.name} 失败: {str(res)[:160]}") return last_err or "⚠️ 所有模型均生成失败" async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str: """图像/视频生成模型统一入口:按 capabilities 调用 AGNES 等媒体生成 API。 返回可直接在 AI 聊天中渲染的富文本(含 /