Change rocket.chat admin logic
This commit is contained in:
133
INTRO.md
133
INTRO.md
@@ -7,18 +7,22 @@ TobiichiGPT 將 Open WebUI 這類 AI 對話系統的後端改造成由真人管
|
||||
### 基本原理
|
||||
|
||||
1. **API 中間層** - 提供 OpenAI API 相容端點(`/v1/chat/completions`)
|
||||
2. **訊息轉發** - 將用戶訊息存入資料庫的 `reply_queue` 表格
|
||||
3. **管理員介面** - 使用對話軟體讓真人管理員查看並回覆
|
||||
4. **輪詢機制** - API 等待管理員在資料庫中填入回覆後返回給用戶
|
||||
2. **訊息轉發** - 將用戶訊息轉發到 Rocket.Chat 頻道
|
||||
3. **管理員介面** - 使用 Rocket.Chat 讓真人管理員查看並回覆
|
||||
4. **輪詢機制** - API 使用時間戳比較等待管理員在 Rocket.Chat 中回覆
|
||||
|
||||
### 技術架構
|
||||
|
||||
```
|
||||
用戶 → Open WebUI → API (FastAPI) → PostgreSQL ← 管理介面 ← 管理員
|
||||
↓ ↓
|
||||
等待回覆 填寫回覆
|
||||
↓ ↓
|
||||
返回給用戶 ←──────────────────────────┘
|
||||
用戶 → Open WebUI → API (FastAPI) → Rocket.Chat ← 管理員
|
||||
↓ ↓
|
||||
等待回覆 管理員查看訊息
|
||||
↓ ↓
|
||||
輪詢檢查 管理員回覆訊息
|
||||
↓ ↓
|
||||
返回給用戶 ←─────────┘
|
||||
|
||||
(PostgreSQL 用於記錄追蹤)
|
||||
```
|
||||
|
||||
## 需求分析
|
||||
@@ -48,21 +52,23 @@ Open WebUI 採用**雙層結構**來組織對話:
|
||||
理想的管理介面應該呈現:
|
||||
|
||||
```
|
||||
📂 所有對話(按用戶分組)
|
||||
├── 👤 張三 (User A)
|
||||
│ ├── 💬 對話: "教我 Python" (3 則訊息)
|
||||
│ ├── 💬 對話: "推薦餐廳" (5 則訊息)
|
||||
│ └── 💬 對話: "旅遊攻略" (2 則訊息)
|
||||
📂 所有對話(每個對話獨立頻道)
|
||||
├── 💬 張三-chat001 (教我 Python)
|
||||
│ ├── 💬 張三: "Python 怎麼學?"
|
||||
│ └── 💬 管理員: "建議從基礎開始..."
|
||||
│
|
||||
└── 👤 李四 (User B)
|
||||
├── 💬 對話: "程式問題" (1 則訊息) ⚠️ 待回覆
|
||||
└── 💬 對話: "健康諮詢" (4 則訊息)
|
||||
├── 💬 張三-chat002 (推薦餐廳)
|
||||
│ ├── 💬 張三: "台北有什麼好吃的?"
|
||||
│ └── 💬 管理員: "推薦鼎泰豐..."
|
||||
│
|
||||
└── 💬 李四-chat004 (程式問題) ⚠️ 待回覆
|
||||
└── 💬 李四: "React Hook 怎麼用?"
|
||||
```
|
||||
|
||||
**核心需求**:
|
||||
- ✅ 用戶分組 - 能看到每個用戶的所有對話
|
||||
- ✅ 對話追蹤 - 同一對話的多輪訊息要串在一起
|
||||
- ✅ 多層結構 - 支援「用戶 → 對話 → 訊息」的層級關係
|
||||
- ✅ 對話隔離 - 每個對話一個獨立頻道
|
||||
- ✅ 用戶識別 - 頻道名稱包含用戶名和對話 ID
|
||||
- ✅ 簡單直觀 - 不使用 Thread,直接在頻道內問答
|
||||
- ✅ 狀態管理 - 清楚標示哪些對話待回覆
|
||||
|
||||
## Rocket.Chat 整合架構
|
||||
@@ -83,23 +89,26 @@ Open WebUI 採用**雙層結構**來組織對話:
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 2. 解析 Headers 提取 user_id, chat_id, user_name │
|
||||
│ 3. 登入 Rocket.Chat 取得認證 Token │
|
||||
│ 4. 創建/取得用戶頻道 (#user-{user_id}) │
|
||||
│ 5. 在頻道中創建執行緒訊息 │
|
||||
│ 6. 輪詢等待管理員在執行緒中回覆 │
|
||||
│ 7. 將回覆返回給 Open WebUI │
|
||||
│ 4. 創建/取得對話頻道 ({user_name}-{chat_id[:8]}) │
|
||||
│ 5. 發送用戶訊息到頻道,記錄時間戳 │
|
||||
│ 6. 輪詢頻道訊息,用時間戳比較等待管理員回覆 │
|
||||
│ 7. 將管理員回覆返回給 Open WebUI │
|
||||
└──────┬──────────────────────────┬───────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌─────────────────────────┐
|
||||
│ PostgreSQL │ │ Rocket.Chat + MongoDB │
|
||||
│ reply_queue │ ├─────────────────────────┤
|
||||
└─────────────┘ │ 頻道: #user-abc123 │
|
||||
│ └── 🧵 chat-001 │
|
||||
│ ├── 用戶訊息 │
|
||||
│ └── 管理員回覆 │
|
||||
│ (記錄追蹤) │ │ 頻道: ckliu-68a77282 │
|
||||
└─────────────┘ │ ├── 💬 ckliu: 測試訊息│
|
||||
│ └── 💬 管理員: ... │
|
||||
│ │
|
||||
│ 頻道: ckliu-a8224bcf │
|
||||
│ ├── 💬 ckliu: 測試訊息 │
|
||||
│ └── 💬 管理員: ... │
|
||||
└─────────────────────────┘
|
||||
▲
|
||||
│ 8. 管理員回覆
|
||||
│ 8. 管理員直接在頻道回覆
|
||||
│
|
||||
┌─────┴──────┐
|
||||
│ 管理員瀏覽器│
|
||||
@@ -110,37 +119,45 @@ Open WebUI 採用**雙層結構**來組織對話:
|
||||
|
||||
| Open WebUI | Rocket.Chat | 說明 |
|
||||
|-----------|-------------|------|
|
||||
| 用戶 (user_id) | 頻道 (Channel) | 每個用戶一個專屬頻道 `#user-{id}` |
|
||||
| 對話 (chat_id) | 執行緒 (Thread) | 同一對話的多輪訊息在同一執行緒 |
|
||||
| 訊息 (message) | 訊息 (Message) | 用戶和管理員的對話內容 |
|
||||
| 對話 (chat_id) | 頻道 (Channel) | 每個對話一個專屬頻道 `{user_name}-{chat_id[:8]}` |
|
||||
| 訊息 (message) | 訊息 (Message) | 直接在頻道中發送,不使用 Thread |
|
||||
| 用戶 (user_name) | 頻道名稱前綴 | 用於識別是誰的對話 |
|
||||
|
||||
**設計理念**:
|
||||
- 簡化架構,不使用 Thread(執行緒)
|
||||
- 每個對話完全獨立,避免複雜的層級結構
|
||||
- 頻道名稱直接顯示用戶和對話 ID,方便識別
|
||||
|
||||
### Rocket.Chat 呈現效果
|
||||
|
||||
```
|
||||
Rocket.Chat 介面:
|
||||
├── 📂 頻道列表
|
||||
│ ├── #user-abc123 (張三)
|
||||
│ │ └── 描述: 用戶: 張三 (ID: abc123...)
|
||||
│ ├── #user-def456 (李四)
|
||||
│ │ └── 描述: 用戶: 李四 (ID: def456...)
|
||||
│ └── #user-xyz789 (王五)
|
||||
│ ├── ckliu-68a77282 (最近活躍)
|
||||
│ ├── ckliu-a8224bcf
|
||||
│ ├── alice-3f91c2e1
|
||||
│ └── bob-7d4e9a23
|
||||
│
|
||||
└── 📂 #user-abc123 (張三) 的內容
|
||||
├── 🧵 [對話 chat-001] "如何學習 Python?"
|
||||
│ ├── 💬 張三: "如何學習 Python?"
|
||||
│ └── 💬 管理員: "推薦從基礎開始..."
|
||||
│
|
||||
├── 🧵 [對話 chat-002] "推薦餐廳"
|
||||
│ ├── 💬 張三: "台北有什麼好吃的?"
|
||||
│ └── 💬 管理員: "推薦鼎泰豐..."
|
||||
│
|
||||
└── 🧵 [對話 chat-003] ⚠️ 待回覆
|
||||
└── 💬 張三: "旅遊攻略"
|
||||
└── 📂 ckliu-68a77282 頻道內容
|
||||
├── 💬 **💬 ckliu:**
|
||||
│ NPU能吃嗎...
|
||||
├── 💬 管理員回覆:
|
||||
│ NPU 是神經處理單元,不能吃喔 😄
|
||||
├── 💬 **💬 ckliu:**
|
||||
│ 那有什麼用途?
|
||||
└── 💬 管理員回覆:
|
||||
主要用於 AI 運算加速...
|
||||
```
|
||||
|
||||
**特點**:
|
||||
- 每個頻道 = 一個完整對話
|
||||
- 訊息直接在頻道中,不用點開 Thread
|
||||
- 管理員看到新頻道就知道有新對話
|
||||
- 頻道名稱清楚標示用戶和對話 ID
|
||||
|
||||
### 資料庫結構
|
||||
|
||||
`reply_queue` 表格結構:
|
||||
`reply_queue` 表格結構(用於記錄追蹤):
|
||||
|
||||
```sql
|
||||
CREATE TABLE reply_queue (
|
||||
@@ -154,11 +171,16 @@ CREATE TABLE reply_queue (
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
replied_at TIMESTAMP,
|
||||
rocketchat_room_id VARCHAR(100),
|
||||
rocketchat_thread_id VARCHAR(100)
|
||||
rocketchat_room_id VARCHAR(100), -- 儲存頻道 ID
|
||||
rocketchat_thread_id VARCHAR(100) -- 保留欄位(目前未使用)
|
||||
);
|
||||
```
|
||||
|
||||
**說明**:
|
||||
- PostgreSQL 僅用於記錄追蹤,非主要邏輯
|
||||
- 實際對話內容和回覆檢測都在 Rocket.Chat 中進行
|
||||
- 使用時間戳比較來判斷是否有新的管理員回覆
|
||||
|
||||
### 服務列表
|
||||
|
||||
| 服務 | 容器名 | Port | 說明 |
|
||||
@@ -171,8 +193,9 @@ CREATE TABLE reply_queue (
|
||||
|
||||
### 技術特點
|
||||
|
||||
1. **雙層映射** - 完美對應 Open WebUI 的用戶+對話結構
|
||||
2. **執行緒隔離** - 每個對話獨立,不會混雜
|
||||
3. **即時通知** - Rocket.Chat 支援桌面和手機推送
|
||||
4. **輕量部署** - 共 5 個容器(含初始化容器)
|
||||
5. **可擴展性** - 支援多管理員協作回覆
|
||||
1. **一對一映射** - 每個 Open WebUI 對話對應一個 Rocket.Chat 頻道
|
||||
2. **簡化架構** - 不使用 Thread,減少複雜度和潛在問題
|
||||
3. **時間戳比較** - 使用 ISO 格式時間戳判斷新訊息,簡單可靠
|
||||
4. **即時通知** - Rocket.Chat 支援桌面和手機推送
|
||||
5. **輕量部署** - 共 5 個容器(含初始化容器)
|
||||
6. **可擴展性** - 支援多管理員協作回覆
|
||||
|
||||
256
api/server.py
256
api/server.py
@@ -82,18 +82,21 @@ async def rocketchat_login():
|
||||
return None
|
||||
|
||||
|
||||
async def get_or_create_channel(user_id: str, user_name: str = 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
|
||||
|
||||
# 頻道名稱使用用戶名,如果沒有則用 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] # 限制長度
|
||||
# 頻道名稱:用戶名-對話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:
|
||||
@@ -103,14 +106,14 @@ async def get_or_create_channel(user_id: str, user_name: str = None):
|
||||
headers=rocketchat_auth,
|
||||
json={
|
||||
"name": channel_name,
|
||||
"members": [] # 空成員列表,只有管理員可見
|
||||
"members": []
|
||||
}
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
room_id = data["channel"]["_id"]
|
||||
print(f"✅ 創建頻道: {channel_name} ({display_name})")
|
||||
print(f"✅ 創建對話頻道: {channel_name}")
|
||||
|
||||
# 設置頻道描述
|
||||
await client.post(
|
||||
@@ -118,7 +121,7 @@ async def get_or_create_channel(user_id: str, user_name: str = None):
|
||||
headers=rocketchat_auth,
|
||||
json={
|
||||
"roomId": room_id,
|
||||
"description": f"用戶: {display_name} (ID: {user_id})"
|
||||
"description": f"用戶: {display_name} | 對話: {chat_id[:8]}"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -149,122 +152,50 @@ async def get_or_create_channel(user_id: str, user_name: str = None):
|
||||
return None
|
||||
|
||||
|
||||
async def get_or_create_thread(room_id: str, chat_id: str):
|
||||
"""查找或創建對話的執行緒"""
|
||||
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
|
||||
|
||||
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"
|
||||
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"**[對話 {chat_id[:8]}] - {display_name}**\n\n{user_message}"
|
||||
"text": f"**💬 {display_name}:**\n{user_message}"
|
||||
}
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
message_id = data["message"]["_id"]
|
||||
print(f"✅ 創建執行緒訊息: {message_id}")
|
||||
return 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}")
|
||||
print(f" 錯誤內容: {resp.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Rocket.Chat 發送訊息錯誤: {e}")
|
||||
print(f"❌ 發送訊息錯誤: {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):
|
||||
"""輪詢等待執行緒中的管理員回覆
|
||||
async def wait_for_admin_reply(room_id: str, after_ts: str, exclude_msg_id: str = None, timeout: int = 600):
|
||||
"""等待管理員在頻道中的回覆
|
||||
|
||||
Args:
|
||||
existing_message_count: 發送訊息前執行緒中已有的訊息數量,只等待新增的回覆
|
||||
room_id: 頻道 ID
|
||||
after_ts: 用戶訊息的時間戳 (ISO 格式)
|
||||
exclude_msg_id: 要排除的用戶訊息 ID (避免自己讀到自己)
|
||||
timeout: 超時秒數
|
||||
"""
|
||||
if not rocketchat_auth:
|
||||
await rocketchat_login()
|
||||
@@ -273,19 +204,23 @@ async def wait_for_thread_reply(room_id: str, thread_id: str, existing_message_c
|
||||
return None
|
||||
|
||||
start_time = time.time()
|
||||
check_interval = 3 # 每 3 秒檢查一次
|
||||
check_interval = 2 # 每 2 秒檢查一次
|
||||
|
||||
print(f"🔄 開始輪詢執行緒回覆 (thread_id: {thread_id}, 現有訊息: {existing_message_count})")
|
||||
# 取得機器人用戶 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/chat.getThreadMessages",
|
||||
f"{ROCKETCHAT_URL}/api/v1/channels.messages",
|
||||
headers=rocketchat_auth,
|
||||
params={
|
||||
"tmid": thread_id # Thread Message ID
|
||||
"roomId": room_id,
|
||||
"count": 20 # 稍微增加數量以防萬一
|
||||
}
|
||||
)
|
||||
|
||||
@@ -293,33 +228,64 @@ async def wait_for_thread_reply(room_id: str, thread_id: str, existing_message_c
|
||||
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})")
|
||||
found_new_reply = False
|
||||
|
||||
# 按時間排序,取最新的訊息
|
||||
sorted_messages = sorted(messages, key=lambda m: m.get("ts", ""), reverse=True)
|
||||
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")
|
||||
|
||||
# 尋找管理員的新回覆
|
||||
for msg in sorted_messages:
|
||||
# 跳過原始訊息
|
||||
if msg["_id"] == thread_id:
|
||||
# 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
|
||||
|
||||
# 檢查是否為真人回覆(有 u 欄位且不是 bot)
|
||||
if "u" in msg and not msg.get("bot"):
|
||||
reply_text = msg.get("msg", "")
|
||||
# 跳過用戶自己的訊息(檢查是否包含對話標記)
|
||||
if reply_text and not reply_text.startswith("[對話"):
|
||||
print(f"✅ 收到管理員回覆: {reply_text[:50]}...")
|
||||
return reply_text
|
||||
# 如果不是轉發格式,那我們假設這是 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}")
|
||||
print(f"⚠️ API 錯誤: {resp.status_code}")
|
||||
|
||||
await asyncio.sleep(check_interval)
|
||||
|
||||
# 超時
|
||||
print(f"⚠️ 等待回覆超時 ({timeout}秒)")
|
||||
print(f"⚠️ 等待回覆超時")
|
||||
return "抱歉,目前客服繁忙,請稍後再試。"
|
||||
|
||||
except Exception as e:
|
||||
@@ -502,13 +468,12 @@ async def chat_completions(
|
||||
}
|
||||
|
||||
print(f"📝 收到訊息")
|
||||
print(f" 用戶ID: {user_id}")
|
||||
print(f" 用戶名: {user_name}")
|
||||
print(f" 用戶: {user_name} ({user_id[:8]}...)")
|
||||
print(f" 對話: {chat_id[:8]}")
|
||||
print(f" 內容: {user_message[:50]}...")
|
||||
|
||||
# 1. 取得或創建用戶頻道
|
||||
room_id = await get_or_create_channel(user_id, user_name)
|
||||
# 1. 為此對話取得或創建專屬頻道(每個 chat_id = 一個 Channel)
|
||||
room_id = await get_or_create_chat_channel(chat_id, user_name)
|
||||
|
||||
if not room_id:
|
||||
return JSONResponse(
|
||||
@@ -516,39 +481,16 @@ async def chat_completions(
|
||||
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
|
||||
# 2. 發送用戶訊息到頻道
|
||||
msg_result = await send_user_message(room_id, user_message, user_name)
|
||||
|
||||
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}")
|
||||
if not msg_result:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "Failed to send message to Rocket.Chat"}
|
||||
)
|
||||
|
||||
# 追加訊息
|
||||
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"}
|
||||
)
|
||||
user_message_ts = msg_result["ts"] # 用時間戳比較,更可靠
|
||||
|
||||
# 3. 記錄到資料庫
|
||||
async with db_pool.acquire() as conn:
|
||||
@@ -560,11 +502,11 @@ async def chat_completions(
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7)
|
||||
""",
|
||||
message_id, user_id, chat_id, user_name, user_message, room_id, thread_id
|
||||
message_id, user_id, chat_id, user_name, user_message, room_id, msg_result["message_id"]
|
||||
)
|
||||
|
||||
# 4. 等待管理員在執行緒中回覆(傳入現有訊息數量,只等待新回覆)
|
||||
admin_reply = await wait_for_thread_reply(room_id, thread_id, existing_message_count)
|
||||
# 4. 等待管理員在頻道中回覆(使用時間戳比較)
|
||||
admin_reply = await wait_for_admin_reply(room_id, user_message_ts, exclude_msg_id=msg_result["message_id"])
|
||||
|
||||
# 5. 更新資料庫
|
||||
async with db_pool.acquire() as conn:
|
||||
|
||||
180
exchange.md
180
exchange.md
@@ -109,4 +109,182 @@ CMD ["streamlit", "run", "admin.py", "--server.port=8501", "--server.address=0.0
|
||||
2. 伺服器資源不足,無法負擔 Rocket.Chat + MongoDB。
|
||||
3. 需要針對特定業務邏輯(如:查看用戶餘額、審核特定關鍵字)進行客製化開發。
|
||||
|
||||
## Rocket.chat實作紀錄
|
||||
## 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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user