feat: 个人微信-对话历史持久化+平台内查阅页; 修复第二轮不回复(过滤/字段/重试); 多轮上下文
This commit is contained in:
1 parent
2eadd83304
commit
62f3986e04
5 files changed
+251
-28
No files matched your search
@@ -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(
|
"to_user_id": to_user_id,
|
||||||
baseurl.rstrip("/") + "/ilink/bot/sendmessage",
|
"message_type": 2,
|
||||||
headers=_wechat_headers(bot_token),
|
"message_state": 2,
|
||||||
json={"msg": {
|
"context_token": context_token,
|
||||||
"to_user_id": to_user_id,
|
"item_list": [{"type": 1, "text_item": {"text": text}}],
|
||||||
"message_type": 2,
|
}}
|
||||||
"message_state": 2,
|
last = False
|
||||||
"context_token": context_token,
|
for _ in range(2):
|
||||||
"item_list": [{"type": 1, "text_item": {"text": text}}],
|
try:
|
||||||
}},
|
resp = requests.post(
|
||||||
timeout=20,
|
baseurl.rstrip("/") + "/ilink/bot/sendmessage",
|
||||||
)
|
headers=_wechat_headers(bot_token),
|
||||||
return resp.ok
|
json=payload,
|
||||||
except Exception:
|
timeout=20,
|
||||||
return False
|
)
|
||||||
|
last = resp.ok
|
||||||
|
if last:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in new issue
Block a user