Compare commits

...

2 Commits

Author SHA1 Message Date
ChenKaiLiuG
f1761cb25d Add Intro 2026-01-31 22:32:31 +08:00
ChenKaiLiuG
13c8f8aa3c Add test.sh 2025-12-23 22:47:40 +08:00
6 changed files with 316 additions and 86 deletions

View File

@@ -1,5 +1,8 @@
# PostgreSQL 密碼
DB_PASSWORD=your_secure_password_here
# Chatwoot Secret Key (使用以下命令生成: openssl rand -hex 64)
CHATWOOT_SECRET_KEY=your_chatwoot_secret_key_here
# Papercups Secret Key (使用以下命令生成: openssl rand -hex 64)
PAPERCUPS_SECRET_KEY=your_papercups_secret_key_here
# Papercups API Token (從 Papercups Settings 取得)
PAPERCUPS_API_TOKEN=your_papercups_api_token_here

68
INTRO.md Normal file
View File

@@ -0,0 +1,68 @@
# 功能介紹
## 核心概念
TobiichiGPT 將 Open WebUI 這類 AI 對話系統的後端改造成由真人管理員回覆的系統。
### 基本原理
1. **API 中間層** - 提供 OpenAI API 相容端點(`/v1/chat/completions`
2. **訊息轉發** - 將用戶訊息存入資料庫的 `reply_queue` 表格
3. **管理員介面** - 使用對話軟體讓真人管理員查看並回覆
4. **輪詢機制** - API 等待管理員在資料庫中填入回覆後返回給用戶
### 技術架構
```
用戶 → Open WebUI → API (FastAPI) → 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
- 每個對話有獨立的主題和訊息串
- 管理員需要能夠明確追蹤「哪個用戶的哪個對話」
### 管理後台需求
理想的管理介面應該呈現:
```
📂 所有對話(按用戶分組)
├── 👤 張三 (User A)
│ ├── 💬 對話: "教我 Python" (3 則訊息)
│ ├── 💬 對話: "推薦餐廳" (5 則訊息)
│ └── 💬 對話: "旅遊攻略" (2 則訊息)
└── 👤 李四 (User B)
├── 💬 對話: "程式問題" (1 則訊息) ⚠️ 待回覆
└── 💬 對話: "健康諮詢" (4 則訊息)
```
**核心需求**
- ✅ 用戶分組 - 能看到每個用戶的所有對話
- ✅ 對話追蹤 - 同一對話的多輪訊息要串在一起
- ✅ 多層結構 - 支援「用戶 → 對話 → 訊息」的層級關係
- ✅ 狀態管理 - 清楚標示哪些對話待回覆
## 架構

View File

@@ -2,3 +2,4 @@ fastapi==0.104.1
uvicorn==0.24.0
pydantic==2.5.0
asyncpg==0.29.0
httpx==0.25.2

View File

@@ -2,7 +2,7 @@
API 轉接層 - 偽裝 OpenAI API將請求轉為人工回覆隊列
"""
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,10 @@ import asyncio
import time
import uuid
import os
import httpx
from typing import Optional
from datetime import datetime
import hashlib
app = FastAPI()
@@ -27,6 +29,10 @@ DB_CONFIG = {
"password": os.getenv("DB_PASSWORD", "tobiichi_password")
}
# Papercups 設定
PAPERCUPS_URL = os.getenv("PAPERCUPS_URL", "http://papercups:4000")
PAPERCUPS_API_TOKEN = os.getenv("PAPERCUPS_API_TOKEN", "")
class Message(BaseModel):
role: str
@@ -39,6 +45,112 @@ class ChatRequest(BaseModel):
stream: Optional[bool] = False
class PapercupsWebhook(BaseModel):
"""Papercups Webhook 資料格式"""
event: str
payload: Optional[dict] = None
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 get_or_create_papercups_conversation(user_id: str, chat_id: str, user_name: str = None):
"""獲取或創建 Papercups 對話(使用 chat_id 作為唯一標識)"""
if not PAPERCUPS_API_TOKEN:
print("⚠️ Papercups 未配置,跳過推送")
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
headers = {"Authorization": f"Bearer {PAPERCUPS_API_TOKEN}"}
# 使用 chat_id 作為唯一標識符
identifier = chat_id if chat_id else user_id
# 如果沒有提供用戶名,嘗試從數據庫獲取
if not user_name and user_id:
user_name = await get_user_name(user_id)
# 生成顯示名稱
if user_name:
display_name = user_name
elif user_id:
display_name = f"User-{user_id[:8]}"
else:
display_name = f"Chat-{chat_id[:8]}"
# Papercups API: 創建或獲取對話
conversation_url = f"{PAPERCUPS_URL}/api/conversations"
conversation_payload = {
"customer": {
"external_id": identifier,
"name": display_name
}
}
conversation_response = await client.post(conversation_url, json=conversation_payload, headers=headers)
if conversation_response.status_code in [200, 201]:
conversation_data = conversation_response.json()
papercups_conv_id = conversation_data.get("id")
print(f"✅ Papercups 對話: #{papercups_conv_id} ({display_name})")
return papercups_conv_id
else:
print(f"⚠️ Papercups 對話創建失敗: {conversation_response.status_code}")
return None
except Exception as e:
print(f"❌ Papercups 錯誤: {e}")
import traceback
traceback.print_exc()
return None
async def send_message_to_papercups(papercups_conv_id: str, message: str, sent_by: str = "customer"):
"""發送訊息到 Papercups 對話"""
if not PAPERCUPS_API_TOKEN or not papercups_conv_id:
return False
try:
async with httpx.AsyncClient(timeout=10.0) as client:
headers = {"Authorization": f"Bearer {PAPERCUPS_API_TOKEN}"}
msg_url = f"{PAPERCUPS_URL}/api/messages"
msg_payload = {
"conversation_id": papercups_conv_id,
"body": message,
"sent_by": sent_by
}
msg_response = await client.post(msg_url, json=msg_payload, headers=headers)
if msg_response.status_code in [200, 201]:
print(f"✅ 訊息已發送到 Papercups 對話 #{papercups_conv_id}")
return True
else:
print(f"⚠️ 訊息發送失敗: {msg_response.status_code}")
return False
except Exception as e:
print(f"❌ 發送訊息錯誤: {e}")
return False
async def init_db():
"""初始化資料庫連接池和表格"""
global db_pool
@@ -50,18 +162,29 @@ async def init_db():
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_message TEXT NOT NULL,
admin_reply TEXT,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW(),
replied_at TIMESTAMP
replied_at TIMESTAMP,
papercups_conversation_id VARCHAR(100)
)
""")
# 添加新欄位(如果不存在)
try:
await conn.execute("ALTER TABLE reply_queue ADD COLUMN IF NOT EXISTS user_id VARCHAR(255);")
await conn.execute("ALTER TABLE reply_queue ADD COLUMN IF NOT EXISTS chat_id VARCHAR(255);")
except:
pass
# 建立索引
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_status ON reply_queue(status);
CREATE INDEX IF NOT EXISTS idx_conversation_id ON reply_queue(conversation_id);
CREATE INDEX IF NOT EXISTS idx_chat_id ON reply_queue(chat_id);
""")
@@ -91,12 +214,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 +227,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
將用戶訊息寫入資料庫,等待管理員回覆
將用戶訊息寫入資料庫,等待管理員回覆(無超時限制)
"""
# 取得最後一則用戶訊息
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,46 +249,70 @@ async def chat_completions(request: ChatRequest):
content={"error": "No user message found"}
)
# 生成對話 ID
conversation_id = str(uuid.uuid4())
# 嘗試從請求中提取用戶信息
user_id = None
chat_id = None
# 從 headers 提取信息
headers_dict = dict(http_request.headers)
user_id = headers_dict.get("x-user-id") or headers_dict.get("user-id")
chat_id = headers_dict.get("x-chat-id") or headers_dict.get("chat-id")
# 如果沒有,生成一個基於授權 token 的穩定 ID
if not user_id and authorization:
user_id = hashlib.md5(authorization.encode()).hexdigest()[:16]
# 生成消息 ID用於追蹤單條消息
message_id = str(uuid.uuid4())
# 如果沒有 chat_id使用 user_id
if not chat_id:
chat_id = user_id if user_id else message_id
# 獲取用戶真實姓名
user_name = await get_user_name(user_id) if user_id else None
print(f"📝 收到訊息 [user:{user_name or user_id}, chat:{chat_id}]: {user_message[:50]}...")
# 獲取或創建 Papercups 對話
papercups_conv_id = await get_or_create_papercups_conversation(user_id, chat_id, user_name)
# 寫入資料庫
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_message, status, papercups_conversation_id)
VALUES ($1, $2, $3, $4, 'pending', $5)
""",
conversation_id, user_message
message_id, user_id, chat_id, user_message, papercups_conv_id
)
print(f"📝 收到訊息 [{conversation_id}]: {user_message[:50]}...")
# 推送到 Papercups
if papercups_conv_id:
await send_message_to_papercups(papercups_conv_id, user_message, "customer")
# 等待管理員回覆 (最多 15 分鐘)
max_wait = 900 # 15 分鐘
check_interval = 3 # 每 3 秒檢查一次
waited = 0
# 無限等待管理員回覆
check_interval = 2 # 每 2 秒檢查一次
while waited < max_wait:
while True:
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
message_id
)
if row and row['status'] == 'replied' and row['admin_reply']:
admin_reply = row['admin_reply']
print(f"✅ 管理員已回覆 [{conversation_id}]")
print(f"✅ 管理員已回覆 [chat:{chat_id}]")
# 回傳 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,
@@ -178,25 +329,58 @@ async def chat_completions(request: ChatRequest):
"total_tokens": len(user_message) + len(admin_reply)
}
}
# 超時回應
print(f"⏰ 等待超時 [{conversation_id}]")
return {
"id": f"chatcmpl-{conversation_id}",
"object": "chat.completion",
"created": int(time.time()),
"model": request.model,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "抱歉,管理員目前忙碌中,請稍後再試。"
},
"finish_reason": "stop"
}
]
}
@app.post("/papercups/webhook")
async def papercups_webhook(request: Request):
"""接收 Papercups Webhook 回調"""
try:
data = await request.json()
event = data.get("event")
# 只處理訊息建立事件
if event != "message:created":
return {"status": "ignored", "event": event}
payload = data.get("payload", {})
message = payload.get("message", {})
# 檢查是否為管理員回覆user type = "user"
user = message.get("user")
if not user:
return {"status": "ignored", "reason": "not_agent_reply"}
# 取得訊息內容和對話 ID
content = message.get("body")
conversation_id = message.get("conversation_id")
if not content or not conversation_id:
return {"status": "error", "message": "Missing content or conversation_id"}
# 更新資料庫中所有該對話的待處理消息(最新的一條)
async with db_pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE reply_queue
SET admin_reply = $1, status = 'replied', replied_at = NOW()
WHERE papercups_conversation_id = $2
AND status = 'pending'
AND id = (
SELECT id FROM reply_queue
WHERE papercups_conversation_id = $2 AND status = 'pending'
ORDER BY created_at DESC
LIMIT 1
)
""",
content, conversation_id
)
print(f"✅ Papercups 回覆已處理: 對話 #{conversation_id}")
return {"status": "success", "conversation_id": conversation_id}
except Exception as e:
print(f"❌ Webhook 處理錯誤: {e}")
return {"status": "error", "message": str(e)}
if __name__ == "__main__":

View File

@@ -33,6 +33,8 @@ services:
- DB_NAME=tobiichiGPT
- DB_USER=tobiichi3227
- DB_PASSWORD=${DB_PASSWORD}
- PAPERCUPS_URL=http://papercups:4000
- PAPERCUPS_API_TOKEN=${PAPERCUPS_API_TOKEN}
volumes:
- /mnt/data/External/tobiichiGPT/api_data:/app
working_dir: /app
@@ -65,53 +67,25 @@ 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
# Papercups - 管理員對話介面
papercups:
image: papercups/papercups:latest
container_name: tobiichiGPT-papercups
restart: unless-stopped
ports:
- "13000:3000"
- "14000:4000"
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
- DATABASE_URL=postgresql://tobiichi3227:${DB_PASSWORD}@postgres:5432/papercups
- SECRET_KEY_BASE=${PAPERCUPS_SECRET_KEY}
- BACKEND_URL=http://localhost:14000
- MIX_ENV=prod
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
"
postgres:
condition: service_healthy
networks:
tobiichiGPT-network:
driver: bridge
name: tobiichiGPT-network # 固定網路名稱,讓 proxy stack 可以連接
volumes:
redis-data:
chatwoot-data:

View File

@@ -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": "自動化測試訊息"}
]