fix(wechat-bot): ①对话时间统一北京时间 ②媒体内联发送(微信直看图片/视频) ③媒体生成计入Token驾驶舱 ④清空技能市场

This commit is contained in:
QihuAI Design committed 2026-07-15 23:37:30 +08:00
1 parent 7739f17594
commit da89a27bca
3 files changed
+166 -42

No files matched your search

+49 -13
View File
@@ -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,19 +2097,20 @@ 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 异步补写真实消耗,此处跳过避免占位文本重复记账)
token_usage = TokenUsageLog( if not media_generating:
id=gen_uuid(), token_usage = TokenUsageLog(
user_id=current_user.id, id=gen_uuid(),
model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), user_id=current_user.id,
model_name=ai_model.name if ai_model else None, model_id=ai_model.id if ai_model else (req.model_id or conv.model_id),
conversation_id=conv_id, model_name=ai_model.name if ai_model else None,
prompt_tokens=prompt_tokens or len(req.content) // 2, conversation_id=conv_id,
completion_tokens=completion_tokens or ai_token_count, prompt_tokens=prompt_tokens or len(req.content) // 2,
total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count), completion_tokens=completion_tokens or ai_token_count,
created_at=cst_now(), total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count),
) created_at=cst_now(),
db.add(token_usage) )
db.add(token_usage)
# 进化轨迹记录 # 进化轨迹记录
try: try:
+74 -23
View File
@@ -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))
+43 -6
View File
@@ -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)