diff --git a/backend/python/app/api/im_bot_user.py b/backend/python/app/api/im_bot_user.py index b51826d..a6bc102 100644 --- a/backend/python/app/api/im_bot_user.py +++ b/backend/python/app/api/im_bot_user.py @@ -99,13 +99,44 @@ def _wechat_mask(row) -> dict: # ============================================================ # 七虎AI 大脑(复用 channels 的真实 LLM 调用) # ============================================================ -def _qihua_reply(text: str) -> str: +def _recent_history(user_id: str, limit: int = 6) -> str: + """拼接最近若干轮对话,作为多轮上下文。""" + db = None + try: + db = SessionLocal() + rows = _execute_raw(db, text( + "SELECT role, content FROM im_bot_chat_log " + "WHERE user_id=:uid ORDER BY id DESC LIMIT :lim" + ), {"uid": user_id, "lim": limit}) + pairs = [] + for r in reversed(rows): + dd = _row_to_dict(r) + role = "用户" if dd["role"] == "user" else "七虎AI" + pairs.append(f"{role}:{dd['content']}") + return "\n".join(pairs) + except Exception: + return "" + finally: + if db: + db.close() + + +def _qihua_reply(text: str, user_id: str = None) -> str: """调用七虎AI 生成回复。自行创建 DB 会话,避免上层线程无可用 session。""" + prompt = text + if user_id: + hist = _recent_history(user_id) + if hist: + prompt = ( + "以下是你与该用户最近的对话历史,请结合上下文作答:\n" + f"{hist}\n\n" + f"用户当前问题:{text}" + ) db = None try: from app.api.channels import _call_real_llm db = SessionLocal() - return _call_real_llm(db, None, text, "个人微信") + return _call_real_llm(db, None, prompt, "个人微信") except Exception as e: return f"(AI 回复生成失败:{e})" finally: @@ -134,23 +165,29 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str): def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str) -> bool: - """发送文本回复(必须带 context_token 关联到正确的对话窗口)""" - try: - resp = requests.post( - baseurl.rstrip("/") + "/ilink/bot/sendmessage", - headers=_wechat_headers(bot_token), - json={"msg": { - "to_user_id": to_user_id, - "message_type": 2, - "message_state": 2, - "context_token": context_token, - "item_list": [{"type": 1, "text_item": {"text": text}}], - }}, - timeout=20, - ) - return resp.ok - except Exception: - return False + """发送文本回复(必须带 context_token 关联到正确的对话窗口)。失败重试一次。""" + payload = {"msg": { + "to_user_id": to_user_id, + "message_type": 2, + "message_state": 2, + "context_token": context_token, + "item_list": [{"type": 1, "text_item": {"text": text}}], + }} + last = False + for _ in range(2): + try: + resp = requests.post( + baseurl.rstrip("/") + "/ilink/bot/sendmessage", + headers=_wechat_headers(bot_token), + json=payload, + timeout=20, + ) + last = resp.ok + if last: + return True + except Exception: + last = False + return last def _wechat_extract_text(msg: dict) -> str: @@ -158,9 +195,55 @@ def _wechat_extract_text(msg: dict) -> str: for it in (msg.get("item_list") or []): if it.get("type") == 1 and it.get("text_item", {}).get("text"): parts.append(it["text_item"]["text"]) + if not parts: + for k in ("text", "content", "message", "msg"): + v = msg.get(k) + if isinstance(v, str) and v.strip(): + parts.append(v) return "\n".join(parts) +def _wechat_sender_id(m: dict) -> str: + """从入站消息中提取发送者 id(兼容多套字段名)。""" + for k in ("from_user_id", "from_user_name", "from_username", "user_id", + "sender_id", "wxid", "from_wxid", "username"): + v = m.get(k) + if v: + return str(v) + return "" + + +def _wechat_context_token(m: dict) -> str: + """提取 context_token(回复必须回带,否则不关联到对话窗口)。""" + for k in ("context_token", "ctx_token", "conversation_id", "conv_id", + "msg_id", "context_id", "session_id"): + v = m.get(k) + if v: + return str(v) + return "" + + +def _log_chat(user_id, platform, from_user, context_token, role, content): + """持久化 IM Bot 对话历史,便于平台内查阅。""" + db = None + try: + db = SessionLocal() + _execute_raw(db, text( + "INSERT INTO im_bot_chat_log " + "(user_id, platform, from_user, context_token, role, content, created_at) " + "VALUES (:uid, :platform, :fu, :ctx, :role, :content, :created)" + ), { + "uid": user_id, "platform": platform, "fu": from_user, + "ctx": context_token, "role": role, "content": content, + "created": datetime.now(), + }) + except Exception: + pass + finally: + if db: + db.close() + + # ============================================================ # 后台接收线程(每用户独立) # ============================================================ @@ -183,19 +266,35 @@ def _wechat_receiver_loop(account_id: int): bot_token = d["bot_token"] baseurl = d.get("baseurl") or ILINK_BASE msgs, cursor = _wechat_get_updates(baseurl, bot_token, cursor) + owner_uid = d.get("user_id") for m in msgs: - if m.get("message_type") not in (1, None): - continue # 只处理用户消息(2=bot 自己发出的) + if m.get("message_type") == 2: + continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理 content = _wechat_extract_text(m) if not content: continue - reply = _qihua_reply(content) - _wechat_send_message( - baseurl, bot_token, - m.get("from_user_id", ""), - m.get("context_token", ""), - reply, - ) + sender = _wechat_sender_id(m) + ctx = _wechat_context_token(m) + # 诊断:记录原始入站消息,定位字段名 / 第二轮不回复 + try: + with open("/tmp/wechat_inbound.log", "a", encoding="utf-8") as _f: + _f.write(datetime.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 = _wechat_send_message(baseurl, bot_token, sender, ctx, reply) + # 持久化对话历史 + if owner_uid: + _log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content) + _log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply) + try: + with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: + _f.write(datetime.now().isoformat() + " ok=" + str(ok) + + " sender=" + repr(sender) + " ctx=" + repr(ctx) + "\n") + except Exception: + pass time.sleep(1) except Exception: time.sleep(5) @@ -354,6 +453,29 @@ async def wechat_disconnect(user: User = Depends(get_current_user)): # ============================================================ # 启动恢复:为所有已连接用户账号拉起接收线程 # ============================================================ +@router.get("/history") +async def wechat_history( + user: User = Depends(get_current_user), + limit: int = Query(200), +): + """查询当前用户通过个人微信与七虎AI 的对话历史""" + db = SessionLocal() + try: + rows = _execute_raw( + db, + text("SELECT id, role, content, platform, from_user, created_at " + "FROM im_bot_chat_log WHERE user_id=:uid ORDER BY id DESC LIMIT :lim"), + {"uid": user.id, "lim": min(int(limit), 500)}, + ) + items = [_row_to_dict(r) for r in rows] + items.reverse() + return {"success": True, "data": {"messages": items, "total": len(items)}} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + def start_all_wechat_receivers(): """在应用启动时调用,恢复此前已连接用户的个人微信接收线程。""" db = SessionLocal() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 258245e..d617651 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import AssetsPage from '@/pages/front/AssetsPage'; import NovelPreviewPage from '@/pages/front/NovelPreviewPage'; import EvolutionDashboard from '@/pages/front/EvolutionDashboard'; import WechatBot from '@/pages/front/WechatBot'; +import WechatHistory from '@/pages/front/WechatHistory'; // Admin Pages import AdminDashboard from '@/pages/admin/Dashboard'; @@ -119,6 +120,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> {/* Admin Routes */} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 8906c3d..e104304 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1000,4 +1000,5 @@ export const imUserAPI = { qrcode: () => post('/im/wechat/qrcode'), qrcodeStatus: (qrcode: string) => post(`/im/wechat/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`), disconnect: () => post('/im/wechat/disconnect'), + history: () => get('/im/wechat/history'), }; diff --git a/frontend/src/components/layout/FrontLayout.tsx b/frontend/src/components/layout/FrontLayout.tsx index 0d4adc2..54f5da2 100644 --- a/frontend/src/components/layout/FrontLayout.tsx +++ b/frontend/src/components/layout/FrontLayout.tsx @@ -18,6 +18,7 @@ const navGroups = [ items: [ { path: '/channels', icon: 'channel', label: '频道管理' }, { path: '/wechat-bot', icon: 'wechat', label: '微信机器人' }, + { path: '/wechat-history', icon: 'chat', label: '对话记录' }, ], }, { diff --git a/frontend/src/pages/front/WechatHistory.tsx b/frontend/src/pages/front/WechatHistory.tsx new file mode 100644 index 0000000..d545a94 --- /dev/null +++ b/frontend/src/pages/front/WechatHistory.tsx @@ -0,0 +1,97 @@ +// @ts-nocheck +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { imUserAPI } from '@/api'; + +const WechatHistory: React.FC = () => { + const [loading, setLoading] = useState(true); + const [messages, setMessages] = useState([]); + const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null); + const bottomRef = useRef(null); + + const showToast = (msg: string, type: 'success' | 'error' = 'success') => { + setToast({ msg, type }); + setTimeout(() => setToast(null), 3000); + }; + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await imUserAPI.history(); + if ((res as any)?.success && (res as any).data) { + setMessages((res as any).data.messages || []); + } else { + showToast('加载失败', 'error'); + } + } catch (e) { + showToast('加载失败', 'error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); + + const formatTime = (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-'); + + return ( + + + {/* Header */} + + + 微信对话记录 + + 你通过个人微信与七虎AI 的对话历史,按时间顺序记录,可随时回看(仅本人可见,按账号隔离)。 + + + {loading ? '刷新中…' : '刷新'} + + + {/* Toast */} + {toast && ( + {toast.msg} + )} + + {/* 记录列表 */} + + {messages.length === 0 ? ( + + {loading ? '加载中…' : '暂无对话记录。请先在「微信机器人」中绑定并发送消息,对话记录会显示在这里。'} + + ) : ( + messages.map((m, i) => ( + + + + {m.role === 'user' ? '我(微信)' : '七虎AI'} · {formatTime(m.created_at)} + + {m.content} + + + )) + )} + + + + + ); +}; + +const btnStyle: React.CSSProperties = { + padding: '8px 20px', background: '#6C63FF', border: 'none', borderRadius: '6px', + color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer', +}; + +export default WechatHistory;
+ 你通过个人微信与七虎AI 的对话历史,按时间顺序记录,可随时回看(仅本人可见,按账号隔离)。 +