feat: 个人微信-对话历史持久化+平台内查阅页; 修复第二轮不回复(过滤/字段/重试); 多轮上下文

This commit is contained in:
WorkBuddy committed 2026-07-10 07:01:39 +08:00
1 parent 2eadd83304
commit 62f3986e04
5 files changed
+243 -20

No files matched your search

+142 -20
View File
@@ -99,13 +99,44 @@ def _wechat_mask(row) -> dict:
# ============================================================ # ============================================================
# 七虎AI 大脑(复用 channels 的真实 LLM 调用) # 七虎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。""" """调用七虎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 db = None
try: try:
from app.api.channels import _call_real_llm from app.api.channels import _call_real_llm
db = SessionLocal() db = SessionLocal()
return _call_real_llm(db, None, text, "个人微信") return _call_real_llm(db, None, prompt, "个人微信")
except Exception as e: except Exception as e:
return f"(AI 回复生成失败:{e})" return f"(AI 回复生成失败:{e})"
finally: 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: def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str) -> bool:
"""发送文本回复(必须带 context_token 关联到正确的对话窗口)""" """发送文本回复(必须带 context_token 关联到正确的对话窗口)。失败重试一次。"""
try: payload = {"msg": {
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/sendmessage",
headers=_wechat_headers(bot_token),
json={"msg": {
"to_user_id": to_user_id, "to_user_id": to_user_id,
"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": [{"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, timeout=20,
) )
return resp.ok last = resp.ok
if last:
return True
except Exception: except Exception:
return False last = False
return last
def _wechat_extract_text(msg: dict) -> str: 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 []): for it in (msg.get("item_list") or []):
if it.get("type") == 1 and it.get("text_item", {}).get("text"): if it.get("type") == 1 and it.get("text_item", {}).get("text"):
parts.append(it["text_item"]["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) 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"] bot_token = d["bot_token"]
baseurl = d.get("baseurl") or ILINK_BASE baseurl = d.get("baseurl") or ILINK_BASE
msgs, cursor = _wechat_get_updates(baseurl, bot_token, cursor) msgs, cursor = _wechat_get_updates(baseurl, bot_token, cursor)
owner_uid = d.get("user_id")
for m in msgs: for m in msgs:
if m.get("message_type") not in (1, None): if m.get("message_type") == 2:
continue # 只处理用户消息(2=bot 自己发出的) continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理
content = _wechat_extract_text(m) content = _wechat_extract_text(m)
if not content: if not content:
continue continue
reply = _qihua_reply(content) sender = _wechat_sender_id(m)
_wechat_send_message( ctx = _wechat_context_token(m)
baseurl, bot_token, # 诊断:记录原始入站消息,定位字段名 / 第二轮不回复
m.get("from_user_id", ""), try:
m.get("context_token", ""), with open("/tmp/wechat_inbound.log", "a", encoding="utf-8") as _f:
reply, _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) time.sleep(1)
except Exception: except Exception:
time.sleep(5) 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(): def start_all_wechat_receivers():
"""在应用启动时调用,恢复此前已连接用户的个人微信接收线程。""" """在应用启动时调用,恢复此前已连接用户的个人微信接收线程。"""
db = SessionLocal() db = SessionLocal()
+2
View File
@@ -23,6 +23,7 @@ import AssetsPage from '@/pages/front/AssetsPage';
import NovelPreviewPage from '@/pages/front/NovelPreviewPage'; import NovelPreviewPage from '@/pages/front/NovelPreviewPage';
import EvolutionDashboard from '@/pages/front/EvolutionDashboard'; import EvolutionDashboard from '@/pages/front/EvolutionDashboard';
import WechatBot from '@/pages/front/WechatBot'; import WechatBot from '@/pages/front/WechatBot';
import WechatHistory from '@/pages/front/WechatHistory';
// Admin Pages // Admin Pages
import AdminDashboard from '@/pages/admin/Dashboard'; import AdminDashboard from '@/pages/admin/Dashboard';
@@ -119,6 +120,7 @@ const App: React.FC = () => {
<Route path="assets/novel/:id" element={<NovelPreviewPage />} /> <Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="evolution" element={<EvolutionDashboard />} /> <Route path="evolution" element={<EvolutionDashboard />} />
<Route path="wechat-bot" element={<WechatBot />} /> <Route path="wechat-bot" element={<WechatBot />} />
<Route path="wechat-history" element={<WechatHistory />} />
</Route> </Route>
{/* Admin Routes */} {/* Admin Routes */}
+1
View File
@@ -1000,4 +1000,5 @@ export const imUserAPI = {
qrcode: () => post<any>('/im/wechat/qrcode'), qrcode: () => post<any>('/im/wechat/qrcode'),
qrcodeStatus: (qrcode: string) => post<any>(`/im/wechat/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`), qrcodeStatus: (qrcode: string) => post<any>(`/im/wechat/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`),
disconnect: () => post<any>('/im/wechat/disconnect'), disconnect: () => post<any>('/im/wechat/disconnect'),
history: () => get<any>('/im/wechat/history'),
}; };
@@ -18,6 +18,7 @@ const navGroups = [
items: [ items: [
{ path: '/channels', icon: 'channel', label: '频道管理' }, { path: '/channels', icon: 'channel', label: '频道管理' },
{ path: '/wechat-bot', icon: 'wechat', label: '微信机器人' }, { path: '/wechat-bot', icon: 'wechat', label: '微信机器人' },
{ path: '/wechat-history', icon: 'chat', label: '对话记录' },
], ],
}, },
{ {
@@ -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<any[]>([]);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const bottomRef = useRef<HTMLDivElement>(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 (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>微信对话记录</h1>
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
你通过个人微信与七虎AI 的对话历史,按时间顺序记录,可随时回看(仅本人可见,按账号隔离)。
</p>
</div>
<button onClick={load} disabled={loading} style={btnStyle}>{loading ? '刷新中…' : '刷新'}</button>
</div>
{/* Toast */}
{toast && (
<div style={{
position: 'fixed', top: '20px', right: '20px', zIndex: 9999,
padding: '12px 24px', borderRadius: '8px',
background: toast.type === 'success' ? '#10b981' : '#ef4444',
color: '#fff', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}>{toast.msg}</div>
)}
{/* 记录列表 */}
<div style={{ background: '#F8F9FC', borderRadius: '12px', padding: '16px', minHeight: '420px' }}>
{messages.length === 0 ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '380px', color: '#9CA3AF', fontSize: '14px', textAlign: 'center', padding: '0 24px' }}>
{loading ? '加载中…' : '暂无对话记录。请先在「微信机器人」中绑定并发送消息,对话记录会显示在这里。'}
</div>
) : (
messages.map((m, i) => (
<div key={i} style={{ display: 'flex', justifyContent: m.role === 'user' ? 'flex-end' : 'flex-start', marginBottom: '14px' }}>
<div style={{ maxWidth: '78%' }}>
<div style={{ fontSize: '11px', color: '#9CA3AF', marginBottom: '4px', textAlign: m.role === 'user' ? 'right' : 'left' }}>
{m.role === 'user' ? '我(微信)' : '七虎AI'} · {formatTime(m.created_at)}
</div>
<div style={{
padding: '10px 14px', borderRadius: '12px', fontSize: '14px', lineHeight: '1.6',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
background: m.role === 'user' ? '#6C63FF' : '#FFFFFF',
color: m.role === 'user' ? '#fff' : '#1A1F36',
border: m.role === 'user' ? 'none' : '1px solid #E5E7EB',
}}>{m.content}</div>
</div>
</div>
))
)}
<div ref={bottomRef} />
</div>
</div>
</div>
);
};
const btnStyle: React.CSSProperties = {
padding: '8px 20px', background: '#6C63FF', border: 'none', borderRadius: '6px',
color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer',
};
export default WechatHistory;