feat(im): mirror WeChat bot chats into AI chat as WeXin Bot conversation; add conversations.source; migrate history; remove redundant history page
This commit is contained in:
1 parent
7c71d193fc
commit
7b7871a14d
5 files changed
+156
-67
No files matched your search
@@ -691,6 +691,7 @@ def list_conversations(
|
|||||||
"status": conv.status,
|
"status": conv.status,
|
||||||
"pinned": conv.pinned,
|
"pinned": conv.pinned,
|
||||||
"knowledge_graph_count": conv.knowledge_graph_count or 0,
|
"knowledge_graph_count": conv.knowledge_graph_count or 0,
|
||||||
|
"source": getattr(conv, "source", None),
|
||||||
"created_at": str(conv.created_at) if conv.created_at else None,
|
"created_at": str(conv.created_at) if conv.created_at else None,
|
||||||
"updated_at": str(conv.updated_at) if conv.updated_at else None,
|
"updated_at": str(conv.updated_at) if conv.updated_at else None,
|
||||||
})
|
})
|
||||||
@@ -1140,69 +1141,8 @@ def clear_conversation(
|
|||||||
return {"code": 0, "message": "对话上下文已清空"}
|
return {"code": 0, "message": "对话上下文已清空"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/conversations/{conv_id}/to-knowledge-graph")
|
def _build_knowledge_graph(db: Session, kb: KnowledgeBase) -> dict:
|
||||||
def convert_to_knowledge_graph(
|
"""从知识库文档构建知识图谱节点和边,返回 graph_data。"""
|
||||||
conv_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""将对话内容转换为知识图谱"""
|
|
||||||
conv = db.query(Conversation).filter(
|
|
||||||
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
|
|
||||||
).first()
|
|
||||||
if not conv:
|
|
||||||
raise HTTPException(status_code=404, detail="对话不存在")
|
|
||||||
|
|
||||||
messages = (
|
|
||||||
db.query(Message)
|
|
||||||
.filter(Message.conversation_id == conv_id)
|
|
||||||
.order_by(asc(Message.created_at))
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not messages:
|
|
||||||
raise HTTPException(status_code=400, detail="对话中没有消息, 无法生成知识图谱")
|
|
||||||
|
|
||||||
kb_name = f"对话图谱-{conv.title}"
|
|
||||||
kb = KnowledgeBase(
|
|
||||||
id=gen_uuid(),
|
|
||||||
user_id=current_user.id,
|
|
||||||
name=kb_name,
|
|
||||||
description=f"从对话「{conv.title}」自动生成的知识图谱",
|
|
||||||
directory_path="/",
|
|
||||||
document_count=0,
|
|
||||||
created_at=cst_now(),
|
|
||||||
updated_at=cst_now(),
|
|
||||||
)
|
|
||||||
db.add(kb)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
doc_count = 0
|
|
||||||
for idx, msg in enumerate(messages):
|
|
||||||
role_label = "用户" if msg.role == "user" else "AI"
|
|
||||||
preview = re.sub(r'[\\n\\r\\t]+', ' ', msg.content[:40]).strip()
|
|
||||||
filename = f"msg_{idx+1:03d}_{role_label}_{preview}.txt"
|
|
||||||
|
|
||||||
doc = KnowledgeDocument(
|
|
||||||
id=gen_uuid(),
|
|
||||||
knowledge_base_id=kb.id,
|
|
||||||
name=filename,
|
|
||||||
filename=filename,
|
|
||||||
file_type="txt",
|
|
||||||
file_size=len(msg.content.encode('utf-8')),
|
|
||||||
content=msg.content,
|
|
||||||
status="completed",
|
|
||||||
chunk_count=max(1, len(msg.content) // 500 + 1),
|
|
||||||
created_at=cst_now(),
|
|
||||||
updated_at=cst_now(),
|
|
||||||
)
|
|
||||||
db.add(doc)
|
|
||||||
doc_count += 1
|
|
||||||
|
|
||||||
kb.document_count = doc_count
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
# 构建知识图谱节点和边
|
|
||||||
documents = db.query(KnowledgeDocument).filter(
|
documents = db.query(KnowledgeDocument).filter(
|
||||||
and_(
|
and_(
|
||||||
KnowledgeDocument.knowledge_base_id == kb.id,
|
KnowledgeDocument.knowledge_base_id == kb.id,
|
||||||
@@ -1268,7 +1208,96 @@ def convert_to_knowledge_graph(
|
|||||||
|
|
||||||
kb.knowledge_graph_count = len(graph_nodes)
|
kb.knowledge_graph_count = len(graph_nodes)
|
||||||
kb.updated_at = cst_now()
|
kb.updated_at = cst_now()
|
||||||
conv.knowledge_graph_count = (conv.knowledge_graph_count or 0) + 1
|
return graph_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conversations/{conv_id}/to-knowledge-graph")
|
||||||
|
def convert_to_knowledge_graph(
|
||||||
|
conv_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""将对话内容转换为知识图谱(幂等:同一对话仅生成一次知识库)"""
|
||||||
|
conv = db.query(Conversation).filter(
|
||||||
|
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
|
||||||
|
).first()
|
||||||
|
if not conv:
|
||||||
|
raise HTTPException(status_code=404, detail="对话不存在")
|
||||||
|
|
||||||
|
messages = (
|
||||||
|
db.query(Message)
|
||||||
|
.filter(Message.conversation_id == conv_id)
|
||||||
|
.order_by(asc(Message.created_at))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
raise HTTPException(status_code=400, detail="对话中没有消息, 无法生成知识图谱")
|
||||||
|
|
||||||
|
# 幂等:若该对话已生成过知识图谱且知识库仍存在,则直接刷新返回
|
||||||
|
if conv.knowledge_graph_base_id:
|
||||||
|
existing_kb = db.query(KnowledgeBase).filter(
|
||||||
|
KnowledgeBase.id == conv.knowledge_graph_base_id
|
||||||
|
).first()
|
||||||
|
if existing_kb:
|
||||||
|
graph_data = _build_knowledge_graph(db, existing_kb)
|
||||||
|
conv.knowledge_graph_count = 1
|
||||||
|
conv.updated_at = cst_now()
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"knowledge_base_id": existing_kb.id,
|
||||||
|
"knowledge_base_name": existing_kb.name,
|
||||||
|
"graph": graph_data,
|
||||||
|
"knowledge_graph_count": 1,
|
||||||
|
"already_exists": True,
|
||||||
|
},
|
||||||
|
"message": "知识图谱已存在, 已刷新展示",
|
||||||
|
}
|
||||||
|
|
||||||
|
kb_name = f"对话图谱-{conv.title}"
|
||||||
|
kb = KnowledgeBase(
|
||||||
|
id=gen_uuid(),
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=kb_name,
|
||||||
|
description=f"从对话「{conv.title}」自动生成的知识图谱",
|
||||||
|
directory_path="/",
|
||||||
|
document_count=0,
|
||||||
|
created_at=cst_now(),
|
||||||
|
updated_at=cst_now(),
|
||||||
|
)
|
||||||
|
db.add(kb)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
doc_count = 0
|
||||||
|
for idx, msg in enumerate(messages):
|
||||||
|
role_label = "用户" if msg.role == "user" else "AI"
|
||||||
|
preview = re.sub(r'[\\n\\r\\t]+', ' ', msg.content[:40]).strip()
|
||||||
|
filename = f"msg_{idx+1:03d}_{role_label}_{preview}.txt"
|
||||||
|
|
||||||
|
doc = KnowledgeDocument(
|
||||||
|
id=gen_uuid(),
|
||||||
|
knowledge_base_id=kb.id,
|
||||||
|
filename=filename,
|
||||||
|
file_type="txt",
|
||||||
|
file_size=len(msg.content.encode('utf-8')),
|
||||||
|
content=msg.content,
|
||||||
|
status="completed",
|
||||||
|
chunk_count=max(1, len(msg.content) // 500 + 1),
|
||||||
|
created_at=cst_now(),
|
||||||
|
updated_at=cst_now(),
|
||||||
|
)
|
||||||
|
db.add(doc)
|
||||||
|
doc_count += 1
|
||||||
|
|
||||||
|
kb.document_count = doc_count
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# 构建知识图谱节点和边
|
||||||
|
graph_data = _build_knowledge_graph(db, kb)
|
||||||
|
conv.knowledge_graph_base_id = kb.id
|
||||||
|
conv.knowledge_graph_count = 1
|
||||||
conv.updated_at = cst_now()
|
conv.updated_at = cst_now()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -1285,6 +1314,8 @@ def convert_to_knowledge_graph(
|
|||||||
"knowledge_base_id": kb.id,
|
"knowledge_base_id": kb.id,
|
||||||
"knowledge_base_name": kb.name,
|
"knowledge_base_name": kb.name,
|
||||||
"graph": graph_data,
|
"graph": graph_data,
|
||||||
|
"knowledge_graph_count": 1,
|
||||||
|
"already_exists": False,
|
||||||
},
|
},
|
||||||
"message": f"知识图谱构建完成",
|
"message": f"知识图谱构建完成",
|
||||||
}
|
}
|
||||||
@@ -1299,6 +1330,7 @@ def get_models(db: Session = Depends(get_db)):
|
|||||||
result.append({
|
result.append({
|
||||||
"id": m.id,
|
"id": m.id,
|
||||||
"name": m.name,
|
"name": m.name,
|
||||||
|
"display_name": getattr(m, "display_name", None) or m.name,
|
||||||
"provider_id": m.provider_id,
|
"provider_id": m.provider_id,
|
||||||
"provider_name": provider.name if provider else "",
|
"provider_name": provider.name if provider else "",
|
||||||
"status": m.status,
|
"status": m.status,
|
||||||
|
|||||||
@@ -264,6 +264,63 @@ def _log_chat(user_id, platform, from_user, context_token, role, content):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 镜像到 AI 聊天(标准 conversations/messages)
|
||||||
|
# 个人微信对话作为一个独立会话窗(默认名 "WeXin Bot")出现在 AI 聊天中,
|
||||||
|
# 从而复用 AI 聊天模块的全部功能(滚动翻阅、重命名、置顶、删除等)。
|
||||||
|
# ============================================================
|
||||||
|
WECHAT_CONV_TITLE = "WeXin Bot"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
|
||||||
|
"""按 user_id 找/建 source='wechat_bot' 的会话,返回会话 id。"""
|
||||||
|
rows = _execute_raw(db, text(
|
||||||
|
"SELECT id FROM conversations WHERE user_id=:uid AND source='wechat_bot' "
|
||||||
|
"AND status<>'archived' ORDER BY updated_at DESC LIMIT 1"
|
||||||
|
), {"uid": user_id})
|
||||||
|
if rows:
|
||||||
|
return _row_to_dict(rows[0])["id"]
|
||||||
|
import uuid as _uuid
|
||||||
|
cid = str(_uuid.uuid4())
|
||||||
|
now = datetime.now()
|
||||||
|
_execute_raw(db, text(
|
||||||
|
"INSERT INTO conversations "
|
||||||
|
"(id, user_id, title, status, pinned, knowledge_graph_count, source, created_at, updated_at) "
|
||||||
|
"VALUES (:id, :uid, :title, 'active', 0, 0, 'wechat_bot', :c, :u)"
|
||||||
|
), {"id": cid, "uid": user_id, "title": WECHAT_CONV_TITLE, "c": now, "u": now})
|
||||||
|
return cid
|
||||||
|
|
||||||
|
|
||||||
|
def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None):
|
||||||
|
import uuid as _uuid
|
||||||
|
_execute_raw(db, text(
|
||||||
|
"INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) "
|
||||||
|
"VALUES (:id, :cid, :role, :content, 0, :c)"
|
||||||
|
), {
|
||||||
|
"id": str(_uuid.uuid4()), "cid": conv_id, "role": role,
|
||||||
|
"content": content, "c": created or datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _mirror_to_ai_chat(user_id: str, user_content: str, assistant_content: str):
|
||||||
|
"""把一轮微信问答写入用户的 WeXin Bot AI 聊天会话。"""
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
db = None
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
cid = _get_or_create_wechat_conv(db, user_id)
|
||||||
|
_append_ai_message(db, cid, "user", user_content)
|
||||||
|
_append_ai_message(db, cid, "assistant", assistant_content)
|
||||||
|
_execute_raw(db, text("UPDATE conversations SET updated_at=:u WHERE id=:id"),
|
||||||
|
{"u": datetime.now(), "id": cid})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if db:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 后台接收线程(每用户独立)
|
# 后台接收线程(每用户独立)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -326,6 +383,8 @@ def _wechat_receiver_loop(account_id: int):
|
|||||||
if owner_uid:
|
if owner_uid:
|
||||||
_log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content)
|
_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)
|
||||||
|
# 同步镜像到 AI 聊天的 WeXin Bot 会话窗
|
||||||
|
_mirror_to_ai_chat(owner_uid, content, reply)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f:
|
with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f:
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class Conversation(Base):
|
|||||||
pinned = Column(Boolean, default=False)
|
pinned = Column(Boolean, default=False)
|
||||||
knowledge_graph_count = Column(Integer, default=0)
|
knowledge_graph_count = Column(Integer, default=0)
|
||||||
knowledge_graph_base_id = Column(String(64), default=None, comment="已生成的知识图谱知识库ID")
|
knowledge_graph_base_id = Column(String(64), default=None, comment="已生成的知识图谱知识库ID")
|
||||||
|
source = Column(String(32), default=None, comment="对话来源,如 wechat_bot")
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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';
|
||||||
@@ -120,7 +119,6 @@ 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 */}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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: '对话记录' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user