From 809a65056dae09e2fcc8eeb0f241ffabcd94b439 Mon Sep 17 00:00:00 2001 From: ChenKaiLiuG Date: Sun, 1 Feb 2026 01:24:56 +0800 Subject: [PATCH] Change platform to rocket.chat --- .env.example | 8 +- INTRO.md | 112 +++++++- SETUP_GUIDE.md | 331 ++++++++++++++++++++++ api/server.py | 666 +++++++++++++++++++++++++++++++-------------- docker-compose.yml | 70 ++++- exchange.md | 112 ++++++++ 6 files changed, 1069 insertions(+), 230 deletions(-) create mode 100644 SETUP_GUIDE.md create mode 100644 exchange.md diff --git a/.env.example b/.env.example index 3cdbc35..b46c241 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,6 @@ # PostgreSQL 密碼 DB_PASSWORD=your_secure_password_here -# Papercups Secret Key (使用以下命令生成: openssl rand -hex 64) -PAPERCUPS_SECRET_KEY=your_papercups_secret_key_here - -# Papercups API Token (從 Papercups Settings 取得) -PAPERCUPS_API_TOKEN=your_papercups_api_token_here +# Rocket.Chat 管理員帳號(首次啟動時設定) +ROCKETCHAT_USER=admin +ROCKETCHAT_PASSWORD=your_rocketchat_password_here diff --git a/INTRO.md b/INTRO.md index 2298244..23d4c31 100644 --- a/INTRO.md +++ b/INTRO.md @@ -65,4 +65,114 @@ Open WebUI 採用**雙層結構**來組織對話: - ✅ 多層結構 - 支援「用戶 → 對話 → 訊息」的層級關係 - ✅ 狀態管理 - 清楚標示哪些對話待回覆 -## 架構 \ No newline at end of file +## Rocket.Chat 整合架構 + +### 系統架構圖 + +``` +┌─────────────┐ +│ Open WebUI │ +│ 用戶介面 │ +└──────┬──────┘ + │ 1. Chat Request + │ Headers: X-OpenWebUI-User-Id, X-OpenWebUI-Chat-Id + │ X-OpenWebUI-User-Name + ▼ +┌─────────────────────────────────────────────────────────┐ +│ API (FastAPI - server.py) │ +├─────────────────────────────────────────────────────────┤ +│ 2. 解析 Headers 提取 user_id, chat_id, user_name │ +│ 3. 登入 Rocket.Chat 取得認證 Token │ +│ 4. 創建/取得用戶頻道 (#user-{user_id}) │ +│ 5. 在頻道中創建執行緒訊息 │ +│ 6. 輪詢等待管理員在執行緒中回覆 │ +│ 7. 將回覆返回給 Open WebUI │ +└──────┬──────────────────────────┬───────────────────────┘ + │ │ + ▼ ▼ +┌─────────────┐ ┌─────────────────────────┐ +│ PostgreSQL │ │ Rocket.Chat + MongoDB │ +│ reply_queue │ ├─────────────────────────┤ +└─────────────┘ │ 頻道: #user-abc123 │ + │ └── 🧵 chat-001 │ + │ ├── 用戶訊息 │ + │ └── 管理員回覆 │ + └─────────────────────────┘ + ▲ + │ 8. 管理員回覆 + │ + ┌─────┴──────┐ + │ 管理員瀏覽器│ + └────────────┘ +``` + +### 資料映射關係 + +| Open WebUI | Rocket.Chat | 說明 | +|-----------|-------------|------| +| 用戶 (user_id) | 頻道 (Channel) | 每個用戶一個專屬頻道 `#user-{id}` | +| 對話 (chat_id) | 執行緒 (Thread) | 同一對話的多輪訊息在同一執行緒 | +| 訊息 (message) | 訊息 (Message) | 用戶和管理員的對話內容 | + +### Rocket.Chat 呈現效果 + +``` +Rocket.Chat 介面: +├── 📂 頻道列表 +│ ├── #user-abc123 (張三) +│ │ └── 描述: 用戶: 張三 (ID: abc123...) +│ ├── #user-def456 (李四) +│ │ └── 描述: 用戶: 李四 (ID: def456...) +│ └── #user-xyz789 (王五) +│ +└── 📂 #user-abc123 (張三) 的內容 + ├── 🧵 [對話 chat-001] "如何學習 Python?" + │ ├── 💬 張三: "如何學習 Python?" + │ └── 💬 管理員: "推薦從基礎開始..." + │ + ├── 🧵 [對話 chat-002] "推薦餐廳" + │ ├── 💬 張三: "台北有什麼好吃的?" + │ └── 💬 管理員: "推薦鼎泰豐..." + │ + └── 🧵 [對話 chat-003] ⚠️ 待回覆 + └── 💬 張三: "旅遊攻略" +``` + +### 資料庫結構 + +`reply_queue` 表格結構: + +```sql +CREATE TABLE reply_queue ( + id SERIAL PRIMARY KEY, + conversation_id VARCHAR(50) UNIQUE NOT NULL, + user_id VARCHAR(255), + chat_id VARCHAR(255), + user_name VARCHAR(255), + user_message TEXT NOT NULL, + admin_reply TEXT, + status VARCHAR(20) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT NOW(), + replied_at TIMESTAMP, + rocketchat_room_id VARCHAR(100), + rocketchat_thread_id VARCHAR(100) +); +``` + +### 服務列表 + +| 服務 | 容器名 | Port | 說明 | +|------|-------|------|------| +| **PostgreSQL** | tobiichiGPT-postgres | 5432 | 資料庫(記錄追蹤) | +| **MongoDB** | tobiichiGPT-mongo | 27017 | Rocket.Chat 資料庫 | +| **API** | tobiichiGPT-api | 18000 | OpenAI API 相容端點 | +| **Open WebUI** | tobiichiGPT-ui | 10060 | 用戶對話介面 | +| **Rocket.Chat** | tobiichiGPT-rocketchat | 13000 | 管理員對話介面 | + +### 技術特點 + +1. **雙層映射** - 完美對應 Open WebUI 的用戶+對話結構 +2. **執行緒隔離** - 每個對話獨立,不會混雜 +3. **即時通知** - Rocket.Chat 支援桌面和手機推送 +4. **輕量部署** - 共 5 個容器(含初始化容器) +5. **可擴展性** - 支援多管理員協作回覆 diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md new file mode 100644 index 0000000..03ebd54 --- /dev/null +++ b/SETUP_GUIDE.md @@ -0,0 +1,331 @@ +# TobiichiGPT - Rocket.Chat 整合指南 + +## 🚀 快速開始 + +### 1. 準備環境檔案 + +```powershell +# 複製環境變數範例 +Copy-Item .env.example .env + +# 編輯 .env +notepad .env +``` + +設定以下變數: +```env +DB_PASSWORD=your_secure_password_here +ROCKETCHAT_USER=admin +ROCKETCHAT_PASSWORD=your_secure_password_here +``` + +### 2. 啟動服務 + +```powershell +# 啟動所有服務 +docker-compose up -d + +# 查看啟動狀態 +docker-compose ps + +# 查看日誌 +docker-compose logs -f +``` + +**啟動順序**: +1. PostgreSQL (資料庫) +2. MongoDB (Rocket.Chat 資料庫) +3. MongoDB Replica Set 初始化 +4. Rocket.Chat (管理員介面) +5. API (轉接層) +6. Open WebUI (用戶介面) + +### 3. 初始化 Rocket.Chat + +訪問 http://localhost:13000 + +#### 首次設定步驟: + +1. **創建管理員帳號** + - Username: `admin` (必須與 .env 中的 ROCKETCHAT_USER 一致) + - Password: 設定密碼 (必須與 .env 中的 ROCKETCHAT_PASSWORD 一致) + - Email: 填寫郵箱 + +2. **基本設定** + - Organization Name: TobiichiGPT + - Language: 繁體中文(可選) + - Server Type: Private + +3. **關閉註冊(可選)** + - 進入 Administration → Settings → Accounts + - 關閉 "Allow User Registration" + +### 4. 配置 Open WebUI + +1. 訪問 http://localhost:10060 +2. 首次訪問會要求創建管理員帳號 +3. 登入後,進入 **Settings** → **Connections** +4. 新增 OpenAI Connection: + - **API Base URL**: `http://tobiichiGPT-api:8000/v1` + - **API Key**: `sk-anything` (任意值即可) +5. 點選 **Save** +6. 模型列表會出現 **tobiichiGPT** + +## 💬 使用流程 + +### 用戶端(Open WebUI) + +1. 在 Open WebUI 中選擇 **tobiichiGPT** 模型 +2. 開始對話 +3. 訊息會被轉發到 Rocket.Chat +4. 等待管理員回覆(畫面會顯示 "Thinking...") + +### 管理員端(Rocket.Chat) + +1. 登入 Rocket.Chat: http://localhost:13000 +2. 左側會看到新增的用戶頻道,格式為 `#user-{user_id}` +3. 點開頻道,會看到執行緒列表(每個對話一個執行緒) +4. 點開執行緒,查看用戶訊息 +5. **在執行緒中回覆**(重要!必須在執行緒內回覆) +6. 回覆後,用戶端會在 3 秒內收到回覆 + +### 回覆方式說明 + +**正確回覆方式**: +1. 點擊訊息右側的 **Reply in Thread** 圖標 +2. 在執行緒視窗中輸入回覆 +3. 按 Enter 送出 + +**錯誤回覆方式**: +- ❌ 直接在頻道主畫面回覆(不會被系統偵測到) +- ❌ 私訊用戶(系統無法接收) + +## 📊 系統監控 + +### 健康檢查 + +訪問 http://localhost:18000/health + +正常回應: +```json +{ + "status": "healthy", + "database": "ok", + "rocketchat": "ok" +} +``` + +### 查看日誌 + +```powershell +# API 日誌 +docker-compose logs -f api + +# Rocket.Chat 日誌 +docker-compose logs -f rocketchat + +# Open WebUI 日誌 +docker-compose logs -f openwebui +``` + +### 常見日誌訊息 + +**API 正常運作**: +``` +✅ 資料庫連接成功 +✅ Rocket.Chat 登入成功 +📝 收到訊息 + 用戶: 張三 + 對話: chat-abc + 內容: 如何學習 Python?... +✅ 創建頻道: user-abc123 (張三) +✅ 創建執行緒訊息: xyz789 +✅ 收到管理員回覆 +✅ 完成回覆 [chat:chat-abc] +``` + +**錯誤訊息**: +``` +⚠️ Rocket.Chat 登入失敗: 401 +❌ Rocket.Chat 頻道操作錯誤: ... +⚠️ 等待回覆超時 (600秒) +``` + +## 🔧 故障排除 + +### 問題 1: Rocket.Chat 無法啟動 + +**症狀**:容器一直重啟 + +**解決方案**: +```powershell +# 檢查 MongoDB 狀態 +docker-compose logs mongo + +# 重新初始化 Replica Set +docker-compose restart mongo-init-replica +``` + +### 問題 2: API 無法登入 Rocket.Chat + +**症狀**:API 日誌顯示 "Rocket.Chat 登入失敗" + +**解決方案**: +1. 確認 Rocket.Chat 已完成初始化 +2. 確認 .env 中的 ROCKETCHAT_USER 和 ROCKETCHAT_PASSWORD 正確 +3. 重啟 API: + ```powershell + docker-compose restart api + ``` + +### 問題 3: 管理員回覆後用戶沒收到 + +**可能原因**: +1. ❌ 沒有在執行緒中回覆(在頻道主畫面回覆) +2. ❌ 回覆的訊息為空 + +**解決方案**: +1. 確認在執行緒視窗中回覆 +2. 確認回覆內容不為空 +3. 檢查 API 日誌是否有 "收到管理員回覆" + +### 問題 4: 等待超時 + +**症狀**:用戶收到 "抱歉,目前客服繁忙,請稍後再試。" + +**原因**:管理員在 10 分鐘內未回覆 + +**解決方案**: +- 修改 `api/server.py` 第 timeout 參數(預設 600 秒) +- 或者加快回覆速度 + +### 問題 5: Open WebUI 無法連接 API + +**症狀**:模型列表為空或顯示錯誤 + +**解決方案**: +1. 確認 API URL 正確: `http://tobiichiGPT-api:8000/v1` +2. 檢查容器網路: + ```powershell + docker network inspect tobiichiGPT-network + ``` +3. 測試 API: + ```powershell + curl http://localhost:18000/v1/models + ``` + +## 🎨 進階配置 + +### 修改等待超時時間 + +編輯 `api/server.py`: + +```python +async def wait_for_thread_reply(room_id: str, thread_id: str, timeout: int = 600): + # 改為 1200 秒(20 分鐘) + timeout: int = 1200 +``` + +### 修改檢查間隔 + +編輯 `api/server.py`: + +```python +async def wait_for_thread_reply(room_id: str, thread_id: str, timeout: int = 600): + check_interval = 3 # 改為 5 秒檢查一次 +``` + +### 自訂頻道名稱格式 + +編輯 `api/server.py`: + +```python +async def get_or_create_channel(user_id: str, user_name: str = None): + # 原本: channel_name = f"user-{user_id[:8]}" + # 改為使用用戶名稱 + channel_name = f"{user_name}" if user_name else f"user-{user_id[:8]}" +``` + +## 📦 資料持久化 + +所有資料都會持久化保存: + +``` +/mnt/data/External/tobiichiGPT/ +├── db_data/ # PostgreSQL 資料 +├── mongo_data/ # Rocket.Chat 資料 +├── api_data/ # API 程式碼 +└── ui_data/ # Open WebUI 資料 +``` + +## 🔄 更新與維護 + +### 更新 API 程式碼 + +```powershell +# 編輯程式碼 +notepad api\server.py + +# 重啟服務 +docker-compose restart api + +# 查看日誌確認 +docker-compose logs -f api +``` + +### 更新 Docker 映像 + +```powershell +# 拉取最新映像 +docker-compose pull + +# 重新啟動 +docker-compose up -d +``` + +### 備份資料 + +```powershell +# 備份 PostgreSQL +docker exec tobiichiGPT-postgres pg_dump -U tobiichi3227 tobiichiGPT > backup.sql + +# 備份 MongoDB +docker exec tobiichiGPT-mongo mongodump --out /backup +``` + +## 🎯 效能優化 + +### 建議配置 + +**最小需求**: +- CPU: 2 核心 +- RAM: 4 GB +- 磁碟: 20 GB + +**推薦配置**: +- CPU: 4 核心 +- RAM: 8 GB +- 磁碟: 50 GB + +### 容器資源限制 + +編輯 `docker-compose.yml` 加入: + +```yaml +rocketchat: + # ... 其他設定 + deploy: + resources: + limits: + cpus: '2' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M +``` + +## 📞 技術支援 + +- GitHub Issues: [open-webui/open-webui](https://github.com/open-webui/open-webui/issues) +- Rocket.Chat 文檔: [docs.rocket.chat](https://docs.rocket.chat) +- Discord: [Rocket.Chat Community](https://discord.gg/rocketchat) diff --git a/api/server.py b/api/server.py index cb5ee76..0da8923 100644 --- a/api/server.py +++ b/api/server.py @@ -1,5 +1,5 @@ """ -API 轉接層 - 偽裝 OpenAI API,將請求轉為人工回覆隊列 +API 轉接層 - 偽裝 OpenAI API,整合 Rocket.Chat 作為管理員回覆介面 """ from fastapi import FastAPI, Request, Header @@ -10,6 +10,8 @@ import asyncio import time import uuid import os +import json +import hashlib import httpx from typing import Optional from datetime import datetime @@ -29,9 +31,13 @@ DB_CONFIG = { "password": os.getenv("DB_PASSWORD", "tobiichi_password") } -# Papercups 設定 -PAPERCUPS_URL = os.getenv("PAPERCUPS_URL", "http://papercups:4000") -PAPERCUPS_API_TOKEN = os.getenv("PAPERCUPS_API_TOKEN", "") +# Rocket.Chat 設定 +ROCKETCHAT_URL = os.getenv("ROCKETCHAT_URL", "http://rocketchat:3000") +ROCKETCHAT_USER = os.getenv("ROCKETCHAT_USER", "admin") +ROCKETCHAT_PASSWORD = os.getenv("ROCKETCHAT_PASSWORD", "admin") + +# 全域認證狀態 +rocketchat_auth = None class Message(BaseModel): @@ -45,14 +51,294 @@ class ChatRequest(BaseModel): stream: Optional[bool] = False -class PapercupsWebhook(BaseModel): - """Papercups Webhook 資料格式""" - event: str - payload: Optional[dict] = None +async def rocketchat_login(): + """登入 Rocket.Chat 取得認證 Token""" + global rocketchat_auth + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{ROCKETCHAT_URL}/api/v1/login", + json={ + "username": ROCKETCHAT_USER, + "password": ROCKETCHAT_PASSWORD + } + ) + + if resp.status_code == 200: + data = resp.json() + rocketchat_auth = { + "X-Auth-Token": data["data"]["authToken"], + "X-User-Id": data["data"]["userId"] + } + print(f"✅ Rocket.Chat 登入成功") + return rocketchat_auth + else: + print(f"⚠️ Rocket.Chat 登入失敗: {resp.status_code}") + return None + + except Exception as e: + print(f"❌ Rocket.Chat 登入錯誤: {e}") + return None + + +async def get_or_create_channel(user_id: str, user_name: str = None): + """取得或創建用戶專屬頻道""" + if not rocketchat_auth: + await rocketchat_login() + + if not rocketchat_auth: + return None + + # 頻道名稱使用用戶名,如果沒有則用 user_id + display_name = user_name if user_name else f"User-{user_id[:8]}" + # 清理頻道名稱(移除空格和特殊字符,Rocket.Chat 頻道名不支援) + channel_name = display_name.replace(" ", "-").replace("@", "").lower()[:50] # 限制長度 + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # 嘗試創建頻道 + resp = await client.post( + f"{ROCKETCHAT_URL}/api/v1/channels.create", + headers=rocketchat_auth, + json={ + "name": channel_name, + "members": [] # 空成員列表,只有管理員可見 + } + ) + + if resp.status_code == 200: + data = resp.json() + room_id = data["channel"]["_id"] + print(f"✅ 創建頻道: {channel_name} ({display_name})") + + # 設置頻道描述 + await client.post( + f"{ROCKETCHAT_URL}/api/v1/channels.setDescription", + headers=rocketchat_auth, + json={ + "roomId": room_id, + "description": f"用戶: {display_name} (ID: {user_id})" + } + ) + + return room_id + + # 如果頻道已存在,取得頻道資訊 + elif resp.status_code == 400: + resp = await client.get( + f"{ROCKETCHAT_URL}/api/v1/channels.info", + headers=rocketchat_auth, + params={"roomName": channel_name} + ) + + if resp.status_code == 200: + data = resp.json() + room_id = data["channel"]["_id"] + print(f"✅ 使用現有頻道: {channel_name}") + return room_id + else: + print(f"⚠️ 取得頻道資訊失敗: {resp.status_code}") + return None + else: + print(f"⚠️ 創建頻道失敗: {resp.status_code}") + return None + + except Exception as e: + print(f"❌ Rocket.Chat 頻道操作錯誤: {e}") + return None + + +async def get_or_create_thread(room_id: str, chat_id: str): + """查找或創建對話的執行緒""" + if not rocketchat_auth: + await rocketchat_login() + + if not rocketchat_auth: + return None + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # 搜尋頻道中是否已有此 chat_id 的執行緒(搜尋最近50條消息) + resp = await client.get( + f"{ROCKETCHAT_URL}/api/v1/channels.messages", + headers=rocketchat_auth, + params={ + "roomId": room_id, + "count": 50 + } + ) + + if resp.status_code == 200: + data = resp.json() + messages = data.get("messages", []) + + # 找到現有的執行緒起始訊息 + for msg in messages: + if f"對話 {chat_id[:8]}" in msg.get("msg", ""): + thread_id = msg["_id"] + print(f"✅ 找到現有執行緒: {thread_id}") + return thread_id + + # 沒有找到,返回 None(需要創建新執行緒) + return None + + except Exception as e: + print(f"❌ 查找執行緒錯誤: {e}") + return None + + +async def create_thread_message(room_id: str, chat_id: str, user_message: str, user_name: str = None): + """在頻道中發送訊息並創建執行緒""" + if not rocketchat_auth: + await rocketchat_login() + + if not rocketchat_auth: + return None + + display_name = user_name if user_name else "User" + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # 發送訊息(這會成為執行緒的起始訊息) + resp = await client.post( + f"{ROCKETCHAT_URL}/api/v1/chat.postMessage", + headers=rocketchat_auth, + json={ + "roomId": room_id, + "text": f"**[對話 {chat_id[:8]}] - {display_name}**\n\n{user_message}" + } + ) + + if resp.status_code == 200: + data = resp.json() + message_id = data["message"]["_id"] + print(f"✅ 創建執行緒訊息: {message_id}") + return message_id + else: + print(f"⚠️ 發送訊息失敗: {resp.status_code}") + print(f" 錯誤內容: {resp.text}") + return None + + except Exception as e: + print(f"❌ Rocket.Chat 發送訊息錯誤: {e}") + return None + + +async def add_thread_reply(room_id: str, thread_id: str, user_message: str, user_name: str = None): + """在現有執行緒中追加用戶訊息""" + if not rocketchat_auth: + await rocketchat_login() + + if not rocketchat_auth: + return False + + display_name = user_name if user_name else "User" + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # 在執行緒中回覆 + resp = await client.post( + f"{ROCKETCHAT_URL}/api/v1/chat.postMessage", + headers=rocketchat_auth, + json={ + "roomId": room_id, + "text": f"**{display_name}:**\n\n{user_message}", + "tmid": thread_id # 指定執行緒 ID + } + ) + + if resp.status_code == 200: + print(f"✅ 追加執行緒訊息") + return True + else: + print(f"⚠️ 追加訊息失敗: {resp.status_code}") + return False + + except Exception as e: + print(f"❌ 追加訊息錯誤: {e}") + return False + + +async def wait_for_thread_reply(room_id: str, thread_id: str, existing_message_count: int = 0, timeout: int = 600): + """輪詢等待執行緒中的管理員回覆 + + Args: + existing_message_count: 發送訊息前執行緒中已有的訊息數量,只等待新增的回覆 + """ + if not rocketchat_auth: + await rocketchat_login() + + if not rocketchat_auth: + return None + + start_time = time.time() + check_interval = 3 # 每 3 秒檢查一次 + + # 取得 bot 帳號的用戶名,用於過濾 + bot_username = ROCKETCHAT_USER + + print(f"🔄 開始輪詢執行緒回覆 (thread_id: {thread_id}, 現有訊息: {existing_message_count})") + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + while time.time() - start_time < timeout: + # 取得執行緒中的所有訊息 + resp = await client.get( + f"{ROCKETCHAT_URL}/api/v1/chat.getThreadMessages", + headers=rocketchat_auth, + params={ + "tmid": thread_id # Thread Message ID + } + ) + + if resp.status_code == 200: + data = resp.json() + messages = data.get("messages", []) + + # 只看新增的訊息(比現有數量多的部分) + if len(messages) > existing_message_count + 1: # +1 是我們剛發的訊息 + print(f" 檢查執行緒,找到 {len(messages)} 條訊息 (新增: {len(messages) - existing_message_count - 1})") + + # 按時間排序,取最新的訊息 + sorted_messages = sorted(messages, key=lambda m: m.get("ts", ""), reverse=True) + + # 尋找管理員的新回覆(不是 bot 發送的) + for msg in sorted_messages: + # 跳過原始訊息 + if msg["_id"] == thread_id: + continue + + # 檢查發送者 + sender = msg.get("u", {}) + sender_username = sender.get("username", "") + + # 跳過 bot 自己發送的訊息(系統轉發的用戶訊息) + if sender_username == bot_username: + continue + + # 檢查是否為真人回覆(有 u 欄位且不是 bot 標記) + if sender and not msg.get("bot"): + reply_text = msg.get("msg", "") + if reply_text: + print(f"✅ 收到管理員回覆 (from: {sender_username}): {reply_text[:50]}...") + return reply_text + else: + print(f" ⚠️ API 回應異常: {resp.status_code}") + + await asyncio.sleep(check_interval) + + # 超時 + print(f"⚠️ 等待回覆超時 ({timeout}秒)") + return "抱歉,目前客服繁忙,請稍後再試。" + + except Exception as e: + print(f"❌ 檢查回覆錯誤: {e}") + return "系統錯誤,請稍後再試。" async def get_user_name(user_id: str): - """從資料庫獲取用戶真實姓名""" + """從資料庫獲取用戶真實姓名(保留作為工具函數)""" if not user_id: return None @@ -72,91 +358,12 @@ async def get_user_name(user_id: str): return None -async def get_or_create_papercups_conversation(user_id: str, chat_id: str, user_name: str = None): - """獲取或創建 Papercups 對話(使用 chat_id 作為唯一標識)""" - if not PAPERCUPS_API_TOKEN: - print("⚠️ Papercups 未配置,跳過推送") - return None - - try: - async with httpx.AsyncClient(timeout=10.0) as client: - headers = {"Authorization": f"Bearer {PAPERCUPS_API_TOKEN}"} - - # 使用 chat_id 作為唯一標識符 - identifier = chat_id if chat_id else user_id - - # 如果沒有提供用戶名,嘗試從數據庫獲取 - if not user_name and user_id: - user_name = await get_user_name(user_id) - - # 生成顯示名稱 - if user_name: - display_name = user_name - elif user_id: - display_name = f"User-{user_id[:8]}" - else: - display_name = f"Chat-{chat_id[:8]}" - - # Papercups API: 創建或獲取對話 - conversation_url = f"{PAPERCUPS_URL}/api/conversations" - conversation_payload = { - "customer": { - "external_id": identifier, - "name": display_name - } - } - - conversation_response = await client.post(conversation_url, json=conversation_payload, headers=headers) - if conversation_response.status_code in [200, 201]: - conversation_data = conversation_response.json() - papercups_conv_id = conversation_data.get("id") - print(f"✅ Papercups 對話: #{papercups_conv_id} ({display_name})") - return papercups_conv_id - else: - print(f"⚠️ Papercups 對話創建失敗: {conversation_response.status_code}") - return None - - except Exception as e: - print(f"❌ Papercups 錯誤: {e}") - import traceback - traceback.print_exc() - return None - - -async def send_message_to_papercups(papercups_conv_id: str, message: str, sent_by: str = "customer"): - """發送訊息到 Papercups 對話""" - if not PAPERCUPS_API_TOKEN or not papercups_conv_id: - return False - - try: - async with httpx.AsyncClient(timeout=10.0) as client: - headers = {"Authorization": f"Bearer {PAPERCUPS_API_TOKEN}"} - msg_url = f"{PAPERCUPS_URL}/api/messages" - msg_payload = { - "conversation_id": papercups_conv_id, - "body": message, - "sent_by": sent_by - } - - msg_response = await client.post(msg_url, json=msg_payload, headers=headers) - if msg_response.status_code in [200, 201]: - print(f"✅ 訊息已發送到 Papercups 對話 #{papercups_conv_id}") - return True - else: - print(f"⚠️ 訊息發送失敗: {msg_response.status_code}") - return False - - except Exception as e: - print(f"❌ 發送訊息錯誤: {e}") - return False - - async def init_db(): """初始化資料庫連接池和表格""" global db_pool db_pool = await asyncpg.create_pool(**DB_CONFIG, min_size=2, max_size=10) - # 建立對話隊列表格 + # 建立對話隊列表格(用於記錄和追蹤) async with db_pool.acquire() as conn: await conn.execute(""" CREATE TABLE IF NOT EXISTS reply_queue ( @@ -164,27 +371,23 @@ async def init_db(): conversation_id VARCHAR(50) UNIQUE NOT NULL, user_id VARCHAR(255), chat_id VARCHAR(255), + user_name VARCHAR(255), user_message TEXT NOT NULL, admin_reply TEXT, status VARCHAR(20) DEFAULT 'pending', created_at TIMESTAMP DEFAULT NOW(), replied_at TIMESTAMP, - papercups_conversation_id VARCHAR(100) + rocketchat_room_id VARCHAR(100), + rocketchat_thread_id VARCHAR(100) ) """) - # 添加新欄位(如果不存在) - try: - await conn.execute("ALTER TABLE reply_queue ADD COLUMN IF NOT EXISTS user_id VARCHAR(255);") - await conn.execute("ALTER TABLE reply_queue ADD COLUMN IF NOT EXISTS chat_id VARCHAR(255);") - except: - pass - # 建立索引 await conn.execute(""" CREATE INDEX IF NOT EXISTS idx_status ON reply_queue(status); CREATE INDEX IF NOT EXISTS idx_conversation_id ON reply_queue(conversation_id); CREATE INDEX IF NOT EXISTS idx_chat_id ON reply_queue(chat_id); + CREATE INDEX IF NOT EXISTS idx_user_id ON reply_queue(user_id); """) @@ -192,6 +395,9 @@ async def init_db(): async def startup(): await init_db() print("✅ 資料庫連接成功") + + # 嘗試登入 Rocket.Chat + await rocketchat_login() @app.on_event("shutdown") @@ -204,7 +410,11 @@ async def shutdown(): @app.get("/") async def root(): """根路徑""" - return {"status": "ok", "service": "TobiichiGPT API"} + return { + "status": "ok", + "service": "TobiichiGPT API", + "chat_backend": "Rocket.Chat" + } @app.get("/v1/models") @@ -234,7 +444,7 @@ async def chat_completions( ): """ 模擬 OpenAI Chat Completions API - 將用戶訊息寫入資料庫,等待管理員回覆(無超時限制) + 將用戶訊息轉發到 Rocket.Chat,等待管理員回覆 """ # 取得最後一則用戶訊息 user_message = None @@ -249,140 +459,174 @@ async def chat_completions( content={"error": "No user message found"} ) - # 嘗試從請求中提取用戶信息 - user_id = None - chat_id = None - - # 從 headers 提取信息 + # 從 Open WebUI headers 提取用戶資訊 headers_dict = dict(http_request.headers) - user_id = headers_dict.get("x-user-id") or headers_dict.get("user-id") - chat_id = headers_dict.get("x-chat-id") or headers_dict.get("chat-id") - # 如果沒有,生成一個基於授權 token 的穩定 ID - if not user_id and authorization: - user_id = hashlib.md5(authorization.encode()).hexdigest()[:16] + # 調試:輸出所有 headers + print(f"🔍 收到的 Headers:") + for key, value in headers_dict.items(): + if 'user' in key.lower() or 'chat' in key.lower() or 'name' in key.lower(): + print(f" {key}: {value}") - # 生成消息 ID(用於追蹤單條消息) + user_id = headers_dict.get("x-openwebui-user-id") + chat_id = headers_dict.get("x-openwebui-chat-id") + user_name = headers_dict.get("x-openwebui-user-name") + user_email = headers_dict.get("x-openwebui-user-email") + + # 如果沒有從 headers 取得,使用備用方案 + if not user_id: + user_id = headers_dict.get("x-user-id") or headers_dict.get("user-id") + if not chat_id: + chat_id = headers_dict.get("x-chat-id") or headers_dict.get("chat-id") + + # 生成 message ID message_id = str(uuid.uuid4()) - # 如果沒有 chat_id,使用 user_id + # 如果還是沒有,使用 fallback + if not user_id: + user_id = hashlib.md5(authorization.encode() if authorization else message_id.encode()).hexdigest()[:16] + if not chat_id: - chat_id = user_id if user_id else message_id + chat_id = message_id - # 獲取用戶真實姓名 - user_name = await get_user_name(user_id) if user_id else None + # 過濾 Open WebUI 的系統任務訊息(標題生成、標籤生成、後續問題生成等) + if user_message.strip().startswith("### Task:"): + print(f"⏭️ 跳過系統任務訊息: {user_message[:50]}...") + # 回傳空的 JSON 回應讓 Open WebUI 處理 + return { + "id": f"chatcmpl-{uuid.uuid4()}", + "object": "chat.completion", + "created": int(time.time()), + "model": request_data.model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{}" + }, + "finish_reason": "stop" + } + ], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + } - print(f"📝 收到訊息 [user:{user_name or user_id}, chat:{chat_id}]: {user_message[:50]}...") + print(f"📝 收到訊息") + print(f" 用戶ID: {user_id}") + print(f" 用戶名: {user_name}") + print(f" 對話: {chat_id[:8]}") + print(f" 內容: {user_message[:50]}...") - # 獲取或創建 Papercups 對話 - papercups_conv_id = await get_or_create_papercups_conversation(user_id, chat_id, user_name) + # 1. 取得或創建用戶頻道 + room_id = await get_or_create_channel(user_id, user_name) - # 寫入資料庫 + if not room_id: + return JSONResponse( + status_code=500, + content={"error": "Failed to create Rocket.Chat channel"} + ) + + # 2. 查找是否已有此 chat_id 的執行緒 + thread_id = await get_or_create_thread(room_id, chat_id) + existing_message_count = 0 + + if thread_id: + # 已有執行緒,先取得現有訊息數量 + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get( + f"{ROCKETCHAT_URL}/api/v1/chat.getThreadMessages", + headers=rocketchat_auth, + params={"tmid": thread_id} + ) + if resp.status_code == 200: + existing_message_count = len(resp.json().get("messages", [])) + except Exception as e: + print(f"⚠️ 取得現有訊息數量失敗: {e}") + + # 追加訊息 + success = await add_thread_reply(room_id, thread_id, user_message, user_name) + if not success: + return JSONResponse( + status_code=500, + content={"error": "Failed to add message to thread"} + ) + else: + # 沒有執行緒,創建新的 + thread_id = await create_thread_message(room_id, chat_id, user_message, user_name) + if not thread_id: + return JSONResponse( + status_code=500, + content={"error": "Failed to create Rocket.Chat thread"} + ) + + # 3. 記錄到資料庫 async with db_pool.acquire() as conn: await conn.execute( """ - INSERT INTO reply_queue (conversation_id, user_id, chat_id, user_message, status, papercups_conversation_id) - VALUES ($1, $2, $3, $4, 'pending', $5) + INSERT INTO reply_queue ( + conversation_id, user_id, chat_id, user_name, user_message, + status, rocketchat_room_id, rocketchat_thread_id + ) + VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7) """, - message_id, user_id, chat_id, user_message, papercups_conv_id + message_id, user_id, chat_id, user_name, user_message, room_id, thread_id ) - # 推送到 Papercups - if papercups_conv_id: - await send_message_to_papercups(papercups_conv_id, user_message, "customer") + # 4. 等待管理員在執行緒中回覆(傳入現有訊息數量,只等待新回覆) + admin_reply = await wait_for_thread_reply(room_id, thread_id, existing_message_count) - # 無限等待管理員回覆 - check_interval = 2 # 每 2 秒檢查一次 + # 5. 更新資料庫 + async with db_pool.acquire() as conn: + await conn.execute( + """ + UPDATE reply_queue + SET admin_reply = $1, status = 'replied', replied_at = NOW() + WHERE conversation_id = $2 + """, + admin_reply, message_id + ) - while True: - await asyncio.sleep(check_interval) - - async with db_pool.acquire() as conn: - row = await conn.fetchrow( - "SELECT admin_reply, status FROM reply_queue WHERE conversation_id = $1", - message_id - ) - - if row and row['status'] == 'replied' and row['admin_reply']: - admin_reply = row['admin_reply'] - print(f"✅ 管理員已回覆 [chat:{chat_id}]") - - # 回傳 OpenAI 格式的回應 - return { - "id": f"chatcmpl-{message_id}", - "object": "chat.completion", - "created": int(time.time()), - "model": request_data.model, - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": admin_reply - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": len(user_message), - "completion_tokens": len(admin_reply), - "total_tokens": len(user_message) + len(admin_reply) - } - } + print(f"✅ 完成回覆 [chat:{chat_id[:8]}]") + + # 6. 回傳 OpenAI 格式的回應 + return { + "id": f"chatcmpl-{message_id}", + "object": "chat.completion", + "created": int(time.time()), + "model": request_data.model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": admin_reply + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": len(user_message), + "completion_tokens": len(admin_reply), + "total_tokens": len(user_message) + len(admin_reply) + } + } -@app.post("/papercups/webhook") -async def papercups_webhook(request: Request): - """接收 Papercups Webhook 回調""" - try: - data = await request.json() - event = data.get("event") - - # 只處理訊息建立事件 - if event != "message:created": - return {"status": "ignored", "event": event} - - payload = data.get("payload", {}) - message = payload.get("message", {}) - - # 檢查是否為管理員回覆(user type = "user") - user = message.get("user") - if not user: - return {"status": "ignored", "reason": "not_agent_reply"} - - # 取得訊息內容和對話 ID - content = message.get("body") - conversation_id = message.get("conversation_id") - - if not content or not conversation_id: - return {"status": "error", "message": "Missing content or conversation_id"} - - # 更新資料庫中所有該對話的待處理消息(最新的一條) - async with db_pool.acquire() as conn: - result = await conn.execute( - """ - UPDATE reply_queue - SET admin_reply = $1, status = 'replied', replied_at = NOW() - WHERE papercups_conversation_id = $2 - AND status = 'pending' - AND id = ( - SELECT id FROM reply_queue - WHERE papercups_conversation_id = $2 AND status = 'pending' - ORDER BY created_at DESC - LIMIT 1 - ) - """, - content, conversation_id - ) - - print(f"✅ Papercups 回覆已處理: 對話 #{conversation_id}") - return {"status": "success", "conversation_id": conversation_id} - - except Exception as e: - print(f"❌ Webhook 處理錯誤: {e}") - return {"status": "error", "message": str(e)} +@app.get("/health") +async def health_check(): + """健康檢查端點""" + db_status = "ok" if db_pool else "disconnected" + rc_status = "ok" if rocketchat_auth else "not_authenticated" + + return { + "status": "healthy", + "database": db_status, + "rocketchat": rc_status + } if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) + diff --git a/docker-compose.yml b/docker-compose.yml index 2369794..4b02a19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: POSTGRES_USER: tobiichi3227 POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - - /mnt/data/External/tobiichiGPT/db_data:/var/lib/postgresql/data + - /mnt/data/External/tobiichiGPT/db_data/pg:/var/lib/postgresql/data networks: - tobiichiGPT-network healthcheck: @@ -20,6 +20,43 @@ services: timeout: 5s retries: 5 + # MongoDB - Rocket.Chat 資料庫 + mongo: + image: mongo:7.0 + container_name: tobiichiGPT-mongo + restart: unless-stopped + volumes: + - /mnt/data/External/tobiichiGPT/db_data/mongo:/data/db + networks: + - tobiichiGPT-network + command: mongod --oplogSize 128 --replSet rs0 + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + + # MongoDB Replica Set 初始化 + mongo-init-replica: + image: mongo:7.0 + container_name: tobiichiGPT-mongo-init + restart: "no" + depends_on: + mongo: + condition: service_healthy + networks: + - tobiichiGPT-network + command: > + bash -c " + for i in {1..30}; do + mongosh mongo/rocketchat --eval \" + rs.initiate({ + _id: 'rs0', + members: [ { _id: 0, host: 'mongo:27017' } ] + })\" && break || echo \"嘗試 $$i 次,等待 5 秒...\" && sleep 5; + done + " + # API 轉接層 - 偽裝 OpenAI API api: image: python:3.11-slim @@ -33,8 +70,9 @@ services: - DB_NAME=tobiichiGPT - DB_USER=tobiichi3227 - DB_PASSWORD=${DB_PASSWORD} - - PAPERCUPS_URL=http://papercups:4000 - - PAPERCUPS_API_TOKEN=${PAPERCUPS_API_TOKEN} + - ROCKETCHAT_URL=http://rocketchat:3000 + - ROCKETCHAT_USER=${ROCKETCHAT_USER} + - ROCKETCHAT_PASSWORD=${ROCKETCHAT_PASSWORD} volumes: - /mnt/data/External/tobiichiGPT/api_data:/app working_dir: /app @@ -48,6 +86,8 @@ services: depends_on: postgres: condition: service_healthy + rocketchat: + condition: service_started # Open WebUI - 用戶對話介面 openwebui: @@ -59,6 +99,7 @@ services: environment: - DATABASE_URL=postgresql://tobiichi3227:${DB_PASSWORD}@postgres:5432/tobiichiGPT - WEBUI_AUTH=True + - ENABLE_FORWARD_USER_INFO_HEADERS=True volumes: - /mnt/data/External/tobiichiGPT/ui_data:/app/backend/data networks: @@ -67,23 +108,26 @@ services: postgres: condition: service_healthy - # Papercups - 管理員對話介面 - papercups: - image: papercups/papercups:latest - container_name: tobiichiGPT-papercups + # Rocket.Chat - 管理員對話介面 + rocketchat: + image: registry.rocket.chat/rocketchat/rocket.chat:latest + container_name: tobiichiGPT-rocketchat restart: unless-stopped ports: - - "14000:4000" + - "13000:3000" environment: - - DATABASE_URL=postgresql://tobiichi3227:${DB_PASSWORD}@postgres:5432/papercups - - SECRET_KEY_BASE=${PAPERCUPS_SECRET_KEY} - - BACKEND_URL=http://localhost:14000 - - MIX_ENV=prod + MONGO_URL: mongodb://mongo:27017/rocketchat?replicaSet=rs0 + MONGO_OPLOG_URL: mongodb://mongo:27017/local?replicaSet=rs0 + ROOT_URL: http://localhost:13000 + PORT: 3000 + DEPLOY_METHOD: docker networks: - tobiichiGPT-network depends_on: - postgres: + mongo: condition: service_healthy + mongo-init-replica: + condition: service_completed_successfully networks: tobiichiGPT-network: diff --git a/exchange.md b/exchange.md new file mode 100644 index 0000000..3c5a0b5 --- /dev/null +++ b/exchange.md @@ -0,0 +1,112 @@ +# 工程師交接說明 + +## 後臺方案更換建議:Streamlit + +若未來認為 Rocket.Chat 部署過重或整合不便,可轉用 **Streamlit** 開發自定義管理後台。此方案能針對 Open WebUI 的資料結構進行 100% 客製化。 + +### 核心優勢 + +1. **極簡開發**:純 Python 即可構建前端,約 200 行程式碼可完成完整後台。 +2. **超輕量級**:單一容器(~100MB 記憶體),無需額外資料庫(直接讀取現有 Postgres)。 +3. **完全客製**:可完美呈現「用戶 → 對話 → 訊息」的三層結構,不受限於聊天軟體的頻道邏輯。 +4. **部署簡單**:標準 Docker image `python:3.11-slim` + `pip install streamlit`。 + +### 實作架構 + +* **前端**:Streamlit Web App (Port 8501) +* **後端邏輯**:直連 PostgreSQL `reply_queue` 表格 +* **功能**:自動刷新、用戶篩選、歷史回覆查詢 + +### 程式碼範例 (admin.py) + +```python +import streamlit as st +import psycopg2 +import pandas as pd + +# 自動刷新設定 (每 5 秒) +from streamlit_autorefresh import st_autorefresh +st_autorefresh(interval=5000, key="msg_refresh") + +st.set_page_config(layout="wide", page_title="TobiichiGPT Admin") + +# 1. 連接資料庫 +conn = psycopg2.connect("postgresql://user:pass@postgres:5432/tobiichiGPT") + +# 2. 側邊欄:用戶列表 +st.sidebar.title("用戶列表") +users = pd.read_sql("SELECT DISTINCT user_id FROM reply_queue", conn) +selected_user = st.sidebar.radio("選擇用戶", users['user_id']) + +# 3. 主畫面:顯示該用戶的對話 +if selected_user: + st.header(f"用戶: {selected_user}") + + # 撈取該用戶訊息 + msgs = pd.read_sql( + f"SELECT * FROM reply_queue WHERE user_id='{selected_user}' ORDER BY created_at DESC", + conn + ) + + for _, row in msgs.iterrows(): + with st.expander(f"對話 {row['chat_id']} ({row['status']})", expanded=True): + st.info(f"用戶: {row['user_message']}") + + if row['status'] == 'pending': + with st.form(key=f"form_{row['id']}"): + reply = st.text_area("回覆內容") + if st.form_submit_button("送出"): + # 更新資料庫 + cur = conn.cursor() + cur.execute( + "UPDATE reply_queue SET admin_reply=%s, status='replied' WHERE id=%s", + (reply, row['id']) + ) + conn.commit() + st.success("已回覆") + st.rerun() + else: + st.success(f"管理員: {row['admin_reply']}") +``` + +### 部署配置 (Docker) + +**Dockerfile**: +```dockerfile +FROM python:3.11-slim +WORKDIR /app +RUN pip install streamlit psycopg2-binary pandas streamlit-autorefresh +COPY admin.py . +CMD ["streamlit", "run", "admin.py", "--server.port=8501", "--server.address=0.0.0.0"] +``` + +**docker-compose.yml**: +```yaml + admin-ui: + build: ./admin-ui + ports: ["8501:8501"] + environment: + - DB_HOST=postgres + - DB_PASSWORD=${DB_PASSWORD} + networks: + - tobiichiGPT-network +``` + +### 與 Rocket.Chat/Chatwoot比較 + +| 特性 | Streamlit (自建) | Rocket.Chat | Chatwoot | +| :--- | :--- | :--- | :--- | +| **對應 Open WebUI 結構** | ⭐⭐⭐⭐⭐ (完全貼合) | ⭐⭐⭐ (需用頻道模擬) | ⭐ (結構扁平) | +| **即時性** | ⭐⭐ (輪詢刷新) | ⭐⭐⭐⭐⭐ (WebSocket) | ⭐⭐⭐⭐⭐ (WebSocket) | +| **手機 App** | ⭐ (網頁版) | ⭐⭐⭐⭐⭐ (原生 App) | ⭐⭐⭐⭐⭐ (原生 App) | +| **資源消耗** | 低 (~100MB) | 中 (~500MB) | 高 (~1GB) | +| **適用場景** | 單人/少數管理員,追求輕量與精準管理 | 多人團隊協作,需要 App 通知 | 專業客服團隊 | + +### 建議切換時機 + +若遇到以下情況,建議切換至 Streamlit 方案: +1. Rocket.Chat 的頻道/執行緒管理變得混亂,難以追蹤用戶對話。 +2. 伺服器資源不足,無法負擔 Rocket.Chat + MongoDB。 +3. 需要針對特定業務邏輯(如:查看用戶餘額、審核特定關鍵字)進行客製化開發。 + +## Rocket.chat實作紀錄