Compare commits
5 Commits
a8f57ec60b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f105a7e748 | ||
|
|
44ba70efe6 | ||
|
|
809a65056d | ||
|
|
f1761cb25d | ||
|
|
13c8f8aa3c |
@@ -1,5 +1,6 @@
|
||||
# PostgreSQL 密碼
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
|
||||
# Chatwoot Secret Key (使用以下命令生成: openssl rand -hex 64)
|
||||
CHATWOOT_SECRET_KEY=your_chatwoot_secret_key_here
|
||||
# Rocket.Chat 管理員帳號(首次啟動時設定)
|
||||
ROCKETCHAT_USER=admin
|
||||
ROCKETCHAT_PASSWORD=your_rocketchat_password_here
|
||||
|
||||
201
INTRO.md
Normal file
201
INTRO.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 功能介紹
|
||||
|
||||
## 核心概念
|
||||
|
||||
TobiichiGPT 將 Open WebUI 這類 AI 對話系統的後端改造成由真人管理員回覆的系統。
|
||||
|
||||
### 基本原理
|
||||
|
||||
1. **API 中間層** - 提供 OpenAI API 相容端點(`/v1/chat/completions`)
|
||||
2. **訊息轉發** - 將用戶訊息轉發到 Rocket.Chat 頻道
|
||||
3. **管理員介面** - 使用 Rocket.Chat 讓真人管理員查看並回覆
|
||||
4. **輪詢機制** - API 使用時間戳比較等待管理員在 Rocket.Chat 中回覆
|
||||
|
||||
### 技術架構
|
||||
|
||||
```
|
||||
用戶 → Open WebUI → API (FastAPI) → Rocket.Chat ← 管理員
|
||||
↓ ↓
|
||||
等待回覆 管理員查看訊息
|
||||
↓ ↓
|
||||
輪詢檢查 管理員回覆訊息
|
||||
↓ ↓
|
||||
返回給用戶 ←─────────┘
|
||||
|
||||
(PostgreSQL 用於記錄追蹤)
|
||||
```
|
||||
|
||||
## 需求分析
|
||||
|
||||
### Open WebUI 的對話結構
|
||||
|
||||
Open WebUI 採用**雙層結構**來組織對話:
|
||||
|
||||
```
|
||||
用戶 A (user_id: abc123)
|
||||
├── 對話 1 (chat_id: chat_001) - "教我 Python"
|
||||
├── 對話 2 (chat_id: chat_002) - "推薦餐廳"
|
||||
└── 對話 3 (chat_id: chat_003) - "旅遊攻略"
|
||||
|
||||
用戶 B (user_id: def456)
|
||||
├── 對話 1 (chat_id: chat_004) - "程式問題"
|
||||
└── 對話 2 (chat_id: chat_005) - "健康諮詢"
|
||||
```
|
||||
|
||||
**關鍵特性**:
|
||||
- 一個用戶(user_id)可以創建多個對話(chat_id)
|
||||
- 每個對話有獨立的主題和訊息串
|
||||
- 管理員需要能夠明確追蹤「哪個用戶的哪個對話」
|
||||
|
||||
### 管理後台需求
|
||||
|
||||
理想的管理介面應該呈現:
|
||||
|
||||
```
|
||||
📂 所有對話(每個對話獨立頻道)
|
||||
├── 💬 張三-chat001 (教我 Python)
|
||||
│ ├── 💬 張三: "Python 怎麼學?"
|
||||
│ └── 💬 管理員: "建議從基礎開始..."
|
||||
│
|
||||
├── 💬 張三-chat002 (推薦餐廳)
|
||||
│ ├── 💬 張三: "台北有什麼好吃的?"
|
||||
│ └── 💬 管理員: "推薦鼎泰豐..."
|
||||
│
|
||||
└── 💬 李四-chat004 (程式問題) ⚠️ 待回覆
|
||||
└── 💬 李四: "React Hook 怎麼用?"
|
||||
```
|
||||
|
||||
**核心需求**:
|
||||
- ✅ 對話隔離 - 每個對話一個獨立頻道
|
||||
- ✅ 用戶識別 - 頻道名稱包含用戶名和對話 ID
|
||||
- ✅ 簡單直觀 - 不使用 Thread,直接在頻道內問答
|
||||
- ✅ 狀態管理 - 清楚標示哪些對話待回覆
|
||||
|
||||
## 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_name}-{chat_id[:8]}) │
|
||||
│ 5. 發送用戶訊息到頻道,記錄時間戳 │
|
||||
│ 6. 輪詢頻道訊息,用時間戳比較等待管理員回覆 │
|
||||
│ 7. 將管理員回覆返回給 Open WebUI │
|
||||
└──────┬──────────────────────────┬───────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌─────────────────────────┐
|
||||
│ PostgreSQL │ │ Rocket.Chat + MongoDB │
|
||||
│ reply_queue │ ├─────────────────────────┤
|
||||
│ (記錄追蹤) │ │ 頻道: ckliu-68a77282 │
|
||||
└─────────────┘ │ ├── 💬 ckliu: 測試訊息│
|
||||
│ └── 💬 管理員: ... │
|
||||
│ │
|
||||
│ 頻道: ckliu-a8224bcf │
|
||||
│ ├── 💬 ckliu: 測試訊息 │
|
||||
│ └── 💬 管理員: ... │
|
||||
└─────────────────────────┘
|
||||
▲
|
||||
│ 8. 管理員直接在頻道回覆
|
||||
│
|
||||
┌─────┴──────┐
|
||||
│ 管理員瀏覽器│
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
### 資料映射關係
|
||||
|
||||
| Open WebUI | Rocket.Chat | 說明 |
|
||||
|-----------|-------------|------|
|
||||
| 對話 (chat_id) | 頻道 (Channel) | 每個對話一個專屬頻道 `{user_name}-{chat_id[:8]}` |
|
||||
| 訊息 (message) | 訊息 (Message) | 直接在頻道中發送,不使用 Thread |
|
||||
| 用戶 (user_name) | 頻道名稱前綴 | 用於識別是誰的對話 |
|
||||
|
||||
**設計理念**:
|
||||
- 簡化架構,不使用 Thread(執行緒)
|
||||
- 每個對話完全獨立,避免複雜的層級結構
|
||||
- 頻道名稱直接顯示用戶和對話 ID,方便識別
|
||||
|
||||
### Rocket.Chat 呈現效果
|
||||
|
||||
```
|
||||
Rocket.Chat 介面:
|
||||
├── 📂 頻道列表
|
||||
│ ├── ckliu-68a77282 (最近活躍)
|
||||
│ ├── ckliu-a8224bcf
|
||||
│ ├── alice-3f91c2e1
|
||||
│ └── bob-7d4e9a23
|
||||
│
|
||||
└── 📂 ckliu-68a77282 頻道內容
|
||||
├── 💬 **💬 ckliu:**
|
||||
│ NPU能吃嗎...
|
||||
├── 💬 管理員回覆:
|
||||
│ NPU 是神經處理單元,不能吃喔 😄
|
||||
├── 💬 **💬 ckliu:**
|
||||
│ 那有什麼用途?
|
||||
└── 💬 管理員回覆:
|
||||
主要用於 AI 運算加速...
|
||||
```
|
||||
|
||||
**特點**:
|
||||
- 每個頻道 = 一個完整對話
|
||||
- 訊息直接在頻道中,不用點開 Thread
|
||||
- 管理員看到新頻道就知道有新對話
|
||||
- 頻道名稱清楚標示用戶和對話 ID
|
||||
|
||||
### 資料庫結構
|
||||
|
||||
`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), -- 儲存頻道 ID
|
||||
rocketchat_thread_id VARCHAR(100) -- 保留欄位(目前未使用)
|
||||
);
|
||||
```
|
||||
|
||||
**說明**:
|
||||
- PostgreSQL 僅用於記錄追蹤,非主要邏輯
|
||||
- 實際對話內容和回覆檢測都在 Rocket.Chat 中進行
|
||||
- 使用時間戳比較來判斷是否有新的管理員回覆
|
||||
|
||||
### 服務列表
|
||||
|
||||
| 服務 | 容器名 | 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 對話對應一個 Rocket.Chat 頻道
|
||||
2. **簡化架構** - 不使用 Thread,減少複雜度和潛在問題
|
||||
3. **時間戳比較** - 使用 ISO 格式時間戳判斷新訊息,簡單可靠
|
||||
4. **即時通知** - Rocket.Chat 支援桌面和手機推送
|
||||
5. **輕量部署** - 共 5 個容器(含初始化容器)
|
||||
6. **可擴展性** - 支援多管理員協作回覆
|
||||
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)
|
||||
@@ -2,3 +2,4 @@ fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
pydantic==2.5.0
|
||||
asyncpg==0.29.0
|
||||
httpx==0.25.2
|
||||
|
||||
486
api/server.py
486
api/server.py
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
API 轉接層 - 偽裝 OpenAI API,將請求轉為人工回覆隊列
|
||||
API 轉接層 - 偽裝 OpenAI API,整合 Rocket.Chat 作為管理員回覆介面
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, Header
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
import asyncpg
|
||||
@@ -10,8 +10,12 @@ import asyncio
|
||||
import time
|
||||
import uuid
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@@ -27,6 +31,14 @@ DB_CONFIG = {
|
||||
"password": os.getenv("DB_PASSWORD", "tobiichi_password")
|
||||
}
|
||||
|
||||
# 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):
|
||||
role: str
|
||||
@@ -39,22 +51,290 @@ class ChatRequest(BaseModel):
|
||||
stream: Optional[bool] = False
|
||||
|
||||
|
||||
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_chat_channel(chat_id: str, user_name: str = None):
|
||||
"""為每個對話創建專屬頻道
|
||||
|
||||
新架構:每個 chat_id 對應一個 Channel,不使用 Thread
|
||||
"""
|
||||
if not rocketchat_auth:
|
||||
await rocketchat_login()
|
||||
|
||||
if not rocketchat_auth:
|
||||
return None
|
||||
|
||||
# 頻道名稱:用戶名-對話ID前8位
|
||||
display_name = user_name if user_name else "user"
|
||||
clean_name = display_name.replace(" ", "-").replace("@", "").lower()
|
||||
channel_name = f"{clean_name}-{chat_id[:8]}"[: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}")
|
||||
|
||||
# 設置頻道描述
|
||||
await client.post(
|
||||
f"{ROCKETCHAT_URL}/api/v1/channels.setDescription",
|
||||
headers=rocketchat_auth,
|
||||
json={
|
||||
"roomId": room_id,
|
||||
"description": f"用戶: {display_name} | 對話: {chat_id[:8]}"
|
||||
}
|
||||
)
|
||||
|
||||
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 send_user_message(room_id: str, user_message: str, user_name: str = None):
|
||||
"""發送用戶訊息到頻道(不使用 Thread)"""
|
||||
if not rocketchat_auth:
|
||||
await rocketchat_login()
|
||||
|
||||
if not rocketchat_auth:
|
||||
return None
|
||||
|
||||
display_name = user_name if user_name else "用戶"
|
||||
|
||||
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{user_message}"
|
||||
}
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
message_id = data["message"]["_id"]
|
||||
message_ts = data["message"]["ts"]
|
||||
print(f"✅ 發送用戶訊息: {message_id}")
|
||||
return {"message_id": message_id, "ts": message_ts}
|
||||
else:
|
||||
print(f"⚠️ 發送訊息失敗: {resp.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 發送訊息錯誤: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def wait_for_admin_reply(room_id: str, after_ts: str, exclude_msg_id: str = None, timeout: int = 600):
|
||||
"""等待管理員在頻道中的回覆
|
||||
|
||||
Args:
|
||||
room_id: 頻道 ID
|
||||
after_ts: 用戶訊息的時間戳 (ISO 格式)
|
||||
exclude_msg_id: 要排除的用戶訊息 ID (避免自己讀到自己)
|
||||
timeout: 超時秒數
|
||||
"""
|
||||
if not rocketchat_auth:
|
||||
await rocketchat_login()
|
||||
|
||||
if not rocketchat_auth:
|
||||
return None
|
||||
|
||||
start_time = time.time()
|
||||
check_interval = 2 # 每 2 秒檢查一次
|
||||
|
||||
# 取得機器人用戶 ID
|
||||
bot_user_id = rocketchat_auth.get("X-User-Id")
|
||||
|
||||
print(f"🔄 等待回覆 (room: {room_id[:8]}..., after: {after_ts}, exclude: {exclude_msg_id})")
|
||||
|
||||
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/channels.messages",
|
||||
headers=rocketchat_auth,
|
||||
params={
|
||||
"roomId": room_id,
|
||||
"count": 20 # 稍微增加數量以防萬一
|
||||
}
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
messages = data.get("messages", [])
|
||||
|
||||
found_new_reply = False
|
||||
|
||||
for msg in messages:
|
||||
msg_id = msg.get("_id")
|
||||
msg_ts = msg.get("ts", "")
|
||||
sender_id = msg.get("u", {}).get("_id", "")
|
||||
sender_name = msg.get("u", {}).get("username", "?")
|
||||
reply_text = msg.get("msg", "")
|
||||
msg_type = msg.get("t")
|
||||
|
||||
# 1. 基本過濾
|
||||
if msg_type: # 忽略系統訊息
|
||||
continue
|
||||
|
||||
if msg_id == exclude_msg_id: # 忽略剛發送的用戶訊息
|
||||
continue
|
||||
|
||||
# 2. 時間戳檢查 (放寬條件: 只要 >= 就考慮,因為可能有精度差)
|
||||
# 但為了避免讀到舊訊息,我們仍需確保它"看起來"是新的
|
||||
if msg_ts < after_ts:
|
||||
continue
|
||||
|
||||
# 3. 機器人自我發送過濾
|
||||
if sender_id == bot_user_id:
|
||||
# 這是最關鍵的部分: 如何區分 "API轉發的用戶訊息" 與 "Admin用同一帳號的回覆"
|
||||
|
||||
# A. 如果這就是我們要排除的 ID (前面已處理)
|
||||
|
||||
# B. 檢查內容格式
|
||||
# 格式為: "**💬 {display_name}:**\n{user_message}"
|
||||
# 我們檢查幾個特徵: 開頭是 **💬, 包含 :**
|
||||
is_forwarded_msg = False
|
||||
if reply_text.strip().startswith("**💬") and ":**" in reply_text:
|
||||
is_forwarded_msg = True
|
||||
|
||||
if is_forwarded_msg:
|
||||
# 這是轉發的訊息,跳過
|
||||
continue
|
||||
|
||||
# 如果不是轉發格式,那我們假設這是 Admin 的手動回覆
|
||||
pass
|
||||
|
||||
# 4. 找到有效回覆
|
||||
if reply_text:
|
||||
print(f"✅ 收到管理員回覆 (from {sender_name}, id: {msg_id}): {reply_text[:50]}...")
|
||||
return reply_text
|
||||
|
||||
# 沒找到
|
||||
elapsed = int(time.time() - start_time)
|
||||
if elapsed % 10 == 0:
|
||||
print(f"🔄 等待中... ({elapsed}s)")
|
||||
|
||||
else:
|
||||
print(f"⚠️ API 錯誤: {resp.status_code}")
|
||||
|
||||
await asyncio.sleep(check_interval)
|
||||
|
||||
print(f"⚠️ 等待回覆超時")
|
||||
return "抱歉,目前客服繁忙,請稍後再試。"
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 檢查回覆錯誤: {e}")
|
||||
return "系統錯誤,請稍後再試。"
|
||||
|
||||
|
||||
async def get_user_name(user_id: str):
|
||||
"""從資料庫獲取用戶真實姓名(保留作為工具函數)"""
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
'SELECT name, email FROM "user" WHERE id = $1',
|
||||
user_id
|
||||
)
|
||||
if row:
|
||||
name = row['name'] or row['email']
|
||||
if name:
|
||||
return name
|
||||
except Exception as e:
|
||||
print(f"⚠️ 獲取用戶名稱失敗: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
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 (
|
||||
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
|
||||
replied_at TIMESTAMP,
|
||||
rocketchat_room_id VARCHAR(100),
|
||||
rocketchat_thread_id VARCHAR(100)
|
||||
)
|
||||
""")
|
||||
|
||||
@@ -62,6 +342,8 @@ async def init_db():
|
||||
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);
|
||||
""")
|
||||
|
||||
|
||||
@@ -69,6 +351,9 @@ async def init_db():
|
||||
async def startup():
|
||||
await init_db()
|
||||
print("✅ 資料庫連接成功")
|
||||
|
||||
# 嘗試登入 Rocket.Chat
|
||||
await rocketchat_login()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
@@ -81,7 +366,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")
|
||||
@@ -91,12 +380,12 @@ async def list_models():
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "human-admin",
|
||||
"id": "tobiichiGPT",
|
||||
"object": "model",
|
||||
"created": int(time.time()),
|
||||
"owned_by": "tobiichi",
|
||||
"permission": [],
|
||||
"root": "human-admin",
|
||||
"root": "tobiichiGPT",
|
||||
"parent": None
|
||||
}
|
||||
]
|
||||
@@ -104,14 +393,18 @@ async def list_models():
|
||||
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
async def chat_completions(request: ChatRequest):
|
||||
async def chat_completions(
|
||||
request_data: ChatRequest,
|
||||
http_request: Request,
|
||||
authorization: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
模擬 OpenAI Chat Completions API
|
||||
將用戶訊息寫入資料庫,等待管理員回覆
|
||||
將用戶訊息轉發到 Rocket.Chat,等待管理員回覆
|
||||
"""
|
||||
# 取得最後一則用戶訊息
|
||||
user_message = None
|
||||
for msg in reversed(request.messages):
|
||||
for msg in reversed(request_data.messages):
|
||||
if msg.role == "user":
|
||||
user_message = msg.content
|
||||
break
|
||||
@@ -122,83 +415,150 @@ async def chat_completions(request: ChatRequest):
|
||||
content={"error": "No user message found"}
|
||||
)
|
||||
|
||||
# 生成對話 ID
|
||||
conversation_id = str(uuid.uuid4())
|
||||
# 從 Open WebUI headers 提取用戶資訊
|
||||
headers_dict = dict(http_request.headers)
|
||||
|
||||
# 寫入資料庫
|
||||
# 調試:輸出所有 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}")
|
||||
|
||||
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())
|
||||
|
||||
# 如果還是沒有,使用 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 = message_id
|
||||
|
||||
# 過濾 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"📝 收到訊息")
|
||||
print(f" 用戶: {user_name} ({user_id[:8]}...)")
|
||||
print(f" 對話: {chat_id[:8]}")
|
||||
print(f" 內容: {user_message[:50]}...")
|
||||
|
||||
# 1. 為此對話取得或創建專屬頻道(每個 chat_id = 一個 Channel)
|
||||
room_id = await get_or_create_chat_channel(chat_id, user_name)
|
||||
|
||||
if not room_id:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "Failed to create Rocket.Chat channel"}
|
||||
)
|
||||
|
||||
# 2. 發送用戶訊息到頻道
|
||||
msg_result = await send_user_message(room_id, user_message, user_name)
|
||||
|
||||
if not msg_result:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "Failed to send message to Rocket.Chat"}
|
||||
)
|
||||
|
||||
user_message_ts = msg_result["ts"] # 用時間戳比較,更可靠
|
||||
|
||||
# 3. 記錄到資料庫
|
||||
async with db_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO reply_queue (conversation_id, user_message, status)
|
||||
VALUES ($1, $2, 'pending')
|
||||
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)
|
||||
""",
|
||||
conversation_id, user_message
|
||||
message_id, user_id, chat_id, user_name, user_message, room_id, msg_result["message_id"]
|
||||
)
|
||||
|
||||
print(f"📝 收到訊息 [{conversation_id}]: {user_message[:50]}...")
|
||||
# 4. 等待管理員在頻道中回覆(使用時間戳比較)
|
||||
admin_reply = await wait_for_admin_reply(room_id, user_message_ts, exclude_msg_id=msg_result["message_id"])
|
||||
|
||||
# 等待管理員回覆 (最多 15 分鐘)
|
||||
max_wait = 900 # 15 分鐘
|
||||
check_interval = 3 # 每 3 秒檢查一次
|
||||
waited = 0
|
||||
# 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 waited < max_wait:
|
||||
await asyncio.sleep(check_interval)
|
||||
waited += check_interval
|
||||
|
||||
async with db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT admin_reply, status FROM reply_queue WHERE conversation_id = $1",
|
||||
conversation_id
|
||||
)
|
||||
|
||||
if row and row['status'] == 'replied' and row['admin_reply']:
|
||||
admin_reply = row['admin_reply']
|
||||
print(f"✅ 管理員已回覆 [{conversation_id}]")
|
||||
|
||||
# 回傳 OpenAI 格式的回應
|
||||
return {
|
||||
"id": f"chatcmpl-{conversation_id}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": request.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]}]")
|
||||
|
||||
# 超時回應
|
||||
print(f"⏰ 等待超時 [{conversation_id}]")
|
||||
# 6. 回傳 OpenAI 格式的回應
|
||||
return {
|
||||
"id": f"chatcmpl-{conversation_id}",
|
||||
"id": f"chatcmpl-{message_id}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": request.model,
|
||||
"model": request_data.model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "抱歉,管理員目前忙碌中,請稍後再試。"
|
||||
"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.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)
|
||||
|
||||
|
||||
@@ -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,6 +70,9 @@ services:
|
||||
- DB_NAME=tobiichiGPT
|
||||
- DB_USER=tobiichi3227
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- ROCKETCHAT_URL=http://rocketchat:3000
|
||||
- ROCKETCHAT_USER=${ROCKETCHAT_USER}
|
||||
- ROCKETCHAT_PASSWORD=${ROCKETCHAT_PASSWORD}
|
||||
volumes:
|
||||
- /mnt/data/External/tobiichiGPT/api_data:/app
|
||||
working_dir: /app
|
||||
@@ -46,6 +86,8 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rocketchat:
|
||||
condition: service_started
|
||||
|
||||
# Open WebUI - 用戶對話介面
|
||||
openwebui:
|
||||
@@ -57,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:
|
||||
@@ -65,53 +108,28 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# Redis - Chatwoot 依賴
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: tobiichiGPT-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- tobiichiGPT-network
|
||||
|
||||
# Chatwoot - 管理員對話介面
|
||||
chatwoot:
|
||||
image: chatwoot/chatwoot:latest
|
||||
container_name: tobiichiGPT-chatwoot
|
||||
# Rocket.Chat - 管理員對話介面
|
||||
rocketchat:
|
||||
image: registry.rocket.chat/rocketchat/rocket.chat:latest
|
||||
container_name: tobiichiGPT-rocketchat
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "13000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_DATABASE=chatwoot
|
||||
- POSTGRES_USERNAME=tobiichi3227
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- SECRET_KEY_BASE=${CHATWOOT_SECRET_KEY}
|
||||
- INSTALLATION_NAME=TobiichiGPT
|
||||
- FORCE_SSL=false
|
||||
- RAILS_LOG_TO_STDOUT=true
|
||||
volumes:
|
||||
- chatwoot-data:/app/storage
|
||||
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
|
||||
- redis
|
||||
command: >
|
||||
sh -c "
|
||||
bundle exec rails db:chatwoot_prepare &&
|
||||
bundle exec rails s -b 0.0.0.0 -p 3000
|
||||
"
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
mongo-init-replica:
|
||||
condition: service_completed_successfully
|
||||
|
||||
networks:
|
||||
tobiichiGPT-network:
|
||||
driver: bridge
|
||||
name: tobiichiGPT-network # 固定網路名稱,讓 proxy stack 可以連接
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
chatwoot-data:
|
||||
|
||||
290
exchange.md
Normal file
290
exchange.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# 工程師交接說明
|
||||
|
||||
## 後臺方案更換建議: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 實作紀錄
|
||||
|
||||
### 實作日期
|
||||
2026年2月1日
|
||||
|
||||
### 系統架構
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Open WebUI │────▶│ API 轉接層 │────▶│ Rocket.Chat │
|
||||
│ (Port 10060) │ │ (Port 18000) │ │ (Port 13000) │
|
||||
│ 用戶前端 │◀────│ FastAPI │◀────│ 管理員後台 │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌─────────────────┐
|
||||
│ PostgreSQL │ │ MongoDB │
|
||||
│ (Port 5432) │ │ (Port 27017) │
|
||||
└──────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. **用戶發送訊息** → Open WebUI 呼叫 `/v1/chat/completions`
|
||||
2. **API 轉接層接收** → 提取用戶資訊 (user_name, chat_id)
|
||||
3. **創建 Rocket.Chat 頻道** → 頻道名稱: `{用戶名}-{對話ID前8位}`
|
||||
4. **發送用戶訊息** → 顯示格式: `💬 用戶名: 訊息內容`
|
||||
5. **輪詢等待回覆** → 每 2 秒檢查是否有管理員新訊息
|
||||
6. **回傳給用戶** → 以 OpenAI 格式回傳管理員的回覆
|
||||
|
||||
### 已完成的修改
|
||||
|
||||
#### 1. docker-compose.yml
|
||||
|
||||
**新增服務:**
|
||||
- `mongo`: MongoDB 7.0 (Rocket.Chat 8.0.1 需要 7.0+)
|
||||
- `mongo-init-replica`: 初始化 MongoDB Replica Set
|
||||
- `rocketchat`: Rocket.Chat 8.0.1
|
||||
|
||||
**修改服務:**
|
||||
- `openwebui`: 新增 `ENABLE_FORWARD_USER_INFO_HEADERS=True` 環境變數
|
||||
|
||||
**關鍵配置:**
|
||||
```yaml
|
||||
mongo:
|
||||
image: mongo:7.0 # 從 5.0 升級,因為 Rocket.Chat 8.0.1 不支援 5.0
|
||||
command: mongod --oplogSize 128 --replSet rs0
|
||||
|
||||
rocketchat:
|
||||
image: registry.rocket.chat/rocketchat/rocket.chat:latest
|
||||
ports:
|
||||
- "13000:3000"
|
||||
environment:
|
||||
MONGO_URL: mongodb://mongo:27017/rocketchat?replicaSet=rs0
|
||||
MONGO_OPLOG_URL: mongodb://mongo:27017/local?replicaSet=rs0
|
||||
|
||||
openwebui:
|
||||
environment:
|
||||
- ENABLE_FORWARD_USER_INFO_HEADERS=True # 轉發用戶資訊到 API
|
||||
```
|
||||
|
||||
#### 2. api/server.py
|
||||
|
||||
**新增 imports:**
|
||||
```python
|
||||
import json
|
||||
import hashlib
|
||||
```
|
||||
|
||||
**核心函數重寫:**
|
||||
|
||||
| 函數名稱 | 功能說明 |
|
||||
|---------|---------|
|
||||
| `rocketchat_login()` | 使用帳密登入 Rocket.Chat,取得 Auth Token |
|
||||
| `get_or_create_chat_channel()` | 為每個對話創建專屬頻道 (不使用 Thread) |
|
||||
| `send_user_message()` | 發送用戶訊息到頻道,回傳訊息 ID |
|
||||
| `wait_for_admin_reply()` | 輪詢等待管理員回覆,過濾機器人訊息 |
|
||||
|
||||
**Headers 提取邏輯:**
|
||||
```python
|
||||
# Open WebUI 轉發的 Headers
|
||||
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")
|
||||
```
|
||||
|
||||
**系統任務過濾:**
|
||||
```python
|
||||
# 跳過 Open WebUI 的標題/標籤/後續問題生成請求
|
||||
if user_message.strip().startswith("### Task:"):
|
||||
return {"choices": [{"message": {"content": "{}"}}], ...}
|
||||
```
|
||||
|
||||
### 遇到的問題與解決方案
|
||||
|
||||
#### 問題 1: MongoDB 版本不相容
|
||||
- **錯誤**: `MongoServerSelectionError`
|
||||
- **原因**: Rocket.Chat 8.0.1 需要 MongoDB 7.0+,但配置的是 5.0
|
||||
- **解決**: 將 `mongo:5.0` 升級為 `mongo:7.0`
|
||||
|
||||
#### 問題 2: postMessage 返回 400 錯誤
|
||||
- **錯誤**: `alias` 參數需要特殊權限
|
||||
- **原因**: Rocket.Chat API 的 `alias` 欄位需要 bot 權限
|
||||
- **解決**: 移除 `alias` 參數,改用訊息內文標註用戶名
|
||||
|
||||
#### 問題 3: Open WebUI 不發送用戶資訊 Headers
|
||||
- **錯誤**: `user_name` 始終為 `None`
|
||||
- **原因**: Open WebUI 預設不轉發用戶資訊
|
||||
- **解決**: 設置環境變數 `ENABLE_FORWARD_USER_INFO_HEADERS=True`
|
||||
|
||||
#### 問題 4: 奇怪的系統訊息被發送到 Rocket.Chat
|
||||
- **錯誤**: `### Task: Generate a concise title...` 等訊息出現
|
||||
- **原因**: Open WebUI 會額外發送標題/標籤/後續問題生成請求
|
||||
- **解決**: 過濾以 `### Task:` 開頭的訊息,直接回傳空 JSON
|
||||
|
||||
#### 問題 5: 重複回傳相同的管理員回覆
|
||||
- **錯誤**: 每次用戶訊息都收到同一個回覆
|
||||
- **原因**: Thread 架構複雜,難以正確追蹤新回覆
|
||||
- **解決**: 改用簡化架構 (每個對話一個 Channel,不用 Thread)
|
||||
|
||||
### 最終架構決策
|
||||
|
||||
**放棄 Thread 架構,改用 Channel 架構:**
|
||||
|
||||
| 項目 | Thread 架構 (放棄) | Channel 架構 (採用) |
|
||||
|-----|-------------------|-------------------|
|
||||
| 結構 | 用戶→頻道→多個Thread | 對話→專屬頻道 |
|
||||
| 追蹤 | 需要追蹤 Thread ID | 只需追蹤訊息 ID |
|
||||
| 複雜度 | 高 | 低 |
|
||||
| 可讀性 | 中 (Thread 內對話) | 高 (直接看頻道) |
|
||||
|
||||
**頻道命名規則:**
|
||||
```
|
||||
{用戶名小寫}-{chat_id前8位}
|
||||
例: ckliu-67b4341e
|
||||
```
|
||||
|
||||
### 待辦事項
|
||||
|
||||
- [ ] 實作管理員回覆後的通知機制
|
||||
- [ ] 處理超時後的重試邏輯
|
||||
- [ ] 新增管理員身份驗證 (目前使用單一 admin 帳號)
|
||||
- [ ] 考慮切換到 Streamlit 方案 (更輕量、完全客製化)
|
||||
|
||||
### 相關檔案
|
||||
|
||||
```
|
||||
tobiichiGPT/
|
||||
├── docker-compose.yml # 容器編排配置
|
||||
├── api/
|
||||
│ ├── server.py # API 轉接層主程式
|
||||
│ └── requirements.txt # Python 依賴
|
||||
└── exchange.md # 本文件
|
||||
```
|
||||
|
||||
### 環境變數清單
|
||||
|
||||
| 變數名稱 | 用途 | 預設值 |
|
||||
|---------|------|-------|
|
||||
| `ROCKETCHAT_URL` | Rocket.Chat API 位址 | `http://rocketchat:3000` |
|
||||
| `ROCKETCHAT_USER` | 登入帳號 | `admin` |
|
||||
| `ROCKETCHAT_PASSWORD` | 登入密碼 | `admin` |
|
||||
| `DB_HOST` | PostgreSQL 主機 | `postgres` |
|
||||
| `DB_PASSWORD` | 資料庫密碼 | (必填) |
|
||||
|
||||
### 測試指令
|
||||
|
||||
```bash
|
||||
# 重建並重啟 API 容器
|
||||
cd /mnt/data/External/tobiichiGPT
|
||||
sudo docker-compose up -d --build api
|
||||
|
||||
# 查看 API 日誌
|
||||
sudo docker logs tobiichiGPT-api --tail 100
|
||||
|
||||
# 重啟 Open WebUI (套用 Headers 設定)
|
||||
sudo docker-compose up -d openwebui
|
||||
```
|
||||
6
test.sh
6
test.sh
@@ -64,8 +64,8 @@ fi
|
||||
# ==================== 測試 3: API Models 端點 ====================
|
||||
print_test "測試 API Models 端點"
|
||||
MODELS_RESPONSE=$(curl -s http://localhost:18000/v1/models)
|
||||
if echo "$MODELS_RESPONSE" | grep -q "human-admin"; then
|
||||
print_success "Models 端點返回 human-admin 模型"
|
||||
if echo "$MODELS_RESPONSE" | grep -q "tobiichiGPT"; then
|
||||
print_success "Models 端點返回 tobiichiGPT 模型"
|
||||
else
|
||||
print_error "Models 端點測試失敗"
|
||||
fi
|
||||
@@ -104,7 +104,7 @@ echo " → 發送測試訊息到 API..."
|
||||
curl -X POST http://localhost:18000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "human-admin",
|
||||
"model": "tobiichiGPT",
|
||||
"messages": [
|
||||
{"role": "user", "content": "自動化測試訊息"}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user