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:
1 parent
4d57987aa5
commit
148e4a21c2
5 files changed
+210
-16
No files matched your search
@@ -16,7 +16,8 @@ from sqlalchemy import desc, func, or_, and_
|
||||
from ...database import get_db
|
||||
from ...models import (
|
||||
User, EvolutionSkill, SkillVersion, SkillPublishRecord,
|
||||
EvolutionEvent, TeamSharedSkill
|
||||
EvolutionEvent, TeamSharedSkill, MarketApp, AppInstallation,
|
||||
AppPurchase, AppReview
|
||||
)
|
||||
from ...auth import get_current_admin
|
||||
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}")
|
||||
|
||||
@@ -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")):
|
||||
return None
|
||||
video_kw = ("视频", "短片", "影片", "vide", "动画视频", "一段视频", "做个视频", "生成视频",
|
||||
"视频生成", "来段视频", "拍个视频", "做段视频", "视频剪辑")
|
||||
"视频生成", "来段视频", "拍个视频", "做段视频", "视频剪辑", "做视频",
|
||||
"生成一个视频", "制作一个视频", "帮我做视频", "制作视频",
|
||||
"生成短片", "做短片", "制作短片", "一段短片")
|
||||
image_kw = ("图片", "图像", "插画", "海报", "照片", "头像", "壁纸", "漫画", "表情包", "logo", "图标",
|
||||
"imag", "画一张", "画张", "画一只", "画只", "画个", "画幅",
|
||||
"来张", "来一张", "来幅", "发张图", "发个图",
|
||||
"生成图片", "生成一张", "生成一幅", "生成图", "图像生成",
|
||||
"做一张", "做张图", "做张", "出张图", "出一张图",
|
||||
"帮我画", "给我画", "请画", "绘制", "作画", "ai绘画", "ai画图",
|
||||
"画个图", "画张图", "画一幅图", "一张图", "一幅画", "生成一幅")
|
||||
"画个图", "画张图", "画一幅图", "一张图", "一幅画", "生成一幅",
|
||||
"画画", "画一下", "画出来", "生成个图", "生成一张图", "生成个图片",
|
||||
"做图片", "做个图", "制图", "整张图", "整张图出来", "搞张图",
|
||||
"帮我生成图片", "帮我生成图像", "给我生成图片", "给我生成图像",
|
||||
"生成一张图片", "生成一个图片", "制作一张图", "制作一张图片",
|
||||
"来一张图片", "画一张图片", "画一幅", "画一副")
|
||||
audio_kw = ("语音", "配音", "朗读", "播报", "念一段", "有声", "tts", "音频", "生成语音",
|
||||
"读一下", "念一下", "语音合成")
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
"""图像/视频生成模型统一入口:按 capabilities 调用 AGNES 等媒体生成 API。
|
||||
返回可直接在 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 ""
|
||||
if not api_url:
|
||||
return "⚠️ 该图像模型未配置 Endpoint。"
|
||||
# 注意:extra_body 是 OpenAI SDK 专属参数,不能直接作为原始 JSON 字段下发,
|
||||
# AGNES 收到未知顶层字段会直接断开连接(表现为 "Server disconnected without
|
||||
# sending a response")。保持与 OpenAI 兼容的极简结构即可。
|
||||
# 先优化 prompt(用户原始输入含多轮对话噪音 → 高质量图像生成提示词)
|
||||
optimized_prompt = await _optimize_media_prompt(prompt, "image", db)
|
||||
payload = {
|
||||
"model": ai_model.name,
|
||||
"prompt": prompt,
|
||||
"prompt": optimized_prompt,
|
||||
"size": "1024x768",
|
||||
}
|
||||
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 ""
|
||||
if not api_url:
|
||||
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
|
||||
payload = {
|
||||
"model": ai_model.name,
|
||||
"prompt": prompt,
|
||||
"prompt": optimized_prompt,
|
||||
"height": 768,
|
||||
"width": 1152,
|
||||
"num_frames": 121,
|
||||
@@ -1762,7 +1850,11 @@ async def send_message(
|
||||
]
|
||||
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(
|
||||
api_url,
|
||||
headers={
|
||||
|
||||
@@ -6,13 +6,17 @@ IM Bot 真实 LLM 调用。
|
||||
|
||||
行为(对齐 AI 聊天):
|
||||
- Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由),
|
||||
不再对“制作图片”这类请求只回文本。
|
||||
不再对"制作图片"这类请求只回文本。
|
||||
- Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能,
|
||||
多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。
|
||||
- 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败;
|
||||
媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import re as _re
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -21,6 +25,20 @@ from ..capability_registry import resolve_auto_model
|
||||
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):
|
||||
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
|
||||
|
||||
@@ -61,7 +79,6 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
if result:
|
||||
# IM 不渲染 HTML,把 <img>/<video> 还原为纯文本链接返回
|
||||
if result.startswith("<img") or result.startswith("<video"):
|
||||
import re as _re
|
||||
m = _re.search(r'src="([^"]+)"', result)
|
||||
url = m.group(1) if m 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
|
||||
]
|
||||
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(
|
||||
api_url,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
|
||||
@@ -84,9 +84,20 @@ def get_rankings(
|
||||
models_list = []
|
||||
for r in model_rankings:
|
||||
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({
|
||||
"model_id": r.model_id,
|
||||
"model_name": model.name if model else "unknown",
|
||||
"model_name": model_name,
|
||||
"total_tokens": int(r.total),
|
||||
"usage_count": int(r.count),
|
||||
})
|
||||
@@ -162,10 +173,18 @@ def get_token_logs(
|
||||
result = []
|
||||
for log_item in logs:
|
||||
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({
|
||||
"id": log_item.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,
|
||||
"completion_tokens": log_item.completion_tokens or 0,
|
||||
"total_tokens": log_item.total_tokens or 0,
|
||||
|
||||
Reference in new issue
Block a user