feat: 个人微信-对话历史持久化+平台内查阅页; 修复第二轮不回复(过滤/字段/重试); 多轮上下文
This commit is contained in:
1 parent
2eadd83304
commit
62f3986e04
5 files changed
+243
-20
No files matched your search
@@ -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": {
|
||||
"""发送文本回复(必须带 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,
|
||||
)
|
||||
return resp.ok
|
||||
last = resp.ok
|
||||
if last:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
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()
|
||||
|
||||
@@ -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 = () => {
|
||||
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
|
||||
<Route path="evolution" element={<EvolutionDashboard />} />
|
||||
<Route path="wechat-bot" element={<WechatBot />} />
|
||||
<Route path="wechat-history" element={<WechatHistory />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin Routes */}
|
||||
|
||||
@@ -1000,4 +1000,5 @@ export const imUserAPI = {
|
||||
qrcode: () => post<any>('/im/wechat/qrcode'),
|
||||
qrcodeStatus: (qrcode: string) => post<any>(`/im/wechat/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`),
|
||||
disconnect: () => post<any>('/im/wechat/disconnect'),
|
||||
history: () => get<any>('/im/wechat/history'),
|
||||
};
|
||||
@@ -18,6 +18,7 @@ const navGroups = [
|
||||
items: [
|
||||
{ path: '/channels', icon: 'channel', 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;
|
||||
Reference in new issue
Block a user