Change platform to rocket.chat
This commit is contained in:
@@ -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
112
INTRO.md
@@ -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
331
SETUP_GUIDE.md
Normal 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)
|
||||||
666
api/server.py
666
api/server.py
@@ -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);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
@@ -192,6 +395,9 @@ async def init_db():
|
|||||||
async def startup():
|
async def startup():
|
||||||
await init_db()
|
await init_db()
|
||||||
print("✅ 資料庫連接成功")
|
print("✅ 資料庫連接成功")
|
||||||
|
|
||||||
|
# 嘗試登入 Rocket.Chat
|
||||||
|
await rocketchat_login()
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("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)
|
|
||||||
|
# 6. 回傳 OpenAI 格式的回應
|
||||||
async with db_pool.acquire() as conn:
|
return {
|
||||||
row = await conn.fetchrow(
|
"id": f"chatcmpl-{message_id}",
|
||||||
"SELECT admin_reply, status FROM reply_queue WHERE conversation_id = $1",
|
"object": "chat.completion",
|
||||||
message_id
|
"created": int(time.time()),
|
||||||
)
|
"model": request_data.model,
|
||||||
|
"choices": [
|
||||||
if row and row['status'] == 'replied' and row['admin_reply']:
|
{
|
||||||
admin_reply = row['admin_reply']
|
"index": 0,
|
||||||
print(f"✅ 管理員已回覆 [chat:{chat_id}]")
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
# 回傳 OpenAI 格式的回應
|
"content": admin_reply
|
||||||
return {
|
},
|
||||||
"id": f"chatcmpl-{message_id}",
|
"finish_reason": "stop"
|
||||||
"object": "chat.completion",
|
}
|
||||||
"created": int(time.time()),
|
],
|
||||||
"model": request_data.model,
|
"usage": {
|
||||||
"choices": [
|
"prompt_tokens": len(user_message),
|
||||||
{
|
"completion_tokens": len(admin_reply),
|
||||||
"index": 0,
|
"total_tokens": len(user_message) + len(admin_reply)
|
||||||
"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")
|
@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 {
|
||||||
# 只處理訊息建立事件
|
"status": "healthy",
|
||||||
if event != "message:created":
|
"database": db_status,
|
||||||
return {"status": "ignored", "event": event}
|
"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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
112
exchange.md
Normal 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實作紀錄
|
||||||
Reference in New Issue
Block a user