fix(wechat-bot): ①对话时间统一北京时间 ②媒体内联发送(微信直看图片/视频) ③媒体生成计入Token驾驶舱 ④清空技能市场
This commit is contained in:
1 parent
7739f17594
commit
da89a27bca
3 files changed
+154
-30
No files matched your search
@@ -459,6 +459,9 @@ async def _generate_media_task(ai_msg_id: str, ai_model_id: str, prompt: str, us
|
|||||||
msg.token_used = len(result) // 2
|
msg.token_used = len(result) // 2
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"[_generate_media_task] 消息 {ai_msg_id} 已回写内容")
|
logger.info(f"[_generate_media_task] 消息 {ai_msg_id} 已回写内容")
|
||||||
|
# 媒体 Token 记账:把图片/视频真实消耗写入 TokenUsageLog,供驾驶舱统计。
|
||||||
|
# 主流程对媒体请求只写了占位文本的几 token,此处补写真实媒体消耗(模型名正确、数值合理)。
|
||||||
|
_write_media_token_log(db, ai_model, user_id, msg.conversation_id if msg else None, prompt)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[_generate_media_task] 后台生成异常: {e}")
|
logger.warning(f"[_generate_media_task] 后台生成异常: {e}")
|
||||||
finally:
|
finally:
|
||||||
@@ -468,6 +471,38 @@ async def _generate_media_task(ai_msg_id: str, ai_model_id: str, prompt: str, us
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _write_media_token_log(db, ai_model, user_id: str, conversation_id: str, prompt: str):
|
||||||
|
"""媒体生成完成后补写 TokenUsageLog(图片/视频),修正此前只记占位文本的偏差。
|
||||||
|
|
||||||
|
媒体 API 不返回 token 用量,采用与 IM Bot 路径一致的估算:
|
||||||
|
视频基线 1200、图片基线 400,再加 prompt 长度的 1/4 作为提示词开销。
|
||||||
|
"""
|
||||||
|
if not user_id or not ai_model:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
is_video = "video" in (ai_model.capabilities or [])
|
||||||
|
base = 1200 if is_video else 400
|
||||||
|
est = base + (len(prompt or "") // 4)
|
||||||
|
db.add(TokenUsageLog(
|
||||||
|
id=gen_uuid(),
|
||||||
|
user_id=user_id,
|
||||||
|
model_id=ai_model.id,
|
||||||
|
model_name=ai_model.name,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
prompt_tokens=est,
|
||||||
|
completion_tokens=0,
|
||||||
|
total_tokens=est,
|
||||||
|
created_at=cst_now(),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[_write_media_token_log] 写入失败(忽略): {e}")
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 外部 Agent 远程调用 (Dify / n8n / Coze)
|
# 外部 Agent 远程调用 (Dify / n8n / Coze)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -2062,7 +2097,8 @@ async def send_message(
|
|||||||
conv.knowledge_graph_count = (conv.knowledge_graph_count or 0) + 1
|
conv.knowledge_graph_count = (conv.knowledge_graph_count or 0) + 1
|
||||||
conv.updated_at = cst_now()
|
conv.updated_at = cst_now()
|
||||||
|
|
||||||
# 记录Token使用
|
# 记录Token使用(媒体生成走 _generate_media_task 异步补写真实消耗,此处跳过避免占位文本重复记账)
|
||||||
|
if not media_generating:
|
||||||
token_usage = TokenUsageLog(
|
token_usage = TokenUsageLog(
|
||||||
id=gen_uuid(),
|
id=gen_uuid(),
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import json
|
|||||||
import random
|
import random
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -31,6 +31,15 @@ from ..models import User
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# 北京时间(UTC+8)。容器系统时区为 UTC,所有对外展示/持久化的时间戳必须按北京时间存储,
|
||||||
|
# 否则前台微信 Bot 对话窗会显示成 UTC(比系统时间早 8 小时)。
|
||||||
|
TZ_BEIJING = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
def _beijing_now() -> datetime:
|
||||||
|
"""返回北京时间的 naive datetime(去掉 tzinfo 以便直接存入 MySQL DATETIME)。"""
|
||||||
|
return datetime.now(TZ_BEIJING).replace(tzinfo=None)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID)
|
# iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -136,7 +145,7 @@ def _qihua_reply(text: str, user_id: str = None) -> str:
|
|||||||
try:
|
try:
|
||||||
from app.api.im_llm import _call_real_llm
|
from app.api.im_llm import _call_real_llm
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
return _call_real_llm(db, None, prompt, "个人微信")
|
return _call_real_llm(db, None, prompt, "个人微信", user_id=user_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"(AI 回复生成失败:{e})"
|
return f"(AI 回复生成失败:{e})"
|
||||||
finally:
|
finally:
|
||||||
@@ -164,9 +173,9 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
|
|||||||
return [], cursor
|
return [], cursor
|
||||||
|
|
||||||
|
|
||||||
def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str):
|
def _wechat_post_msg(baseurl: str, bot_token: str, to_user_id: str, context_token: str, item_list: list):
|
||||||
"""发送文本回复(必须带 context_token 关联到正确的对话窗口)。
|
"""向 iLink 发送一条消息(item_list 可含文本/图片/视频等)。返回 (ok, ret, body)。
|
||||||
返回 (ok, ret, body)。ret=-14=会话过期需重扫;ret=-2=token 失效。"""
|
ret=-14=会话过期需重扫;ret=-2=token 失效。"""
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
payload = {
|
payload = {
|
||||||
"msg": {
|
"msg": {
|
||||||
@@ -176,7 +185,7 @@ def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_
|
|||||||
"message_type": 2,
|
"message_type": 2,
|
||||||
"message_state": 2,
|
"message_state": 2,
|
||||||
"context_token": context_token,
|
"context_token": context_token,
|
||||||
"item_list": [{"type": 1, "text_item": {"text": text}}],
|
"item_list": item_list,
|
||||||
},
|
},
|
||||||
"base_info": {"channel_version": "1.0.2"},
|
"base_info": {"channel_version": "1.0.2"},
|
||||||
}
|
}
|
||||||
@@ -197,7 +206,7 @@ def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_
|
|||||||
ret = body.get("ret") if isinstance(body, dict) else None
|
ret = body.get("ret") if isinstance(body, dict) else None
|
||||||
try:
|
try:
|
||||||
with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f:
|
with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f:
|
||||||
_f.write(datetime.now().isoformat() + " SEND_HTTP=%s ret=%s body=%s\n"
|
_f.write(_beijing_now().isoformat() + " SEND_HTTP=%s ret=%s body=%s\n"
|
||||||
% (resp.status_code, ret, json.dumps(body, ensure_ascii=False)[:300]))
|
% (resp.status_code, ret, json.dumps(body, ensure_ascii=False)[:300]))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -210,6 +219,31 @@ def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_
|
|||||||
return False, (last_body.get("ret") if isinstance(last_body, dict) else None), last_body
|
return False, (last_body.get("ret") if isinstance(last_body, dict) else None), last_body
|
||||||
|
|
||||||
|
|
||||||
|
def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str):
|
||||||
|
"""发送纯文本回复。"""
|
||||||
|
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
|
||||||
|
[{"type": 1, "text_item": {"text": text}}])
|
||||||
|
|
||||||
|
|
||||||
|
def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str,
|
||||||
|
media_type: str, url: str):
|
||||||
|
"""发送媒体(图片/视频)并附带文本链接兜底,确保用户可在微信内直接查看。
|
||||||
|
|
||||||
|
iLink 消息 item_list:
|
||||||
|
- type=2 图片:image_item: {url}
|
||||||
|
- type=5 视频:video_item: {url}
|
||||||
|
同时附带一条文本项(含直链),防止微信/iLink 不渲染外链时用户仍能复制查看。
|
||||||
|
"""
|
||||||
|
if media_type == "image":
|
||||||
|
media_item = {"type": 2, "image_item": {"url": url}}
|
||||||
|
caption = "🖼️ 图片已生成(若未直接显示,请点链接查看):" + url
|
||||||
|
else:
|
||||||
|
media_item = {"type": 5, "video_item": {"url": url}}
|
||||||
|
caption = "🎬 视频已生成(若未直接播放,请点链接查看):" + url
|
||||||
|
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
|
||||||
|
[media_item, {"type": 1, "text_item": {"text": caption}}])
|
||||||
|
|
||||||
|
|
||||||
def _wechat_extract_text(msg: dict) -> str:
|
def _wechat_extract_text(msg: dict) -> str:
|
||||||
parts = []
|
parts = []
|
||||||
for it in (msg.get("item_list") or []):
|
for it in (msg.get("item_list") or []):
|
||||||
@@ -255,7 +289,7 @@ def _log_chat(user_id, platform, from_user, context_token, role, content):
|
|||||||
), {
|
), {
|
||||||
"uid": user_id, "platform": platform, "fu": from_user,
|
"uid": user_id, "platform": platform, "fu": from_user,
|
||||||
"ctx": context_token, "role": role, "content": content,
|
"ctx": context_token, "role": role, "content": content,
|
||||||
"created": datetime.now(),
|
"created": _beijing_now(),
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -282,7 +316,7 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
|
|||||||
return _row_to_dict(rows[0])["id"]
|
return _row_to_dict(rows[0])["id"]
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
cid = str(_uuid.uuid4())
|
cid = str(_uuid.uuid4())
|
||||||
now = datetime.now()
|
now = _beijing_now()
|
||||||
_execute_raw(db, text(
|
_execute_raw(db, text(
|
||||||
"INSERT INTO conversations "
|
"INSERT INTO conversations "
|
||||||
"(id, user_id, title, status, pinned, knowledge_graph_count, source, created_at, updated_at) "
|
"(id, user_id, title, status, pinned, knowledge_graph_count, source, created_at, updated_at) "
|
||||||
@@ -298,7 +332,7 @@ def _append_ai_message(db: Session, conv_id: str, role: str, content: str, creat
|
|||||||
"VALUES (:id, :cid, :role, :content, 0, :c)"
|
"VALUES (:id, :cid, :role, :content, 0, :c)"
|
||||||
), {
|
), {
|
||||||
"id": str(_uuid.uuid4()), "cid": conv_id, "role": role,
|
"id": str(_uuid.uuid4()), "cid": conv_id, "role": role,
|
||||||
"content": content, "c": created or datetime.now(),
|
"content": content, "c": created or _beijing_now(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -310,13 +344,13 @@ def _mirror_to_ai_chat(user_id: str, user_content: str, assistant_content: str):
|
|||||||
try:
|
try:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
cid = _get_or_create_wechat_conv(db, user_id)
|
cid = _get_or_create_wechat_conv(db, user_id)
|
||||||
now = datetime.now()
|
now = _beijing_now()
|
||||||
_append_ai_message(db, cid, "user", user_content, created=now)
|
_append_ai_message(db, cid, "user", user_content, created=now)
|
||||||
# assistant 比 user 晚 50ms,保证排序严格递增,避免同秒时 ORDER BY 乱序
|
# assistant 比 user 晚 50ms,保证排序严格递增,避免同秒时 ORDER BY 乱序
|
||||||
_append_ai_message(db, cid, "assistant", assistant_content,
|
_append_ai_message(db, cid, "assistant", assistant_content,
|
||||||
created=now + timedelta(milliseconds=50))
|
created=now + timedelta(milliseconds=50))
|
||||||
_execute_raw(db, text("UPDATE conversations SET updated_at=:u WHERE id=:id"),
|
_execute_raw(db, text("UPDATE conversations SET updated_at=:u WHERE id=:id"),
|
||||||
{"u": datetime.now(), "id": cid})
|
{"u": _beijing_now(), "id": cid})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
@@ -335,7 +369,7 @@ def _mark_wechat_expired(account_id: int):
|
|||||||
_execute_raw(db, text(
|
_execute_raw(db, text(
|
||||||
"UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, "
|
"UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, "
|
||||||
"updated_at=:ts WHERE id=:aid"
|
"updated_at=:ts WHERE id=:aid"
|
||||||
), {"aid": account_id, "ts": datetime.now()})
|
), {"aid": account_id, "ts": _beijing_now()})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
@@ -374,24 +408,39 @@ def _wechat_receiver_loop(account_id: int):
|
|||||||
# 诊断:记录原始入站消息,定位字段名 / 第二轮不回复
|
# 诊断:记录原始入站消息,定位字段名 / 第二轮不回复
|
||||||
try:
|
try:
|
||||||
with open("/tmp/wechat_inbound.log", "a", encoding="utf-8") as _f:
|
with open("/tmp/wechat_inbound.log", "a", encoding="utf-8") as _f:
|
||||||
_f.write(datetime.now().isoformat() + " sender=" + repr(sender)
|
_f.write(_beijing_now().isoformat() + " sender=" + repr(sender)
|
||||||
+ " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n")
|
+ " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# 先生成回复(此时历史尚未包含本条,避免重复)
|
# 先生成回复(此时历史尚未包含本条,避免重复)
|
||||||
reply = _qihua_reply(content, owner_uid)
|
reply = _qihua_reply(content, owner_uid)
|
||||||
ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply)
|
# 媒体结构化返回:_call_real_llm 生成图片/视频时返回
|
||||||
|
# {"__media__": True, "media_type": "image"/"video", "url": "..."}
|
||||||
|
if isinstance(reply, dict) and reply.get("__media__"):
|
||||||
|
media_type = reply.get("media_type") or "image"
|
||||||
|
media_url = reply.get("url") or ""
|
||||||
|
ok, ret, _ = _wechat_send_media(baseurl, bot_token, sender, ctx, media_type, media_url)
|
||||||
|
# 对话历史里存可渲染的媒体标签(AI 聊天 WeXin Bot 窗与历史页均可显示)
|
||||||
|
reply_for_log = (
|
||||||
|
f'<img src="{media_url}" alt="生成图片"/>'
|
||||||
|
if media_type == "image"
|
||||||
|
else f'<video src="{media_url}" controls></video>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reply_text = reply if isinstance(reply, str) else str(reply)
|
||||||
|
ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply_text)
|
||||||
|
reply_for_log = reply_text
|
||||||
if ok:
|
if ok:
|
||||||
# 仅发送成功才写对话历史(避免“发了但没收到”的误导记录)
|
# 仅发送成功才写对话历史(避免“发了但没收到”的误导记录)
|
||||||
if owner_uid:
|
if owner_uid:
|
||||||
_log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content)
|
_log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content)
|
||||||
_log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply)
|
_log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply_for_log)
|
||||||
# 同步镜像到 AI 聊天的 WeXin Bot 会话窗
|
# 同步镜像到 AI 聊天的 WeXin Bot 会话窗
|
||||||
_mirror_to_ai_chat(owner_uid, content, reply)
|
_mirror_to_ai_chat(owner_uid, content, reply_for_log)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f:
|
with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f:
|
||||||
_f.write(datetime.now().isoformat() + " SEND_FAIL ret=%s sender=%s\n"
|
_f.write(_beijing_now().isoformat() + " SEND_FAIL ret=%s sender=%s\n"
|
||||||
% (ret, repr(sender)))
|
% (ret, repr(sender)))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -511,8 +560,9 @@ async def wechat_qrcode_status(
|
|||||||
"user_id": user.id, "username": user.username,
|
"user_id": user.id, "username": user.username,
|
||||||
"bot_token": bot_token, "ilink_bot_id": ilink_bot_id,
|
"bot_token": bot_token, "ilink_bot_id": ilink_bot_id,
|
||||||
"baseurl": baseurl,
|
"baseurl": baseurl,
|
||||||
"connected_at": datetime.now(),
|
# 统一按北京时间存储,避免容器 UTC 导致显示偏差
|
||||||
"created_at": datetime.now(), "updated_at": datetime.now(),
|
"connected_at": _beijing_now(),
|
||||||
|
"created_at": _beijing_now(), "updated_at": _beijing_now(),
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
_execute_raw(db, text("""UPDATE im_bot_wechat_account SET
|
_execute_raw(db, text("""UPDATE im_bot_wechat_account SET
|
||||||
@@ -523,8 +573,9 @@ async def wechat_qrcode_status(
|
|||||||
{
|
{
|
||||||
"user_id": user.id, "username": user.username,
|
"user_id": user.id, "username": user.username,
|
||||||
"bot_token": bot_token, "ilink_bot_id": ilink_bot_id,
|
"bot_token": bot_token, "ilink_bot_id": ilink_bot_id,
|
||||||
"baseurl": baseurl, "connected_at": datetime.now(),
|
"baseurl": baseurl,
|
||||||
"updated_at": datetime.now(), "id": _row_to_dict(row)["id"],
|
"connected_at": _beijing_now(),
|
||||||
|
"updated_at": _beijing_now(), "id": _row_to_dict(row)["id"],
|
||||||
})
|
})
|
||||||
row = _wechat_row_for_user(db, user.id)
|
row = _wechat_row_for_user(db, user.id)
|
||||||
aid = _row_to_dict(row)["id"] if row else 1
|
aid = _row_to_dict(row)["id"] if row else 1
|
||||||
@@ -547,7 +598,7 @@ async def wechat_disconnect(user: User = Depends(get_current_user)):
|
|||||||
_execute_raw(db, text(
|
_execute_raw(db, text(
|
||||||
"UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', "
|
"UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', "
|
||||||
"updated_at=:updated_at WHERE id=:id"
|
"updated_at=:updated_at WHERE id=:id"
|
||||||
), {"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]})
|
), {"updated_at": _beijing_now(), "id": _row_to_dict(row)["id"]})
|
||||||
return {"success": True, "message": "已断开个人微信连接"}
|
return {"success": True, "message": "已断开个人微信连接"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ IM Bot 真实 LLM 调用。
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re as _re
|
import re as _re
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse as _urlparse
|
from urllib.parse import urlparse as _urlparse
|
||||||
|
|
||||||
@@ -22,7 +24,9 @@ import httpx
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..capability_registry import resolve_auto_model
|
from ..capability_registry import resolve_auto_model
|
||||||
from ..models import AIModel, ModelProvider
|
from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。
|
# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。
|
||||||
@@ -65,7 +69,37 @@ def _run_async(coro):
|
|||||||
return asyncio.run(coro)
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> str:
|
def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str):
|
||||||
|
"""IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。
|
||||||
|
|
||||||
|
此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里“查无此项”。
|
||||||
|
媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。
|
||||||
|
"""
|
||||||
|
if not user_id or not ai_model:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
base = 1200 if media_type == "video" else 400
|
||||||
|
est = base + (len(prompt or "") // 4)
|
||||||
|
db.add(TokenUsageLog(
|
||||||
|
id=gen_uuid(),
|
||||||
|
user_id=user_id,
|
||||||
|
model_id=ai_model.id,
|
||||||
|
model_name=ai_model.name,
|
||||||
|
prompt_tokens=est,
|
||||||
|
completion_tokens=0,
|
||||||
|
total_tokens=est,
|
||||||
|
created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
|
||||||
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由与已安装技能自动发现。"""
|
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由与已安装技能自动发现。"""
|
||||||
try:
|
try:
|
||||||
from app.api.chat import (
|
from app.api.chat import (
|
||||||
@@ -88,19 +122,22 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
|||||||
# 绝不直接把原始异常(如 "Server disconnected")甩给用户
|
# 绝不直接把原始异常(如 "Server disconnected")甩给用户
|
||||||
result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。"
|
result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。"
|
||||||
if result:
|
if result:
|
||||||
# IM 不渲染 HTML,把 <img>/<video> 还原为纯文本链接返回
|
# 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频
|
||||||
s = result.strip()
|
s = result.strip()
|
||||||
if s.startswith("<img") or s.startswith("<video"):
|
if s.startswith("<img") or s.startswith("<video"):
|
||||||
m = _re.search(r'src="([^"]+)"', s)
|
m = _re.search(r'src="([^"]+)"', s)
|
||||||
url = m.group(1) if m else ""
|
url = m.group(1) if m else ""
|
||||||
label = "图片" if s.startswith("<img") else "视频"
|
media_type = "image" if s.startswith("<img") else "video"
|
||||||
return f"已为你生成{label}:{url}" if url else "(生成完成,但未能提取媒体地址)"
|
if not url:
|
||||||
|
return "(生成完成,但未能提取媒体地址)"
|
||||||
|
# IM Bot 路径此前完全不记账 → 补写媒体消耗,驾驶舱才看得到图片/视频数据
|
||||||
|
_log_media_usage(db, user_id, ai_model, media_type, prompt)
|
||||||
|
return {"__media__": True, "media_type": media_type, "url": url}
|
||||||
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
|
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
|
||||||
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户
|
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户
|
||||||
if s.startswith("⚠️") or s.startswith("(媒体"):
|
if s.startswith("⚠️") or s.startswith("(媒体"):
|
||||||
return s
|
return s
|
||||||
return "(媒体生成未能返回有效内容,请重试)"
|
return "(媒体生成未能返回有效内容,请重试)"
|
||||||
return result
|
|
||||||
|
|
||||||
# ===== 2) 文本回复 + 技能自动发现(Issue 8)=====
|
# ===== 2) 文本回复 + 技能自动发现(Issue 8)=====
|
||||||
ai_model = resolve_auto_model("text", db)
|
ai_model = resolve_auto_model("text", db)
|
||||||
|
|||||||
Reference in new issue
Block a user