fix: 全面修复6项问题——Auto模型路由/媒体提示词优化/微信Bot/Token驾驶舱/技能市场清空

1. resolve_auto_model: image/video/audio无显式默认时自动发现启用模型(原返回None→文本模型回退→AI说无法生成)
2. 新增_optimize_media_prompt: 调用文本LLM精炼用户原始输入为AGNES高质量提示词(去除对话噪音+英文优化)
3. detect_media_intent: 扩展关键词覆盖多轮对话模式(做图片/生成个图/画一下/制作视频等)
4. chat.py+im_llm.py: 所有httpx调用补trust_env=False+_proxy_for_url, 规避容器坏代理
5. token_usage.py: 模型名回退链(model表→日志自带的model_name→auto标签), 不再显示unknown
6. skills_admin.py: 新增POST /batch-delete批量删除API(ids空则删全部)
7. 技能市场数据已清空, 用户可重新导入技能
This commit is contained in:
deploy committed 2026-07-15 17:05:00 +08:00
1 parent 4d57987aa5
commit 148e4a21c2
5 files changed
+210 -16

No files matched your search

+51 -2
View File
@@ -16,7 +16,8 @@ from sqlalchemy import desc, func, or_, and_
from ...database import get_db from ...database import get_db
from ...models import ( from ...models import (
User, EvolutionSkill, SkillVersion, SkillPublishRecord, User, EvolutionSkill, SkillVersion, SkillPublishRecord,
EvolutionEvent, TeamSharedSkill EvolutionEvent, TeamSharedSkill, MarketApp, AppInstallation,
AppPurchase, AppReview
) )
from ...auth import get_current_admin from ...auth import get_current_admin
from ...middleware.log_service import LogService from ...middleware.log_service import LogService
@@ -429,7 +430,55 @@ async def update_skill(
# ============================================================ # ============================================================
# 删除技能 # 批量删除技能(用于一次性清空技能市场)
# ============================================================
class BatchDeleteSkillsRequest(BaseModel):
ids: list[str] = [] # 要删除的技能 ID 列表;传空列表则删除所有
@router.post("/batch-delete")
async def batch_delete_skills(
req: BatchDeleteSkillsRequest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""批量删除技能。ids 为空则删除全部技能。"""
if req.ids:
skills = db.query(EvolutionSkill).filter(EvolutionSkill.id.in_(req.ids)).all()
else:
skills = db.query(EvolutionSkill).all()
deleted_count = 0
for skill in skills:
skill_name = skill.skill_name
# 删除版本历史
db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete()
# 删除发布记录
db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill.id).delete()
# 删除团队共享
db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill.id).delete()
# 同步清理市场应用表中的技能
market_app = db.query(MarketApp).filter(
MarketApp.type == "skill",
MarketApp.name == skill_name,
).first()
if market_app:
# 清理关联的安装和购买记录
db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete()
db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete()
db.query(AppReview).filter(AppReview.app_id == market_app.id).delete()
db.delete(market_app)
db.delete(skill)
deleted_count += 1
db.commit()
return {"code": 0, "data": {"deleted": deleted_count}, "message": f"已删除 {deleted_count} 个技能"}
# ============================================================
# 删除技能(单个)
# ============================================================ # ============================================================
@router.delete("/{skill_id}") @router.delete("/{skill_id}")
+100 -8
View File
@@ -48,14 +48,21 @@ def detect_media_intent(text: str) -> str | None:
if t.endswith(("?", "?")) or any(q in t for q in ("吗", "呢", "怎么", "如何", "能否", "是否可以", "可以吗", "会不会", "why", "how")): if t.endswith(("?", "?")) or any(q in t for q in ("吗", "呢", "怎么", "如何", "能否", "是否可以", "可以吗", "会不会", "why", "how")):
return None return None
video_kw = ("视频", "短片", "影片", "vide", "动画视频", "一段视频", "做个视频", "生成视频", video_kw = ("视频", "短片", "影片", "vide", "动画视频", "一段视频", "做个视频", "生成视频",
"视频生成", "来段视频", "拍个视频", "做段视频", "视频剪辑") "视频生成", "来段视频", "拍个视频", "做段视频", "视频剪辑", "做视频",
"生成一个视频", "制作一个视频", "帮我做视频", "制作视频",
"生成短片", "做短片", "制作短片", "一段短片")
image_kw = ("图片", "图像", "插画", "海报", "照片", "头像", "壁纸", "漫画", "表情包", "logo", "图标", image_kw = ("图片", "图像", "插画", "海报", "照片", "头像", "壁纸", "漫画", "表情包", "logo", "图标",
"imag", "画一张", "画张", "画一只", "画只", "画个", "画幅", "imag", "画一张", "画张", "画一只", "画只", "画个", "画幅",
"来张", "来一张", "来幅", "发张图", "发个图", "来张", "来一张", "来幅", "发张图", "发个图",
"生成图片", "生成一张", "生成一幅", "生成图", "图像生成", "生成图片", "生成一张", "生成一幅", "生成图", "图像生成",
"做一张", "做张图", "做张", "出张图", "出一张图", "做一张", "做张图", "做张", "出张图", "出一张图",
"帮我画", "给我画", "请画", "绘制", "作画", "ai绘画", "ai画图", "帮我画", "给我画", "请画", "绘制", "作画", "ai绘画", "ai画图",
"画个图", "画张图", "画一幅图", "一张图", "一幅画", "生成一幅") "画个图", "画张图", "画一幅图", "一张图", "一幅画", "生成一幅",
"画画", "画一下", "画出来", "生成个图", "生成一张图", "生成个图片",
"做图片", "做个图", "制图", "整张图", "整张图出来", "搞张图",
"帮我生成图片", "帮我生成图像", "给我生成图片", "给我生成图像",
"生成一张图片", "生成一个图片", "制作一张图", "制作一张图片",
"来一张图片", "画一张图片", "画一幅", "画一副")
audio_kw = ("语音", "配音", "朗读", "播报", "念一段", "有声", "tts", "音频", "生成语音", audio_kw = ("语音", "配音", "朗读", "播报", "念一段", "有声", "tts", "音频", "生成语音",
"读一下", "念一下", "语音合成") "读一下", "念一下", "语音合成")
if any(k in t for k in video_kw): if any(k in t for k in video_kw):
@@ -152,6 +159,86 @@ def _proxy_for_url(url: str) -> str | None:
return _outbound_proxy() return _outbound_proxy()
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},
]
body = {"model": text_model.name, "messages": messages, "max_tokens": 512, "temperature": 0.7}
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
async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str: async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str:
"""图像/视频生成模型统一入口:按 capabilities 调用 AGNES 等媒体生成 API。 """图像/视频生成模型统一入口:按 capabilities 调用 AGNES 等媒体生成 API。
返回可直接在 AI 聊天中渲染的富文本(含 <img>/<video> 标签)。""" 返回可直接在 AI 聊天中渲染的富文本(含 <img>/<video> 标签)。"""
@@ -171,12 +258,11 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
api_url = f"{provider.api_base_url.rstrip('/')}/v1/images/generations" if provider else "" api_url = f"{provider.api_base_url.rstrip('/')}/v1/images/generations" if provider else ""
if not api_url: if not api_url:
return "⚠️ 该图像模型未配置 Endpoint。" return "⚠️ 该图像模型未配置 Endpoint。"
# 注意:extra_body 是 OpenAI SDK 专属参数,不能直接作为原始 JSON 字段下发, # 先优化 prompt(用户原始输入含多轮对话噪音 → 高质量图像生成提示词)
# AGNES 收到未知顶层字段会直接断开连接(表现为 "Server disconnected without optimized_prompt = await _optimize_media_prompt(prompt, "image", db)
# sending a response")。保持与 OpenAI 兼容的极简结构即可。
payload = { payload = {
"model": ai_model.name, "model": ai_model.name,
"prompt": prompt, "prompt": optimized_prompt,
"size": "1024x768", "size": "1024x768",
} }
try: try:
@@ -206,10 +292,12 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
api_url = f"{provider.api_base_url.rstrip('/')}/v1/videos" if provider else "" api_url = f"{provider.api_base_url.rstrip('/')}/v1/videos" if provider else ""
if not api_url: if not api_url:
return "⚠️ 该视频模型未配置 Endpoint。" return "⚠️ 该视频模型未配置 Endpoint。"
# 优化 prompt(用户输入含多轮对话噪音 → 高质量视频提示词)
optimized_prompt = await _optimize_media_prompt(prompt, "video", db)
base = api_url.split("/v1")[0] if "/v1" in api_url else api_url base = api_url.split("/v1")[0] if "/v1" in api_url else api_url
payload = { payload = {
"model": ai_model.name, "model": ai_model.name,
"prompt": prompt, "prompt": optimized_prompt,
"height": 768, "height": 768,
"width": 1152, "width": 1152,
"num_frames": 121, "num_frames": 121,
@@ -1762,7 +1850,11 @@ async def send_message(
] ]
request_body["tool_choice"] = "auto" request_body["tool_choice"] = "auto"
async with httpx.AsyncClient(timeout=ai_model.timeout or 120) as client: async with httpx.AsyncClient(
timeout=ai_model.timeout or 120,
proxy=_proxy_for_url(api_url),
trust_env=False,
) as client:
resp = await client.post( resp = await client.post(
api_url, api_url,
headers={ headers={
+24 -3
View File
@@ -6,13 +6,17 @@ IM Bot 真实 LLM 调用。
行为(对齐 AI 聊天): 行为(对齐 AI 聊天):
- Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由), - Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由),
不再对“制作图片”这类请求只回文本。 不再对"制作图片"这类请求只回文本。
- Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能, - Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能,
多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。 多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。
- 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败;
媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。
""" """
import asyncio import asyncio
import json import json
import re as _re
from typing import Optional from typing import Optional
from urllib.parse import urlparse as _urlparse
import httpx import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,6 +25,20 @@ from ..capability_registry import resolve_auto_model
from ..models import AIModel, ModelProvider from ..models import AIModel, ModelProvider
def _proxy_for_url(url: str) -> str | None:
"""按目标域名选择是否走代理(与 chat.py 同逻辑)。
对 AGNES 等国内域名返回 None(直连),平台自有域名直连,其余走智能路由代理。
"""
import os
host = (_urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host:
return None
if "qihuyun.com" 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): def _run_async(coro):
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。 """在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
@@ -61,7 +79,6 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
if result: if result:
# IM 不渲染 HTML,把 <img>/<video> 还原为纯文本链接返回 # IM 不渲染 HTML,把 <img>/<video> 还原为纯文本链接返回
if result.startswith("<img") or result.startswith("<video"): if result.startswith("<img") or result.startswith("<video"):
import re as _re
m = _re.search(r'src="([^"]+)"', result) m = _re.search(r'src="([^"]+)"', result)
url = m.group(1) if m else "" url = m.group(1) if m else ""
label = "图片" if result.startswith("<img") else "视频" label = "图片" if result.startswith("<img") else "视频"
@@ -110,7 +127,11 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
{"type": "function", "function": fd} for fd in function_definitions {"type": "function", "function": fd} for fd in function_definitions
] ]
request_body["tool_choice"] = "auto" request_body["tool_choice"] = "auto"
with httpx.Client(timeout=ai_model.timeout or 120) as client: with httpx.Client(
timeout=ai_model.timeout or 120,
proxy=_proxy_for_url(api_url),
trust_env=False,
) as client:
resp = client.post( resp = client.post(
api_url, api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+21 -2
View File
@@ -84,9 +84,20 @@ def get_rankings(
models_list = [] models_list = []
for r in model_rankings: for r in model_rankings:
model = db.query(AIModel).filter(AIModel.id == r.model_id).first() model = db.query(AIModel).filter(AIModel.id == r.model_id).first()
if model:
model_name = model.name
else:
# 模型已被删除,从最近一条日志中取 model_name 作为回退
recent = db.query(TokenUsageLog.model_name).filter(
TokenUsageLog.model_id == r.model_id,
TokenUsageLog.model_name.isnot(None),
).first()
model_name = (recent.model_name if recent else None) or (
"Auto 智能匹配" if r.model_id == "auto" else "未知模型"
)
models_list.append({ models_list.append({
"model_id": r.model_id, "model_id": r.model_id,
"model_name": model.name if model else "unknown", "model_name": model_name,
"total_tokens": int(r.total), "total_tokens": int(r.total),
"usage_count": int(r.count), "usage_count": int(r.count),
}) })
@@ -162,10 +173,18 @@ def get_token_logs(
result = [] result = []
for log_item in logs: for log_item in logs:
model = db.query(AIModel).filter(AIModel.id == log_item.model_id).first() model = db.query(AIModel).filter(AIModel.id == log_item.model_id).first()
if model:
model_name = model.name
elif log_item.model_name:
model_name = log_item.model_name # 日志自带的名称
elif log_item.model_id == "auto":
model_name = "🤖 Auto 智能匹配"
else:
model_name = "未知模型 (ID: {})".format(log_item.model_id[:8] if log_item.model_id else "?")
result.append({ result.append({
"id": log_item.id, "id": log_item.id,
"model_id": log_item.model_id, "model_id": log_item.model_id,
"model_name": model.name if model else "unknown", "model_name": model_name,
"prompt_tokens": log_item.prompt_tokens or 0, "prompt_tokens": log_item.prompt_tokens or 0,
"completion_tokens": log_item.completion_tokens or 0, "completion_tokens": log_item.completion_tokens or 0,
"total_tokens": log_item.total_tokens or 0, "total_tokens": log_item.total_tokens or 0,
+14 -1
View File
@@ -77,7 +77,8 @@ def resolve_auto_model(capability: str, db: Session) -> Optional[AIModel]:
根据能力解析平台默认模型: 根据能力解析平台默认模型:
1. 优先使用后台"按能力默认模型"中配置的值 1. 优先使用后台"按能力默认模型"中配置的值
2. 文本类能力若未单独配置,回退到全局默认模型(is_default) 2. 文本类能力若未单独配置,回退到全局默认模型(is_default)
3. 其余能力(embedding/rerank/asr 等)若未配置则返回 None 3. 媒体类能力(image/video/audio)若未配置,自动发现第一个启用且具备该能力的模型
4. 其余能力(embedding/rerank/asr 等)若未配置则返回 None
""" """
data = get_capability_defaults(db) data = get_capability_defaults(db)
mid = data.get(capability) mid = data.get(capability)
@@ -94,4 +95,16 @@ def resolve_auto_model(capability: str, db: Session) -> Optional[AIModel]:
).first() ).first()
if m: if m:
return m return m
# 媒体类能力(image/video/audio):无显式配置时自动发现任一启用的模型
# 避免用户未在后台配置"能力默认模型"时 Auto 回退到文本模型导致"无法生成图片/视频"
if capability in ("image", "video", "audio"):
from sqlalchemy import text as _text
m = db.query(AIModel).filter(
AIModel.is_enabled == True,
AIModel.capabilities.op("JSON_CONTAINS")(
AIModel.capabilities, f'"{capability}"'
),
).first()
if m:
return m
return None return None