Change platform to rocket.chat

This commit is contained in:
ChenKaiLiuG
2026-02-01 01:24:56 +08:00
parent f1761cb25d
commit 809a65056d
6 changed files with 1069 additions and 230 deletions

View File

@@ -1,8 +1,6 @@
# PostgreSQL 密碼 # PostgreSQL 密碼
DB_PASSWORD=your_secure_password_here DB_PASSWORD=your_secure_password_here
# Papercups Secret Key (使用以下命令生成: openssl rand -hex 64) # Rocket.Chat 管理員帳號(首次啟動時設定)
PAPERCUPS_SECRET_KEY=your_papercups_secret_key_here ROCKETCHAT_USER=admin
ROCKETCHAT_PASSWORD=your_rocketchat_password_here
# Papercups API Token (從 Papercups Settings 取得)
PAPERCUPS_API_TOKEN=your_papercups_api_token_here

112
INTRO.md
View File

@@ -65,4 +65,114 @@ Open WebUI 採用**雙層結構**來組織對話:
- ✅ 多層結構 - 支援「用戶 → 對話 → 訊息」的層級關係 - ✅ 多層結構 - 支援「用戶 → 對話 → 訊息」的層級關係
- ✅ 狀態管理 - 清楚標示哪些對話待回覆 - ✅ 狀態管理 - 清楚標示哪些對話待回覆
## 架構 ## 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. **可擴展性** - 支援多管理員協作回覆

331
SETUP_GUIDE.md Normal file
View File

@@ -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)

View File

@@ -1,5 +1,5 @@
""" """
API 轉接層 - 偽裝 OpenAI API將請求轉為人工回覆隊列 API 轉接層 - 偽裝 OpenAI API整合 Rocket.Chat 作為管理員回覆介面
""" """
from fastapi import FastAPI, Request, Header from fastapi import FastAPI, Request, Header
@@ -10,6 +10,8 @@ import asyncio
import time import time
import uuid import uuid
import os import os
import json
import hashlib
import httpx import httpx
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
@@ -29,9 +31,13 @@ DB_CONFIG = {
"password": os.getenv("DB_PASSWORD", "tobiichi_password") "password": os.getenv("DB_PASSWORD", "tobiichi_password")
} }
# Papercups 設定 # Rocket.Chat 設定
PAPERCUPS_URL = os.getenv("PAPERCUPS_URL", "http://papercups:4000") ROCKETCHAT_URL = os.getenv("ROCKETCHAT_URL", "http://rocketchat:3000")
PAPERCUPS_API_TOKEN = os.getenv("PAPERCUPS_API_TOKEN", "") ROCKETCHAT_USER = os.getenv("ROCKETCHAT_USER", "admin")
ROCKETCHAT_PASSWORD = os.getenv("ROCKETCHAT_PASSWORD", "admin")
# 全域認證狀態
rocketchat_auth = None
class Message(BaseModel): class Message(BaseModel):
@@ -45,14 +51,294 @@ class ChatRequest(BaseModel):
stream: Optional[bool] = False stream: Optional[bool] = False
class PapercupsWebhook(BaseModel): async def rocketchat_login():
"""Papercups Webhook 資料格式""" """登入 Rocket.Chat 取得認證 Token"""
event: str global rocketchat_auth
payload: Optional[dict] = None
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): async def get_user_name(user_id: str):
"""從資料庫獲取用戶真實姓名""" """從資料庫獲取用戶真實姓名(保留作為工具函數)"""
if not user_id: if not user_id:
return None return None
@@ -72,91 +358,12 @@ async def get_user_name(user_id: str):
return None 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(): async def init_db():
"""初始化資料庫連接池和表格""" """初始化資料庫連接池和表格"""
global db_pool global db_pool
db_pool = await asyncpg.create_pool(**DB_CONFIG, min_size=2, max_size=10) db_pool = await asyncpg.create_pool(**DB_CONFIG, min_size=2, max_size=10)
# 建立對話隊列表格 # 建立對話隊列表格(用於記錄和追蹤)
async with db_pool.acquire() as conn: async with db_pool.acquire() as conn:
await conn.execute(""" await conn.execute("""
CREATE TABLE IF NOT EXISTS reply_queue ( CREATE TABLE IF NOT EXISTS reply_queue (
@@ -164,27 +371,23 @@ async def init_db():
conversation_id VARCHAR(50) UNIQUE NOT NULL, conversation_id VARCHAR(50) UNIQUE NOT NULL,
user_id VARCHAR(255), user_id VARCHAR(255),
chat_id VARCHAR(255), chat_id VARCHAR(255),
user_name VARCHAR(255),
user_message TEXT NOT NULL, user_message TEXT NOT NULL,
admin_reply TEXT, admin_reply TEXT,
status VARCHAR(20) DEFAULT 'pending', status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
replied_at TIMESTAMP, 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(""" await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_status ON reply_queue(status); 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_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_chat_id ON reply_queue(chat_id);
CREATE INDEX IF NOT EXISTS idx_user_id ON reply_queue(user_id);
""") """)
@@ -193,6 +396,9 @@ async def startup():
await init_db() await init_db()
print("✅ 資料庫連接成功") print("✅ 資料庫連接成功")
# 嘗試登入 Rocket.Chat
await rocketchat_login()
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown(): async def shutdown():
@@ -204,7 +410,11 @@ async def shutdown():
@app.get("/") @app.get("/")
async def root(): async def root():
"""根路徑""" """根路徑"""
return {"status": "ok", "service": "TobiichiGPT API"} return {
"status": "ok",
"service": "TobiichiGPT API",
"chat_backend": "Rocket.Chat"
}
@app.get("/v1/models") @app.get("/v1/models")
@@ -234,7 +444,7 @@ async def chat_completions(
): ):
""" """
模擬 OpenAI Chat Completions API 模擬 OpenAI Chat Completions API
將用戶訊息寫入資料庫,等待管理員回覆(無超時限制) 將用戶訊息轉發到 Rocket.Chat,等待管理員回覆
""" """
# 取得最後一則用戶訊息 # 取得最後一則用戶訊息
user_message = None user_message = None
@@ -249,140 +459,174 @@ async def chat_completions(
content={"error": "No user message found"} content={"error": "No user message found"}
) )
# 嘗試從請求中提取用戶信息 # 從 Open WebUI headers 提取用戶資訊
user_id = None
chat_id = None
# 從 headers 提取信息
headers_dict = dict(http_request.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 # 調試:輸出所有 headers
if not user_id and authorization: print(f"🔍 收到的 Headers:")
user_id = hashlib.md5(authorization.encode()).hexdigest()[:16] 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()) 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: if not chat_id:
chat_id = user_id if user_id else message_id chat_id = message_id
# 獲取用戶真實姓名 # 過濾 Open WebUI 的系統任務訊息(標題生成、標籤生成、後續問題生成等)
user_name = await get_user_name(user_id) if user_id else None 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 對話 # 1. 取得或創建用戶頻道
papercups_conv_id = await get_or_create_papercups_conversation(user_id, chat_id, user_name) 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: async with db_pool.acquire() as conn:
await conn.execute( await conn.execute(
""" """
INSERT INTO reply_queue (conversation_id, user_id, chat_id, user_message, status, papercups_conversation_id) INSERT INTO reply_queue (
VALUES ($1, $2, $3, $4, 'pending', $5) 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 # 4. 等待管理員在執行緒中回覆(傳入現有訊息數量,只等待新回覆)
if papercups_conv_id: admin_reply = await wait_for_thread_reply(room_id, thread_id, existing_message_count)
await send_message_to_papercups(papercups_conv_id, user_message, "customer")
# 無限等待管理員回覆 # 5. 更新資料庫
check_interval = 2 # 每 2 秒檢查一次 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: print(f"✅ 完成回覆 [chat:{chat_id[:8]}]")
await asyncio.sleep(check_interval)
async with db_pool.acquire() as conn: # 6. 回傳 OpenAI 格式的回應
row = await conn.fetchrow( return {
"SELECT admin_reply, status FROM reply_queue WHERE conversation_id = $1", "id": f"chatcmpl-{message_id}",
message_id "object": "chat.completion",
) "created": int(time.time()),
"model": request_data.model,
if row and row['status'] == 'replied' and row['admin_reply']: "choices": [
admin_reply = row['admin_reply'] {
print(f"✅ 管理員已回覆 [chat:{chat_id}]") "index": 0,
"message": {
# 回傳 OpenAI 格式的回應 "role": "assistant",
return { "content": admin_reply
"id": f"chatcmpl-{message_id}", },
"object": "chat.completion", "finish_reason": "stop"
"created": int(time.time()), }
"model": request_data.model, ],
"choices": [ "usage": {
{ "prompt_tokens": len(user_message),
"index": 0, "completion_tokens": len(admin_reply),
"message": { "total_tokens": len(user_message) + len(admin_reply)
"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") @app.get("/health")
async def papercups_webhook(request: Request): async def health_check():
"""接收 Papercups Webhook 回調""" """健康檢查端點"""
try: db_status = "ok" if db_pool else "disconnected"
data = await request.json() rc_status = "ok" if rocketchat_auth else "not_authenticated"
event = data.get("event")
# 只處理訊息建立事件 return {
if event != "message:created": "status": "healthy",
return {"status": "ignored", "event": event} "database": db_status,
"rocketchat": rc_status
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)}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -11,7 +11,7 @@ services:
POSTGRES_USER: tobiichi3227 POSTGRES_USER: tobiichi3227
POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes: volumes:
- /mnt/data/External/tobiichiGPT/db_data:/var/lib/postgresql/data - /mnt/data/External/tobiichiGPT/db_data/pg:/var/lib/postgresql/data
networks: networks:
- tobiichiGPT-network - tobiichiGPT-network
healthcheck: healthcheck:
@@ -20,6 +20,43 @@ services:
timeout: 5s timeout: 5s
retries: 5 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 轉接層 - 偽裝 OpenAI API
api: api:
image: python:3.11-slim image: python:3.11-slim
@@ -33,8 +70,9 @@ services:
- DB_NAME=tobiichiGPT - DB_NAME=tobiichiGPT
- DB_USER=tobiichi3227 - DB_USER=tobiichi3227
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD}
- PAPERCUPS_URL=http://papercups:4000 - ROCKETCHAT_URL=http://rocketchat:3000
- PAPERCUPS_API_TOKEN=${PAPERCUPS_API_TOKEN} - ROCKETCHAT_USER=${ROCKETCHAT_USER}
- ROCKETCHAT_PASSWORD=${ROCKETCHAT_PASSWORD}
volumes: volumes:
- /mnt/data/External/tobiichiGPT/api_data:/app - /mnt/data/External/tobiichiGPT/api_data:/app
working_dir: /app working_dir: /app
@@ -48,6 +86,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
rocketchat:
condition: service_started
# Open WebUI - 用戶對話介面 # Open WebUI - 用戶對話介面
openwebui: openwebui:
@@ -59,6 +99,7 @@ services:
environment: environment:
- DATABASE_URL=postgresql://tobiichi3227:${DB_PASSWORD}@postgres:5432/tobiichiGPT - DATABASE_URL=postgresql://tobiichi3227:${DB_PASSWORD}@postgres:5432/tobiichiGPT
- WEBUI_AUTH=True - WEBUI_AUTH=True
- ENABLE_FORWARD_USER_INFO_HEADERS=True
volumes: volumes:
- /mnt/data/External/tobiichiGPT/ui_data:/app/backend/data - /mnt/data/External/tobiichiGPT/ui_data:/app/backend/data
networks: networks:
@@ -67,23 +108,26 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
# Papercups - 管理員對話介面 # Rocket.Chat - 管理員對話介面
papercups: rocketchat:
image: papercups/papercups:latest image: registry.rocket.chat/rocketchat/rocket.chat:latest
container_name: tobiichiGPT-papercups container_name: tobiichiGPT-rocketchat
restart: unless-stopped restart: unless-stopped
ports: ports:
- "14000:4000" - "13000:3000"
environment: environment:
- DATABASE_URL=postgresql://tobiichi3227:${DB_PASSWORD}@postgres:5432/papercups MONGO_URL: mongodb://mongo:27017/rocketchat?replicaSet=rs0
- SECRET_KEY_BASE=${PAPERCUPS_SECRET_KEY} MONGO_OPLOG_URL: mongodb://mongo:27017/local?replicaSet=rs0
- BACKEND_URL=http://localhost:14000 ROOT_URL: http://localhost:13000
- MIX_ENV=prod PORT: 3000
DEPLOY_METHOD: docker
networks: networks:
- tobiichiGPT-network - tobiichiGPT-network
depends_on: depends_on:
postgres: mongo:
condition: service_healthy condition: service_healthy
mongo-init-replica:
condition: service_completed_successfully
networks: networks:
tobiichiGPT-network: tobiichiGPT-network:

112
exchange.md Normal file
View File

@@ -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實作紀錄