From 7b7871a14da1795f1178e2818836d6bc75e6ac5c Mon Sep 17 00:00:00 2001 From: qyyfw Date: Fri, 10 Jul 2026 10:09:49 +0800 Subject: [PATCH] feat(im): mirror WeChat bot chats into AI chat as WeXin Bot conversation; add conversations.source; migrate history; remove redundant history page --- backend/python/app/api/chat.py | 160 ++- backend/python/app/api/im_bot_user.py | 1137 +++++++++-------- backend/python/app/models.py | 1 + frontend/src/App.tsx | 334 +++-- .../src/components/layout/FrontLayout.tsx | 367 +++--- 5 files changed, 1044 insertions(+), 955 deletions(-) diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py index 57934e7..f5510d7 100644 --- a/backend/python/app/api/chat.py +++ b/backend/python/app/api/chat.py @@ -691,6 +691,7 @@ def list_conversations( "status": conv.status, "pinned": conv.pinned, "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, "updated_at": str(conv.updated_at) if conv.updated_at else None, }) @@ -1140,69 +1141,8 @@ def clear_conversation( return {"code": 0, "message": "对话上下文已清空"} -@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="对话中没有消息, 无法生成知识图谱") - - 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() - - # 构建知识图谱节点和边 +def _build_knowledge_graph(db: Session, kb: KnowledgeBase) -> dict: + """从知识库文档构建知识图谱节点和边,返回 graph_data。""" documents = db.query(KnowledgeDocument).filter( and_( KnowledgeDocument.knowledge_base_id == kb.id, @@ -1268,7 +1208,96 @@ def convert_to_knowledge_graph( kb.knowledge_graph_count = len(graph_nodes) 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() db.commit() @@ -1285,6 +1314,8 @@ def convert_to_knowledge_graph( "knowledge_base_id": kb.id, "knowledge_base_name": kb.name, "graph": graph_data, + "knowledge_graph_count": 1, + "already_exists": False, }, "message": f"知识图谱构建完成", } @@ -1299,6 +1330,7 @@ def get_models(db: Session = Depends(get_db)): result.append({ "id": m.id, "name": m.name, + "display_name": getattr(m, "display_name", None) or m.name, "provider_id": m.provider_id, "provider_name": provider.name if provider else "", "status": m.status, diff --git a/backend/python/app/api/im_bot_user.py b/backend/python/app/api/im_bot_user.py index 94ca54d..2aa8434 100644 --- a/backend/python/app/api/im_bot_user.py +++ b/backend/python/app/api/im_bot_user.py @@ -1,539 +1,598 @@ -""" -Phase 4: 个人微信 (iLink 协议 / 微信 ClawBot 插件) —— 前台用户模块 -im_bot_user.py - -为【平台注册用户】提供"各自绑定自己的个人微信"能力: -- 每个用户独立一条 im_bot_wechat_account 记录(按 user_id 隔离) -- 扫码登录免 AppID;消息收发走 Bearer bot_token + X-WECHAT-UIN -- 绑定后由七虎AI 大脑自动应答(复用 channels 的真实 LLM 调用) - -与后台 im_bot_admin 的区别: -- 后台:平台级 IM Bot 接入中枢(飞书/企微/Telegram 等多平台、路由、统计),其个人微信 - 端点仅限 user_id IS NULL 的"全局 Bot",与用户绑定互不干扰。 -- 前台:每个登录用户管理自己的个人微信,数据严格按 user_id 隔离。 -""" -import base64 -import json -import random -import threading -import time -from datetime import datetime -from typing import Optional - -import requests -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import text -from sqlalchemy.orm import Session - -from ..auth import get_current_user -from ..database import SessionLocal -from ..models import User - -router = APIRouter() - -# ============================================================ -# iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID) -# ============================================================ -ILINK_BASE = "https://ilinkai.weixin.qq.com" - -# 每个已连接账号一个后台接收线程,key = 账号表主键 id -_wechat_receiver_threads: dict[int, threading.Thread] = {} - - -# ============================================================ -# 通用 DB 工具 -# ============================================================ -def _execute_raw(db: Session, sql_text, params=None): - result = db.execute(sql_text, params or {}) - if result.returns_rows: - return result.fetchall() - db.commit() - return result - - -def _row_to_dict(row) -> dict: - if row is None: - return None - d = {} - for col in row._mapping.keys(): - val = row._mapping[col] - if isinstance(val, datetime): - val = val.isoformat() - d[col] = val - return d - - -# ============================================================ -# iLink 协议请求工具 -# ============================================================ -def _wechat_headers(bot_token: str) -> dict: - """iLink 协议请求头:Bearer bot_token + 随机 X-WECHAT-UIN 防重放""" - uin = base64.b64encode(str(random.randint(0, 2 ** 32 - 1)).encode("utf-8")).decode("utf-8") - return { - "Content-Type": "application/json", - "Authorization": f"Bearer {bot_token}", - "AuthorizationType": "ilink_bot_token", - "X-WECHAT-UIN": uin, - } - - -def _wechat_row_for_user(db: Session, user_id: str): - rows = _execute_raw( - db, - text("SELECT * FROM im_bot_wechat_account WHERE user_id = :uid ORDER BY id DESC LIMIT 1"), - {"uid": user_id}, - ) - return rows[0] if rows else None - - -def _wechat_mask(row) -> dict: - d = _row_to_dict(row) - if d.get("bot_token"): - bt = d["bot_token"] - d["bot_token"] = ("***" + bt[-6:]) if len(bt) > 6 else "***" - d.pop("appid", None) - d.pop("secret_key", None) - return d - - -# ============================================================ -# 七虎AI 大脑(复用 channels 的真实 LLM 调用) -# ============================================================ -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, prompt, "个人微信") - except Exception as e: - return f"(AI 回复生成失败:{e})" - finally: - if db: - db.close() - - -# ============================================================ -# iLink 消息收发 -# ============================================================ -def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str): - """长轮询收消息,返回 (msgs, new_cursor)。最多 hold 35s。""" - try: - resp = requests.post( - baseurl.rstrip("/") + "/ilink/bot/getupdates", - headers=_wechat_headers(bot_token), - json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}}, - timeout=40, - ) - if not resp.ok: - return [], cursor - j = resp.json() if resp.content else {} - return j.get("msgs", []) or [], j.get("get_updates_buf", cursor) - except Exception: - return [], cursor - - -def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str): - """发送文本回复(必须带 context_token 关联到正确的对话窗口)。 - 返回 (ok, ret, body)。ret=-14=会话过期需重扫;ret=-2=token 失效。""" - import uuid as _uuid - payload = { - "msg": { - "from_user_id": "", - "to_user_id": to_user_id, - "client_id": "py-" + str(_uuid.uuid4()), - "message_type": 2, - "message_state": 2, - "context_token": context_token, - "item_list": [{"type": 1, "text_item": {"text": text}}], - }, - "base_info": {"channel_version": "1.0.2"}, - } - last_body = {} - for _ in range(2): - try: - resp = requests.post( - baseurl.rstrip("/") + "/ilink/bot/sendmessage", - headers=_wechat_headers(bot_token), - json=payload, - timeout=20, - ) - try: - body = resp.json() if resp.content else {} - except Exception: - body = {} - last_body = body - ret = body.get("ret") if isinstance(body, dict) else None - try: - with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: - _f.write(datetime.now().isoformat() + " SEND_HTTP=%s ret=%s body=%s\n" - % (resp.status_code, ret, json.dumps(body, ensure_ascii=False)[:300])) - except Exception: - pass - if resp.ok and (ret is None or ret == 0 or not body): - return True, ret, body - if ret in (-2, -14): - return False, ret, body # 鉴权/会话失效,立即停止重试 - except Exception: - pass - return False, (last_body.get("ret") if isinstance(last_body, dict) else None), last_body - - -def _wechat_extract_text(msg: dict) -> str: - parts = [] - 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() - - -# ============================================================ -# 后台接收线程(每用户独立) -# ============================================================ -def _mark_wechat_expired(account_id: int): - """发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。""" - db = None - try: - db = SessionLocal() - _execute_raw(db, text( - "UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, " - "updated_at=:ts WHERE id=:aid" - ), {"aid": account_id, "ts": datetime.now()}) - except Exception: - pass - finally: - if db: - db.close() - - -def _wechat_receiver_loop(account_id: int): - """长轮询个人微信消息 → 七虎AI 回复 → 发送。""" - cursor = "" - while True: - db = SessionLocal() - try: - row = _execute_raw( - db, - text("SELECT * FROM im_bot_wechat_account WHERE id = :aid"), - {"aid": account_id}, - ) - if not row: - return - d = _row_to_dict(row[0]) - if d.get("status") != "connected" or not d.get("bot_token"): - return - 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") == 2: - continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理 - content = _wechat_extract_text(m) - if not content: - continue - 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, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply) - if ok: - # 仅发送成功才写对话历史(避免“发了但没收到”的误导记录) - if owner_uid: - _log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content) - _log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply) - else: - try: - with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: - _f.write(datetime.now().isoformat() + " SEND_FAIL ret=%s sender=%s\n" - % (ret, repr(sender))) - except Exception: - pass - if ret in (-2, -14): - # 会话/鉴权失效:标记需重扫并停止接收线程 - _mark_wechat_expired(d["id"]) - return - time.sleep(1) - except Exception: - time.sleep(5) - finally: - db.close() - - -def _start_wechat_receiver(account_id: int): - if account_id in _wechat_receiver_threads and _wechat_receiver_threads[account_id].is_alive(): - return - t = threading.Thread(target=_wechat_receiver_loop, args=(account_id,), daemon=True) - t.start() - _wechat_receiver_threads[account_id] = t - - -# ============================================================ -# 前台用户接口(按 user_id 隔离) -# ============================================================ -@router.get("/status") -async def wechat_status(user: User = Depends(get_current_user)): - """当前登录用户的个人微信连接状态""" - db = SessionLocal() - try: - row = _wechat_row_for_user(db, user.id) - if not row: - return {"success": True, "data": {"connected": False, "configured": False}} - d = _row_to_dict(row) - return {"success": True, "data": { - "connected": d.get("status") == "connected", - "status": d.get("status"), - "configured": True, - "baseurl": d.get("baseurl"), - "ilink_bot_id": d.get("ilink_bot_id"), - "username": d.get("username"), - "connected_at": str(d.get("connected_at")) if d.get("connected_at") else None, - }} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - finally: - db.close() - - -@router.post("/qrcode") -async def wechat_qrcode(user: User = Depends(get_current_user)): - """获取个人微信扫码登录二维码(iLink 协议,免 AppID)""" - try: - resp = requests.get( - ILINK_BASE + "/ilink/bot/get_bot_qrcode", - params={"bot_type": 3}, - timeout=20, - ) - if not resp.ok: - return {"success": False, "error": f"获取二维码失败 HTTP {resp.status_code}"} - j = resp.json() if resp.content else {} - qr_url = j.get("qrcode_img_content") or "" - qr_img = None - try: - import io as _io - import qrcode as _qrcode - _buf = _io.BytesIO() - _q = _qrcode.QRCode(box_size=10, border=2) - _q.add_data(qr_url) - _q.make(fit=True) - _img = _q.make_image(fill_color="black", back_color="white") - _img.save(_buf, format="PNG") - qr_img = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode() - except Exception: - qr_img = None - return {"success": True, "data": { - "qrcode": j.get("qrcode"), - "qrcode_img_content": qr_url, - "qrcode_img": qr_img, - }} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/qrcode/status") -async def wechat_qrcode_status( - qrcode: str = Query(...), - user: User = Depends(get_current_user), -): - """轮询扫码状态;confirmed 时保存该用户的 bot_token 并启动接收线程""" - try: - resp = requests.get( - ILINK_BASE + "/ilink/bot/get_qrcode_status", - params={"qrcode": qrcode}, - timeout=40, - ) - if not resp.ok: - return {"success": False, "error": f"查询状态失败 HTTP {resp.status_code}"} - j = resp.json() if resp.content else {} - data = j.get("data", j) if isinstance(j, dict) else j - status = data.get("status") - if status == "confirmed": - creds = data.get("credentials", {}) or {} - bot_token = data.get("bot_token") or creds.get("bot_token") - baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE - ilink_bot_id = data.get("ilink_bot_id") or creds.get("ilink_bot_id") - db = SessionLocal() - try: - row = _wechat_row_for_user(db, user.id) - if not row: - _execute_raw(db, text("""INSERT INTO im_bot_wechat_account - (user_id, username, bot_token, ilink_bot_id, baseurl, status, - connected_at, created_at, updated_at) - VALUES (:user_id, :username, :bot_token, :ilink_bot_id, :baseurl, - 'connected', :connected_at, :created_at, :updated_at)"""), - { - "user_id": user.id, "username": user.username, - "bot_token": bot_token, "ilink_bot_id": ilink_bot_id, - "baseurl": baseurl, - "connected_at": datetime.now(), - "created_at": datetime.now(), "updated_at": datetime.now(), - }) - else: - _execute_raw(db, text("""UPDATE im_bot_wechat_account SET - user_id=:user_id, username=:username, bot_token=:bot_token, - ilink_bot_id=:ilink_bot_id, baseurl=:baseurl, - status='connected', connected_at=:connected_at, updated_at=:updated_at - WHERE id=:id"""), - { - "user_id": user.id, "username": user.username, - "bot_token": bot_token, "ilink_bot_id": ilink_bot_id, - "baseurl": baseurl, "connected_at": datetime.now(), - "updated_at": datetime.now(), "id": _row_to_dict(row)["id"], - }) - row = _wechat_row_for_user(db, user.id) - aid = _row_to_dict(row)["id"] if row else 1 - _start_wechat_receiver(aid) - finally: - db.close() - return {"success": True, "data": {"status": "confirmed", "connected": True}} - return {"success": True, "data": {"status": status, "connected": False}} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/disconnect") -async def wechat_disconnect(user: User = Depends(get_current_user)): - """断开当前用户的个人微信连接(清除 bot_token)""" - db = SessionLocal() - try: - row = _wechat_row_for_user(db, user.id) - if row: - _execute_raw(db, text( - "UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', " - "updated_at=:updated_at WHERE id=:id" - ), {"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]}) - return {"success": True, "message": "已断开个人微信连接"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - finally: - db.close() - - -# ============================================================ -# 启动恢复:为所有已连接用户账号拉起接收线程 -# ============================================================ -@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() - try: - rows = _execute_raw( - db, - text("SELECT id FROM im_bot_wechat_account WHERE status='connected' AND user_id IS NOT NULL"), - ) - for r in rows: - _start_wechat_receiver(r._mapping["id"]) - if rows: - import logging - logging.getLogger("aiflow").info(f"[IM个人微信] 已恢复 {len(rows)} 个用户微信接收线程") - except Exception as e: - import logging - logging.getLogger("aiflow").warning(f"[IM个人微信] 启动恢复失败(不影响服务): {e}") - finally: - db.close() +""" +Phase 4: 个人微信 (iLink 协议 / 微信 ClawBot 插件) —— 前台用户模块 +im_bot_user.py + +为【平台注册用户】提供"各自绑定自己的个人微信"能力: +- 每个用户独立一条 im_bot_wechat_account 记录(按 user_id 隔离) +- 扫码登录免 AppID;消息收发走 Bearer bot_token + X-WECHAT-UIN +- 绑定后由七虎AI 大脑自动应答(复用 channels 的真实 LLM 调用) + +与后台 im_bot_admin 的区别: +- 后台:平台级 IM Bot 接入中枢(飞书/企微/Telegram 等多平台、路由、统计),其个人微信 + 端点仅限 user_id IS NULL 的"全局 Bot",与用户绑定互不干扰。 +- 前台:每个登录用户管理自己的个人微信,数据严格按 user_id 隔离。 +""" +import base64 +import json +import random +import threading +import time +from datetime import datetime +from typing import Optional + +import requests +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import text +from sqlalchemy.orm import Session + +from ..auth import get_current_user +from ..database import SessionLocal +from ..models import User + +router = APIRouter() + +# ============================================================ +# iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID) +# ============================================================ +ILINK_BASE = "https://ilinkai.weixin.qq.com" + +# 每个已连接账号一个后台接收线程,key = 账号表主键 id +_wechat_receiver_threads: dict[int, threading.Thread] = {} + + +# ============================================================ +# 通用 DB 工具 +# ============================================================ +def _execute_raw(db: Session, sql_text, params=None): + result = db.execute(sql_text, params or {}) + if result.returns_rows: + return result.fetchall() + db.commit() + return result + + +def _row_to_dict(row) -> dict: + if row is None: + return None + d = {} + for col in row._mapping.keys(): + val = row._mapping[col] + if isinstance(val, datetime): + val = val.isoformat() + d[col] = val + return d + + +# ============================================================ +# iLink 协议请求工具 +# ============================================================ +def _wechat_headers(bot_token: str) -> dict: + """iLink 协议请求头:Bearer bot_token + 随机 X-WECHAT-UIN 防重放""" + uin = base64.b64encode(str(random.randint(0, 2 ** 32 - 1)).encode("utf-8")).decode("utf-8") + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {bot_token}", + "AuthorizationType": "ilink_bot_token", + "X-WECHAT-UIN": uin, + } + + +def _wechat_row_for_user(db: Session, user_id: str): + rows = _execute_raw( + db, + text("SELECT * FROM im_bot_wechat_account WHERE user_id = :uid ORDER BY id DESC LIMIT 1"), + {"uid": user_id}, + ) + return rows[0] if rows else None + + +def _wechat_mask(row) -> dict: + d = _row_to_dict(row) + if d.get("bot_token"): + bt = d["bot_token"] + d["bot_token"] = ("***" + bt[-6:]) if len(bt) > 6 else "***" + d.pop("appid", None) + d.pop("secret_key", None) + return d + + +# ============================================================ +# 七虎AI 大脑(复用 channels 的真实 LLM 调用) +# ============================================================ +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, prompt, "个人微信") + except Exception as e: + return f"(AI 回复生成失败:{e})" + finally: + if db: + db.close() + + +# ============================================================ +# iLink 消息收发 +# ============================================================ +def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str): + """长轮询收消息,返回 (msgs, new_cursor)。最多 hold 35s。""" + try: + resp = requests.post( + baseurl.rstrip("/") + "/ilink/bot/getupdates", + headers=_wechat_headers(bot_token), + json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}}, + timeout=40, + ) + if not resp.ok: + return [], cursor + j = resp.json() if resp.content else {} + return j.get("msgs", []) or [], j.get("get_updates_buf", cursor) + except Exception: + return [], cursor + + +def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str): + """发送文本回复(必须带 context_token 关联到正确的对话窗口)。 + 返回 (ok, ret, body)。ret=-14=会话过期需重扫;ret=-2=token 失效。""" + import uuid as _uuid + payload = { + "msg": { + "from_user_id": "", + "to_user_id": to_user_id, + "client_id": "py-" + str(_uuid.uuid4()), + "message_type": 2, + "message_state": 2, + "context_token": context_token, + "item_list": [{"type": 1, "text_item": {"text": text}}], + }, + "base_info": {"channel_version": "1.0.2"}, + } + last_body = {} + for _ in range(2): + try: + resp = requests.post( + baseurl.rstrip("/") + "/ilink/bot/sendmessage", + headers=_wechat_headers(bot_token), + json=payload, + timeout=20, + ) + try: + body = resp.json() if resp.content else {} + except Exception: + body = {} + last_body = body + ret = body.get("ret") if isinstance(body, dict) else None + try: + with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: + _f.write(datetime.now().isoformat() + " SEND_HTTP=%s ret=%s body=%s\n" + % (resp.status_code, ret, json.dumps(body, ensure_ascii=False)[:300])) + except Exception: + pass + if resp.ok and (ret is None or ret == 0 or not body): + return True, ret, body + if ret in (-2, -14): + return False, ret, body # 鉴权/会话失效,立即停止重试 + except Exception: + pass + return False, (last_body.get("ret") if isinstance(last_body, dict) else None), last_body + + +def _wechat_extract_text(msg: dict) -> str: + parts = [] + 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() + + +# ============================================================ +# 镜像到 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() + + +# ============================================================ +# 后台接收线程(每用户独立) +# ============================================================ +def _mark_wechat_expired(account_id: int): + """发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。""" + db = None + try: + db = SessionLocal() + _execute_raw(db, text( + "UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, " + "updated_at=:ts WHERE id=:aid" + ), {"aid": account_id, "ts": datetime.now()}) + except Exception: + pass + finally: + if db: + db.close() + + +def _wechat_receiver_loop(account_id: int): + """长轮询个人微信消息 → 七虎AI 回复 → 发送。""" + cursor = "" + while True: + db = SessionLocal() + try: + row = _execute_raw( + db, + text("SELECT * FROM im_bot_wechat_account WHERE id = :aid"), + {"aid": account_id}, + ) + if not row: + return + d = _row_to_dict(row[0]) + if d.get("status") != "connected" or not d.get("bot_token"): + return + 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") == 2: + continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理 + content = _wechat_extract_text(m) + if not content: + continue + 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, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply) + if ok: + # 仅发送成功才写对话历史(避免“发了但没收到”的误导记录) + if owner_uid: + _log_chat(owner_uid, "personal_wechat", sender, ctx, "user", content) + _log_chat(owner_uid, "personal_wechat", sender, ctx, "assistant", reply) + # 同步镜像到 AI 聊天的 WeXin Bot 会话窗 + _mirror_to_ai_chat(owner_uid, content, reply) + else: + try: + with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f: + _f.write(datetime.now().isoformat() + " SEND_FAIL ret=%s sender=%s\n" + % (ret, repr(sender))) + except Exception: + pass + if ret in (-2, -14): + # 会话/鉴权失效:标记需重扫并停止接收线程 + _mark_wechat_expired(d["id"]) + return + time.sleep(1) + except Exception: + time.sleep(5) + finally: + db.close() + + +def _start_wechat_receiver(account_id: int): + if account_id in _wechat_receiver_threads and _wechat_receiver_threads[account_id].is_alive(): + return + t = threading.Thread(target=_wechat_receiver_loop, args=(account_id,), daemon=True) + t.start() + _wechat_receiver_threads[account_id] = t + + +# ============================================================ +# 前台用户接口(按 user_id 隔离) +# ============================================================ +@router.get("/status") +async def wechat_status(user: User = Depends(get_current_user)): + """当前登录用户的个人微信连接状态""" + db = SessionLocal() + try: + row = _wechat_row_for_user(db, user.id) + if not row: + return {"success": True, "data": {"connected": False, "configured": False}} + d = _row_to_dict(row) + return {"success": True, "data": { + "connected": d.get("status") == "connected", + "status": d.get("status"), + "configured": True, + "baseurl": d.get("baseurl"), + "ilink_bot_id": d.get("ilink_bot_id"), + "username": d.get("username"), + "connected_at": str(d.get("connected_at")) if d.get("connected_at") else None, + }} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.post("/qrcode") +async def wechat_qrcode(user: User = Depends(get_current_user)): + """获取个人微信扫码登录二维码(iLink 协议,免 AppID)""" + try: + resp = requests.get( + ILINK_BASE + "/ilink/bot/get_bot_qrcode", + params={"bot_type": 3}, + timeout=20, + ) + if not resp.ok: + return {"success": False, "error": f"获取二维码失败 HTTP {resp.status_code}"} + j = resp.json() if resp.content else {} + qr_url = j.get("qrcode_img_content") or "" + qr_img = None + try: + import io as _io + import qrcode as _qrcode + _buf = _io.BytesIO() + _q = _qrcode.QRCode(box_size=10, border=2) + _q.add_data(qr_url) + _q.make(fit=True) + _img = _q.make_image(fill_color="black", back_color="white") + _img.save(_buf, format="PNG") + qr_img = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode() + except Exception: + qr_img = None + return {"success": True, "data": { + "qrcode": j.get("qrcode"), + "qrcode_img_content": qr_url, + "qrcode_img": qr_img, + }} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/qrcode/status") +async def wechat_qrcode_status( + qrcode: str = Query(...), + user: User = Depends(get_current_user), +): + """轮询扫码状态;confirmed 时保存该用户的 bot_token 并启动接收线程""" + try: + resp = requests.get( + ILINK_BASE + "/ilink/bot/get_qrcode_status", + params={"qrcode": qrcode}, + timeout=40, + ) + if not resp.ok: + return {"success": False, "error": f"查询状态失败 HTTP {resp.status_code}"} + j = resp.json() if resp.content else {} + data = j.get("data", j) if isinstance(j, dict) else j + status = data.get("status") + if status == "confirmed": + creds = data.get("credentials", {}) or {} + bot_token = data.get("bot_token") or creds.get("bot_token") + baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE + ilink_bot_id = data.get("ilink_bot_id") or creds.get("ilink_bot_id") + db = SessionLocal() + try: + row = _wechat_row_for_user(db, user.id) + if not row: + _execute_raw(db, text("""INSERT INTO im_bot_wechat_account + (user_id, username, bot_token, ilink_bot_id, baseurl, status, + connected_at, created_at, updated_at) + VALUES (:user_id, :username, :bot_token, :ilink_bot_id, :baseurl, + 'connected', :connected_at, :created_at, :updated_at)"""), + { + "user_id": user.id, "username": user.username, + "bot_token": bot_token, "ilink_bot_id": ilink_bot_id, + "baseurl": baseurl, + "connected_at": datetime.now(), + "created_at": datetime.now(), "updated_at": datetime.now(), + }) + else: + _execute_raw(db, text("""UPDATE im_bot_wechat_account SET + user_id=:user_id, username=:username, bot_token=:bot_token, + ilink_bot_id=:ilink_bot_id, baseurl=:baseurl, + status='connected', connected_at=:connected_at, updated_at=:updated_at + WHERE id=:id"""), + { + "user_id": user.id, "username": user.username, + "bot_token": bot_token, "ilink_bot_id": ilink_bot_id, + "baseurl": baseurl, "connected_at": datetime.now(), + "updated_at": datetime.now(), "id": _row_to_dict(row)["id"], + }) + row = _wechat_row_for_user(db, user.id) + aid = _row_to_dict(row)["id"] if row else 1 + _start_wechat_receiver(aid) + finally: + db.close() + return {"success": True, "data": {"status": "confirmed", "connected": True}} + return {"success": True, "data": {"status": status, "connected": False}} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/disconnect") +async def wechat_disconnect(user: User = Depends(get_current_user)): + """断开当前用户的个人微信连接(清除 bot_token)""" + db = SessionLocal() + try: + row = _wechat_row_for_user(db, user.id) + if row: + _execute_raw(db, text( + "UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', " + "updated_at=:updated_at WHERE id=:id" + ), {"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]}) + return {"success": True, "message": "已断开个人微信连接"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# ============================================================ +# 启动恢复:为所有已连接用户账号拉起接收线程 +# ============================================================ +@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() + try: + rows = _execute_raw( + db, + text("SELECT id FROM im_bot_wechat_account WHERE status='connected' AND user_id IS NOT NULL"), + ) + for r in rows: + _start_wechat_receiver(r._mapping["id"]) + if rows: + import logging + logging.getLogger("aiflow").info(f"[IM个人微信] 已恢复 {len(rows)} 个用户微信接收线程") + except Exception as e: + import logging + logging.getLogger("aiflow").warning(f"[IM个人微信] 启动恢复失败(不影响服务): {e}") + finally: + db.close() diff --git a/backend/python/app/models.py b/backend/python/app/models.py index 5234101..defbbc7 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -85,6 +85,7 @@ class Conversation(Base): pinned = Column(Boolean, default=False) knowledge_graph_count = Column(Integer, default=0) 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) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d617651..d3b69fa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,168 +1,166 @@ -import React, { useState, useEffect } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; -import type { User } from '@/types'; -import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; -import { authAPI } from '@/api'; - -// Layouts -import FrontLayout from '@/components/layout/FrontLayout'; -import AdminLayout from '@/components/layout/AdminLayout'; - -// Front Pages -import LandingPage from '@/pages/front/LandingPage'; -import LoginPage from '@/pages/front/LoginPage'; -import ChatPage from '@/pages/front/ChatPage'; -import ChannelPage from '@/pages/front/ChannelPage'; -import KnowledgePage from '@/pages/front/KnowledgePage'; -import MarketPage from '@/pages/front/MarketPage'; -import TokenDashboardPage from '@/pages/front/TokenDashboardPage'; -import PricingPage from '@/pages/front/PricingPage'; -import ProfilePage from '@/pages/front/ProfilePage'; -import NotificationPage from '@/pages/front/NotificationPage'; -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'; -import AdminUsers from '@/pages/admin/Users'; -import AdminModels from '@/pages/admin/Models'; -import AdminSiteSettings from '@/pages/admin/SiteSettings'; -import AdminPayment from '@/pages/admin/Payment'; -import AdminServices from '@/pages/admin/Services'; -import AdminMembership from '@/pages/admin/Membership'; -import AdminAds from '@/pages/admin/Ads'; -import AdminMarket from '@/pages/admin/Market'; -import AdminLogs from '@/pages/admin/Logs'; -import AdminSkills from '@/pages/admin/Skills' -import AdminAgents from '@/pages/admin/Agents'; -import AdminCodeExec from '@/pages/admin/CodeExec';; -import DistributionBuilder from '@/pages/admin/DistributionBuilder'; -import PrivacySettings from '@/pages/admin/PrivacySettings'; -import ImBots from '@/pages/admin/ImBots'; -import Promotion from '@/pages/admin/Promotion'; -import Achievement from '@/pages/admin/Achievement'; -import Tenant from '@/pages/admin/Tenant'; -import Billing from '@/pages/admin/Billing'; -import Analytics from '@/pages/admin/Analytics'; -import UserPortal from '@/pages/UserPortal'; -import AdminKnowledge from '@/pages/admin/Knowledge'; -import AdminWorkflow from '@/pages/admin/Workflow'; -import AdminStorage from '@/pages/admin/Storage'; -import AdminDeveloper from '@/pages/admin/Developer'; -import AdminDeveloperTools from '@/pages/admin/DeveloperTools'; -import AdminAutomation from '@/pages/admin/Automation'; -import AdminMcp from '@/pages/admin/Mcp'; -import AdminApps from '@/pages/admin/Apps'; -import AssetCenter from '@/pages/admin/AssetCenter'; - -const App: React.FC = () => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - // 初始化:从localStorage恢复登录状态 - useEffect(() => { - const token = localStorage.getItem('aiflow_token'); - const cachedUser = localStorage.getItem('aiflow_user'); - if (token && cachedUser) { - try { - setUser(JSON.parse(cachedUser)); - } catch { - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - } - } - setLoading(false); - }, []); - - const login = (u: User) => { - setUser(u); - localStorage.setItem('aiflow_user', JSON.stringify(u)); - }; - const logout = () => { - setUser(null); - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - }; - const isAdmin = user?.role === 'admin'; - - if (loading) { - return
加载中...
; - } - - return ( - - - {/* Login */} - : ) : - } /> - - {/* Root - Landing for guests, redirect for authenticated users */} - : ) : - } /> - - {/* Front Routes (authenticated only) */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Admin Routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - } /> - - - ); -}; - -export default App; +import React, { useState, useEffect } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import type { User } from '@/types'; +import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; +import { authAPI } from '@/api'; + +// Layouts +import FrontLayout from '@/components/layout/FrontLayout'; +import AdminLayout from '@/components/layout/AdminLayout'; + +// Front Pages +import LandingPage from '@/pages/front/LandingPage'; +import LoginPage from '@/pages/front/LoginPage'; +import ChatPage from '@/pages/front/ChatPage'; +import ChannelPage from '@/pages/front/ChannelPage'; +import KnowledgePage from '@/pages/front/KnowledgePage'; +import MarketPage from '@/pages/front/MarketPage'; +import TokenDashboardPage from '@/pages/front/TokenDashboardPage'; +import PricingPage from '@/pages/front/PricingPage'; +import ProfilePage from '@/pages/front/ProfilePage'; +import NotificationPage from '@/pages/front/NotificationPage'; +import AssetsPage from '@/pages/front/AssetsPage'; +import NovelPreviewPage from '@/pages/front/NovelPreviewPage'; +import EvolutionDashboard from '@/pages/front/EvolutionDashboard'; +import WechatBot from '@/pages/front/WechatBot'; + +// Admin Pages +import AdminDashboard from '@/pages/admin/Dashboard'; +import AdminUsers from '@/pages/admin/Users'; +import AdminModels from '@/pages/admin/Models'; +import AdminSiteSettings from '@/pages/admin/SiteSettings'; +import AdminPayment from '@/pages/admin/Payment'; +import AdminServices from '@/pages/admin/Services'; +import AdminMembership from '@/pages/admin/Membership'; +import AdminAds from '@/pages/admin/Ads'; +import AdminMarket from '@/pages/admin/Market'; +import AdminLogs from '@/pages/admin/Logs'; +import AdminSkills from '@/pages/admin/Skills' +import AdminAgents from '@/pages/admin/Agents'; +import AdminCodeExec from '@/pages/admin/CodeExec';; +import DistributionBuilder from '@/pages/admin/DistributionBuilder'; +import PrivacySettings from '@/pages/admin/PrivacySettings'; +import ImBots from '@/pages/admin/ImBots'; +import Promotion from '@/pages/admin/Promotion'; +import Achievement from '@/pages/admin/Achievement'; +import Tenant from '@/pages/admin/Tenant'; +import Billing from '@/pages/admin/Billing'; +import Analytics from '@/pages/admin/Analytics'; +import UserPortal from '@/pages/UserPortal'; +import AdminKnowledge from '@/pages/admin/Knowledge'; +import AdminWorkflow from '@/pages/admin/Workflow'; +import AdminStorage from '@/pages/admin/Storage'; +import AdminDeveloper from '@/pages/admin/Developer'; +import AdminDeveloperTools from '@/pages/admin/DeveloperTools'; +import AdminAutomation from '@/pages/admin/Automation'; +import AdminMcp from '@/pages/admin/Mcp'; +import AdminApps from '@/pages/admin/Apps'; +import AssetCenter from '@/pages/admin/AssetCenter'; + +const App: React.FC = () => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // 初始化:从localStorage恢复登录状态 + useEffect(() => { + const token = localStorage.getItem('aiflow_token'); + const cachedUser = localStorage.getItem('aiflow_user'); + if (token && cachedUser) { + try { + setUser(JSON.parse(cachedUser)); + } catch { + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + } + } + setLoading(false); + }, []); + + const login = (u: User) => { + setUser(u); + localStorage.setItem('aiflow_user', JSON.stringify(u)); + }; + const logout = () => { + setUser(null); + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + }; + const isAdmin = user?.role === 'admin'; + + if (loading) { + return
加载中...
; + } + + return ( + + + {/* Login */} + : ) : + } /> + + {/* Root - Landing for guests, redirect for authenticated users */} + : ) : + } /> + + {/* Front Routes (authenticated only) */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Admin Routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + ); +}; + +export default App; diff --git a/frontend/src/components/layout/FrontLayout.tsx b/frontend/src/components/layout/FrontLayout.tsx index 54f5da2..e3afe47 100644 --- a/frontend/src/components/layout/FrontLayout.tsx +++ b/frontend/src/components/layout/FrontLayout.tsx @@ -1,184 +1,183 @@ -import React, { useState } from 'react'; -import { Outlet, NavLink, useNavigate } from 'react-router-dom'; -import { useAuth } from '@/contexts/AuthContext'; -import './FrontLayout.css'; - -const navGroups = [ - { - title: '智能体', - items: [ - { path: '/chat', icon: 'chat', label: 'AI聊天' }, - { path: '/knowledge', icon: 'knowledge', label: '知识库' }, - { path: '/market', icon: 'market', label: '技能市场' }, - { path: '/evolution', icon: 'evolution', label: 'AI自进化' }, - ], - }, - { - title: 'IM Bot', - items: [ - { path: '/channels', icon: 'channel', label: '频道管理' }, - { path: '/wechat-bot', icon: 'wechat', label: '微信机器人' }, - { path: '/wechat-history', icon: 'chat', label: '对话记录' }, - ], - }, - { - title: '资源中心', - items: [ - { path: '/assets', icon: 'assets', label: '资产文件' }, - { path: '/token', icon: 'token', label: 'Token驾驶舱' }, - { path: '/pricing', icon: 'pricing', label: '付费中心' }, - ], - }, -]; - -const bottomItems = [ - { path: '/profile', icon: 'profile', label: '个人中心' }, - { path: '/notifications', icon: 'notify', label: '消息通知' }, -]; - -const iconPaths: Record = { - chat: , - channel: , - knowledge: , - market: , - apps: , - token: , - pricing: , - profile: , - notify: , - assets: , - evolution: , - wechat: , -}; - -const FrontLayout: React.FC = () => { - const { user, logout } = useAuth(); - const navigate = useNavigate(); - const [collapsed, setCollapsed] = useState(false); - - if (!user) { - navigate('/login'); - return null; - } - - return ( -
- {/* Sidebar */} - - - {/* Main Content */} -
- -
-
- ); -}; - -export default FrontLayout; +import React, { useState } from 'react'; +import { Outlet, NavLink, useNavigate } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import './FrontLayout.css'; + +const navGroups = [ + { + title: '智能体', + items: [ + { path: '/chat', icon: 'chat', label: 'AI聊天' }, + { path: '/knowledge', icon: 'knowledge', label: '知识库' }, + { path: '/market', icon: 'market', label: '技能市场' }, + { path: '/evolution', icon: 'evolution', label: 'AI自进化' }, + ], + }, + { + title: 'IM Bot', + items: [ + { path: '/channels', icon: 'channel', label: '频道管理' }, + { path: '/wechat-bot', icon: 'wechat', label: '微信机器人' }, + ], + }, + { + title: '资源中心', + items: [ + { path: '/assets', icon: 'assets', label: '资产文件' }, + { path: '/token', icon: 'token', label: 'Token驾驶舱' }, + { path: '/pricing', icon: 'pricing', label: '付费中心' }, + ], + }, +]; + +const bottomItems = [ + { path: '/profile', icon: 'profile', label: '个人中心' }, + { path: '/notifications', icon: 'notify', label: '消息通知' }, +]; + +const iconPaths: Record = { + chat: , + channel: , + knowledge: , + market: , + apps: , + token: , + pricing: , + profile: , + notify: , + assets: , + evolution: , + wechat: , +}; + +const FrontLayout: React.FC = () => { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const [collapsed, setCollapsed] = useState(false); + + if (!user) { + navigate('/login'); + return null; + } + + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ +
+
+ ); +}; + +export default FrontLayout;