From da89a27bca966b23d8c330cc10aca02992244d1c Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Wed, 15 Jul 2026 23:37:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(wechat-bot):=20=E2=91=A0=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=BB=9F=E4=B8=80=E5=8C=97=E4=BA=AC=E6=97=B6?= =?UTF-8?q?=E9=97=B4=20=E2=91=A1=E5=AA=92=E4=BD=93=E5=86=85=E8=81=94?= =?UTF-8?q?=E5=8F=91=E9=80=81(=E5=BE=AE=E4=BF=A1=E7=9B=B4=E7=9C=8B?= =?UTF-8?q?=E5=9B=BE=E7=89=87/=E8=A7=86=E9=A2=91)=20=E2=91=A2=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E7=94=9F=E6=88=90=E8=AE=A1=E5=85=A5Token=E9=A9=BE?= =?UTF-8?q?=E9=A9=B6=E8=88=B1=20=E2=91=A3=E6=B8=85=E7=A9=BA=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E5=B8=82=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/python/app/api/chat.py | 62 +++++++++++++---- backend/python/app/api/im_bot_user.py | 97 ++++++++++++++++++++------- backend/python/app/api/im_llm.py | 49 ++++++++++++-- 3 files changed, 166 insertions(+), 42 deletions(-) diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py index 9e23cd4..a0e5647 100644 --- a/backend/python/app/api/chat.py +++ b/backend/python/app/api/chat.py @@ -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 db.commit() 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: logger.warning(f"[_generate_media_task] 后台生成异常: {e}") finally: @@ -468,6 +471,38 @@ async def _generate_media_task(ai_msg_id: str, ai_model_id: str, prompt: str, us 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) # ============================================================ @@ -2062,19 +2097,20 @@ async def send_message( conv.knowledge_graph_count = (conv.knowledge_graph_count or 0) + 1 conv.updated_at = cst_now() - # 记录Token使用 - token_usage = TokenUsageLog( - id=gen_uuid(), - user_id=current_user.id, - model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), - model_name=ai_model.name if ai_model else None, - conversation_id=conv_id, - prompt_tokens=prompt_tokens or len(req.content) // 2, - completion_tokens=completion_tokens or ai_token_count, - total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count), - created_at=cst_now(), - ) - db.add(token_usage) + # 记录Token使用(媒体生成走 _generate_media_task 异步补写真实消耗,此处跳过避免占位文本重复记账) + if not media_generating: + token_usage = TokenUsageLog( + id=gen_uuid(), + user_id=current_user.id, + model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), + model_name=ai_model.name if ai_model else None, + conversation_id=conv_id, + prompt_tokens=prompt_tokens or len(req.content) // 2, + completion_tokens=completion_tokens or ai_token_count, + total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count), + created_at=cst_now(), + ) + db.add(token_usage) # 进化轨迹记录 try: diff --git a/backend/python/app/api/im_bot_user.py b/backend/python/app/api/im_bot_user.py index 9453301..0af85fc 100644 --- a/backend/python/app/api/im_bot_user.py +++ b/backend/python/app/api/im_bot_user.py @@ -17,7 +17,7 @@ import json import random import threading import time -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional import requests @@ -31,6 +31,15 @@ from ..models import User 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) # ============================================================ @@ -136,7 +145,7 @@ def _qihua_reply(text: str, user_id: str = None) -> str: try: from app.api.im_llm import _call_real_llm db = SessionLocal() - return _call_real_llm(db, None, prompt, "个人微信") + return _call_real_llm(db, None, prompt, "个人微信", user_id=user_id) except Exception as e: return f"(AI 回复生成失败:{e})" finally: @@ -164,9 +173,9 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str): return [], cursor -def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str): - """发送文本回复(必须带 context_token 关联到正确的对话窗口)。 - 返回 (ok, ret, body)。ret=-14=会话过期需重扫;ret=-2=token 失效。""" +def _wechat_post_msg(baseurl: str, bot_token: str, to_user_id: str, context_token: str, item_list: list): + """向 iLink 发送一条消息(item_list 可含文本/图片/视频等)。返回 (ok, ret, body)。 + ret=-14=会话过期需重扫;ret=-2=token 失效。""" import uuid as _uuid payload = { "msg": { @@ -176,7 +185,7 @@ def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_ "message_type": 2, "message_state": 2, "context_token": context_token, - "item_list": [{"type": 1, "text_item": {"text": text}}], + "item_list": item_list, }, "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 try: 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])) except Exception: 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 +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: parts = [] 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, "ctx": context_token, "role": role, "content": content, - "created": datetime.now(), + "created": _beijing_now(), }) except Exception: pass @@ -282,7 +316,7 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str: return _row_to_dict(rows[0])["id"] import uuid as _uuid cid = str(_uuid.uuid4()) - now = datetime.now() + now = _beijing_now() _execute_raw(db, text( "INSERT INTO conversations " "(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)" ), { "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: db = SessionLocal() 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) # assistant 比 user 晚 50ms,保证排序严格递增,避免同秒时 ORDER BY 乱序 _append_ai_message(db, cid, "assistant", assistant_content, created=now + timedelta(milliseconds=50)) _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: pass finally: @@ -335,7 +369,7 @@ def _mark_wechat_expired(account_id: int): _execute_raw(db, text( "UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, " "updated_at=:ts WHERE id=:aid" - ), {"aid": account_id, "ts": datetime.now()}) + ), {"aid": account_id, "ts": _beijing_now()}) except Exception: pass finally: @@ -374,24 +408,39 @@ def _wechat_receiver_loop(account_id: int): # 诊断:记录原始入站消息,定位字段名 / 第二轮不回复 try: 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") except Exception: pass # 先生成回复(此时历史尚未包含本条,避免重复) 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'生成图片' + if media_type == "image" + else f'' + ) + 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 owner_uid: _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 会话窗 - _mirror_to_ai_chat(owner_uid, content, reply) + _mirror_to_ai_chat(owner_uid, content, reply_for_log) else: try: 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))) except Exception: pass @@ -511,8 +560,9 @@ async def wechat_qrcode_status( "user_id": user.id, "username": user.username, "bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl, - "connected_at": datetime.now(), - "created_at": datetime.now(), "updated_at": datetime.now(), + # 统一按北京时间存储,避免容器 UTC 导致显示偏差 + "connected_at": _beijing_now(), + "created_at": _beijing_now(), "updated_at": _beijing_now(), }) else: _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, "bot_token": bot_token, "ilink_bot_id": ilink_bot_id, - "baseurl": baseurl, "connected_at": datetime.now(), - "updated_at": datetime.now(), "id": _row_to_dict(row)["id"], + "baseurl": baseurl, + "connected_at": _beijing_now(), + "updated_at": _beijing_now(), "id": _row_to_dict(row)["id"], }) row = _wechat_row_for_user(db, user.id) 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( "UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', " "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": "已断开个人微信连接"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/python/app/api/im_llm.py b/backend/python/app/api/im_llm.py index f4f6d3e..0665705 100644 --- a/backend/python/app/api/im_llm.py +++ b/backend/python/app/api/im_llm.py @@ -14,7 +14,9 @@ IM Bot 真实 LLM 调用。 """ import asyncio import json +import logging import re as _re +from datetime import datetime, timezone, timedelta from typing import Optional from urllib.parse import urlparse as _urlparse @@ -22,7 +24,9 @@ import httpx from sqlalchemy.orm import Session 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 同值。 @@ -65,7 +69,37 @@ def _run_async(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 复用。支持媒体意图路由与已安装技能自动发现。""" try: from app.api.chat import ( @@ -88,19 +122,22 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im # 绝不直接把原始异常(如 "Server disconnected")甩给用户 result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。" if result: - # IM 不渲染 HTML,把 /