This commit is contained in:
ChenKaiLiuG
2025-12-20 01:09:27 +08:00
parent 55a527a1b1
commit 506836f22e
9 changed files with 875 additions and 1 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.env
*.log
__pycache__
*.pyc
cloudflared-config.yml
credentials.json
npm-data/
open-webui-data/

122
QUICKSTART.md Normal file
View File

@@ -0,0 +1,122 @@
# TobiichiGPT - 快速設定指南
## 📦 一鍵啟動
```powershell
# 進入 docker-stack 目錄
cd docker-stack
# 啟動所有服務
docker-compose up -d
```
## 🌐 服務清單
| 服務 | 網址 | 用途 |
|------|------|------|
| Open WebUI | http://localhost:3000 | 用戶對話介面 |
| 管理員後台 | http://localhost:8000/admin | 真人回覆訊息 |
| NPM 管理面板 | http://localhost:82 | 反向代理設定 |
| Cloudflare Tunnel | 自動連線 | 公開訪問(需設定) |
## ⚙️ Open WebUI 設定步驟
1. 開啟 http://localhost:3000
2. 進入 **Settings****Connections**
3. 點擊 **+ Add OpenAI Connection**
4. 填入:
- **API Base URL**: `http://human-reply-server:8000/v1`
- **API Key**: `sk-human` (隨便填)
5. 模型列表會出現 **human-admin**
## 💬 使用流程
### 用戶端
1. 在 Open WebUI 選擇 `human-admin` 模型
2. 輸入訊息後送出
3. 等待管理員回覆(畫面會轉圈)
### 管理員端
1. 訪問 http://localhost:8000/admin
2. 看到新訊息通知
3. 在文字框輸入回覆
4. 點擊「送出回覆」
## 🔒 設定 HTTPS
### 方法 1: 使用 NPM本機 NPM
1. 訪問 http://localhost:82
2. 登入預設帳密admin@example.com / changeme
3. 首次登入會要求更改密碼
4. 新增 Proxy Host
- **Domain Names**: 你的網域 (例如: chat.example.com)
- **Forward Hostname**: `open-webui`
- **Forward Port**: `8080`
- 勾選 **SSL** → 申請 Let's Encrypt 憑證
### 方法 2: 使用 Cloudflare Tunnel推薦
1. 前往 https://one.dash.cloudflare.com/
2. 建立 Tunnel 並複製 Token
3. 編輯 `docker-stack/.env`,填入 Token
4. 在 Cloudflare 設定 Public Hostname
- `chat.yourdomain.com``open-webui:8080`
- `admin.yourdomain.com``human-reply-server:8000`
5. 自動獲得 HTTPS + DDoS 保護
## 🛠️ 常用指令
```powershell
# 查看服務狀態
docker-compose ps
# 查看日誌
docker-compose logs -f
# 重啟服務
docker-compose restart
# 停止服務
docker-compose down
# 完全移除(包含資料)
docker-compose down -v
```
## 🐛 疑難排解
### Open WebUI 連不到人工回覆伺服器
- 確認 API URL 使用 `http://human-reply-server:8000/v1`(容器名稱)
- 不要使用 `localhost:8000`
### 管理員後台打不開
- 確認容器是否正常運行:`docker ps`
- 查看錯誤日誌:`docker logs tobiichi-gpt`
### NPM 無法訪問
- 確認 port 81 沒有被佔用
- Windows 防火牆可能需要開放 port
## 📝 Port 對應
### 本機訪問
| 容器內 Port | 本機 Port | 服務 |
|------------|----------|------|
| 8080 | 3000 | Open WebUI |
| 8000 | 8000 | 人工回覆 API |
| 80 | 8080 | 本專案 NPM HTTP |
| 443 | 8443 | 本專案 NPM HTTPS |
| 81 | 82 | 本專案 NPM 管理 |
### 與現有 NPM 共存
| 服務 | Port | 說明 |
|------|------|------|
| 現有 NPM | 80/443/81 | 原有的 NPM 實例 |
| 本專案 NPM | 8080/8443/82 | TobiichiGPT 專用 NPM |
### 透過 Cloudflare Tunnel
無需開放 Port直接使用網域訪問需設定 `.env` 檔案)

251
README.md
View File

@@ -1,2 +1,251 @@
# tobiichiGPT # TobiichiGPT - 人工回覆系統
將 AI 對話系統改造成由真人管理員回覆的極簡方案。
## 🎯 特色
-**零修改** - 不需要修改 Open WebUI 程式碼
-**極簡化** - 單一 Python 檔案即可運行
-**即插即用** - 偽裝成 OpenAI API直接在 Open WebUI 設定中使用
-**視覺化後台** - 美觀的網頁介面供管理員回覆
## 🚀 快速開始
### 方法 1: Docker (推薦)
```powershell
# 進入 docker-stack 目錄
cd docker-stack
# 設定環境變數(首次使用)
Copy-Item .env.example .env
notepad .env # 填入 Cloudflare Tunnel Token若要使用
# 使用 Docker Compose 啟動
docker-compose up -d
# 查看日誌
docker-compose logs -f
# 停止服務
docker-compose down
```
### 方法 2: 直接執行
```powershell
# 1. 進入 docker 目錄
cd docker
# 2. 安裝依賴
pip install -r requirements.txt
# 3. 啟動伺服器
python human_reply_server.py
```
啟動後會看到:
```
🚀 人工回覆伺服器啟動中...
📌 管理員後台: http://localhost:8000/admin
📌 API 端點: http://localhost:8000/v1
```
## 🌐 服務訪問
啟動後可訪問以下服務:
| 服務 | 網址 | 說明 |
|------|------|------|
| **Open WebUI** | http://localhost:3000 | 用戶對話介面 |
| **管理員後台** | http://localhost:8000/admin | 真人回覆訊息 |
| **NPM 管理面板** | http://localhost:82 | Nginx Proxy Manager |
| **Cloudflare Tunnel** | 自動連線 | 無需手動訪問 |
預設帳號密碼NPM
- Email: `admin@example.com`
- Password: `changeme`
### 在 Open WebUI 中設定
1. 開啟 http://localhost:3000
2. 進入 **Settings****Connections**
3. 點擊 **+ Add OpenAI Connection**
4. 填入設定:
- **API Base URL**: `http://human-reply-server:8000/v1`
- **API Key**: 隨便填 (例如: `sk-human`)
5. 儲存後,模型列表會出現 **human-admin**
### 開始使用
1. **用戶端**: 在 Open WebUI (http://localhost:3000) 選擇 `human-admin` 模型,發送訊息
2. **管理員**: 訪問 http://localhost:8000/admin查看並回覆訊息
3. **自動刷新**: 後台每 5 秒自動刷新,顯示新訊息
## 📋 運作流程
```
用戶發送訊息
Open WebUI 調用 API (轉圈圈等待)
訊息進入待處理隊列
管理員在後台看到訊息
管理員輸入並送出回覆
API 回傳給 Open WebUI
用戶收到回覆
```
## 🔧 技術架構
- **FastAPI**: 輕量級 Python Web 框架
- **AsyncIO**: 異步等待管理員回覆
- **偽裝 OpenAI API**:
- `/v1/models` - 模型列表
- `/v1/chat/completions` - 聊天完成端點
## 🐳 Docker 部署
### 檔案結構
```
tobiichiGPT/
├── docker/ # Docker 相關檔案
│ ├── human_reply_server.py # 主程式
│ ├── requirements.txt # Python 依賴
│ ├── Dockerfile # Docker 映像檔
│ └── .dockerignore # Docker 忽略檔案
├── docker-stack/ # Docker Compose 配置
│ ├── docker-compose.yml # 服務編排檔案
│ └── .env.example # 環境變數範例
├── README.md # 專案說明
├── QUICKSTART.md # 快速開始指南
└── LICENSE # 授權檔案
```
### Docker 指令
```powershell
# 建立映像檔
cd docker
docker build -t tobiichi-gpt .
# 直接執行容器
docker run -d -p 8000:8000 --name tobiichi-gpt tobiichi-gpt
# 使用 Docker Compose推薦
cd .\docker-stack
docker-compose up -d
# 查看容器狀態
docker ps
# 查看日誌
docker logs -f tobiichi-gpt
# 停止並移除
docker-compose down
```
### 與 Open WebUI 整合
如果 Open WebUI 也在 Docker 中運行,將兩者加入同一網路:
```yaml
# 在 docker-compose.yml 中加入 Open WebUI
version: '3.8'
services:
human-reply-server:
build: .
container_name: tobiichi-gpt
ports:
- "8000:8000"
networks:
- tobiichi-network
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
ports:
- "3000:8080"
volumes:
- open-webui:/app/backend/data
networks:
- tobiichi-network
networks:
tobiichi-network:
driver: bridge
volumes:
open-webui:
```
然後在 Open WebUI 中使用:
- **API Base URL**: `http://human-reply-server:8000/v1`
## 📝 進階設定
### 修改等待超時時間
編輯 `docker/human_reply_server.py` 第 79 行:
```python
timeout = 600 # 預設 10 分鐘,可改為其他秒數
```
### 修改伺服器埠號
**方法 1: 修改 docker-compose.yml**
```yaml
# 編輯 docker-stack/docker-compose.yml
ports:
- "9000:8000" # 本機 9000 對應容器 8000
```
**方法 2: 修改程式碼**
```python
# 編輯 docker/human_reply_server.py
uvicorn.run(app, host="0.0.0.0", port=8000) # 改為其他埠號
```
### 設定 Cloudflare Tunnel
詳細設定步驟請參考專案根目錄的說明文件,或參考 `docker-stack/.env.example`
```powershell
cd docker-stack
Copy-Item .env.example .env
notepad .env # 填入您的 CLOUDFLARE_TUNNEL_TOKEN
```
### 支援多管理員
目前版本採「先搶先贏」機制,任何管理員都可以回覆任何訊息。若需要分配機制,可以加入:
- 管理員登入系統
- 訊息認領機制
- 管理員負載平衡
## ⚠️ 注意事項
- 此為**最簡化版本**,適合小規模使用
- 訊息儲存在記憶體中,重啟會遺失
- 沒有身份驗證,建議僅在內網使用
- 若需生產環境,建議加入:
- 資料庫持久化
- 身份驗證
- WebSocket 即時推送
- 訊息隊列系統
## 🎨 管理員後台截圖
後台提供:
- 待處理訊息列表
- 用戶訊息顯示
- 回覆文字框
- 一鍵送出功能
- 自動刷新

View File

@@ -0,0 +1,4 @@
# Cloudflare Tunnel Token
# 請到 Cloudflare Zero Trust Dashboard 建立 Tunnel 並取得 Token
# https://one.dash.cloudflare.com/
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token-here

View File

@@ -0,0 +1,69 @@
version: '3.8'
services:
# 人工回覆伺服器
human-reply-server:
build: .
container_name: tobiichi-gpt
ports:
- "8000:8000"
restart: unless-stopped
environment:
- TZ=Asia/Taipei
networks:
- tobiichi-network
# Open WebUI
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
ports:
- "3000:8080"
volumes:
- open-webui-data:/app/backend/data
environment:
- WEBUI_AUTH=False # 關閉登入驗證(可選)
- TZ=Asia/Taipei
restart: unless-stopped
networks:
- tobiichi-network
# Nginx Proxy Manager
nginx-proxy-manager:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
ports:
- "8080:80" # HTTP (避免與現有 NPM 衝突)
- "8443:443" # HTTPS (避免與現有 NPM 衝突)
- "82:81" # NPM 管理介面 (避免與現有 NPM 衝突)
volumes:
- npm-data:/data
- npm-letsencrypt:/etc/letsencrypt
environment:
- TZ=Asia/Taipei
restart: unless-stopped
networks:
- tobiichi-network
# Cloudflare Tunnel
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared-tunnel
command: tunnel run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
restart: unless-stopped
networks:
- tobiichi-network
depends_on:
- open-webui
- human-reply-server
networks:
tobiichi-network:
driver: bridge
volumes:
open-webui-data:
npm-data:
npm-letsencrypt:

17
docker/.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
.git
.gitignore
*.md
LICENSE
.vscode
.idea
*.log

18
docker/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.11-slim
WORKDIR /app
# 複製依賴檔案
COPY requirements.txt .
# 安裝依賴
RUN pip install --no-cache-dir -r requirements.txt
# 複製程式檔案
COPY human_reply_server.py .
# 暴露端口
EXPOSE 8000
# 啟動伺服器
CMD ["python", "human_reply_server.py"]

View File

@@ -0,0 +1,384 @@
"""
人工回覆伺服器 - 偽裝成 OpenAI API
使用方式:
1. 執行此程式python human_reply_server.py
2. 在 Open WebUI 設定中加入模型http://localhost:8000/v1
3. 用戶發送訊息後,訪問 http://localhost:8000/admin 查看並回覆
"""
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel
import asyncio
import json
import time
from datetime import datetime
from typing import Optional
import uuid
app = FastAPI()
# 儲存待處理的對話
pending_conversations = {}
# 儲存管理員的回覆
admin_replies = {}
class Message(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
model: str
messages: list[Message]
stream: Optional[bool] = False
@app.get("/")
async def root():
return {"status": "Human Reply Server Running", "admin_panel": "/admin"}
@app.get("/v1/models")
async def list_models():
"""模擬 OpenAI 的模型列表 API"""
return {
"object": "list",
"data": [
{
"id": "human-admin",
"object": "model",
"created": int(time.time()),
"owned_by": "human-admin"
}
]
}
@app.post("/v1/chat/completions")
async def chat_completions(request: ChatRequest):
"""模擬 OpenAI 的聊天完成 API - 等待真人回覆"""
# 生成對話 ID
conversation_id = str(uuid.uuid4())[:8]
# 取得最後一則用戶訊息
user_message = ""
for msg in reversed(request.messages):
if msg.role == "user":
user_message = msg.content
break
# 儲存對話資訊
pending_conversations[conversation_id] = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"user_message": user_message,
"full_history": [{"role": msg.role, "content": msg.content} for msg in request.messages]
}
print(f"\n[新訊息 {conversation_id}] {user_message}")
print(f"→ 等待管理員回覆,請訪問: http://localhost:8000/admin")
# 等待管理員回覆 (最多等待 10 分鐘)
timeout = 600
elapsed = 0
while conversation_id not in admin_replies and elapsed < timeout:
await asyncio.sleep(1)
elapsed += 1
# 取得管理員回覆
if conversation_id in admin_replies:
reply_content = admin_replies[conversation_id]
del admin_replies[conversation_id]
del pending_conversations[conversation_id]
else:
reply_content = "抱歉,管理員暫時無法回覆,請稍後再試。"
# 根據是否需要串流回傳
if request.stream:
return StreamingResponse(
stream_response(reply_content),
media_type="text/event-stream"
)
else:
return {
"id": f"chatcmpl-{conversation_id}",
"object": "chat.completion",
"created": int(time.time()),
"model": request.model,
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": reply_content
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
}
}
async def stream_response(content: str):
"""串流方式回傳回覆"""
# 開始標記
yield f"data: {json.dumps({'choices': [{'delta': {'role': 'assistant'}, 'index': 0}]})}\n\n"
# 逐字回傳
for char in content:
chunk = {
"choices": [{
"delta": {"content": char},
"index": 0
}]
}
yield f"data: {json.dumps(chunk)}\n\n"
await asyncio.sleep(0.01)
# 結束標記
yield f"data: {json.dumps({'choices': [{'delta': {}, 'index': 0, 'finish_reason': 'stop'}]})}\n\n"
yield "data: [DONE]\n\n"
@app.get("/admin", response_class=HTMLResponse)
async def admin_panel():
"""管理員後台 - 查看並回覆訊息"""
# 生成待處理對話的 HTML
conversations_html = ""
if pending_conversations:
for conv_id, conv_data in pending_conversations.items():
conversations_html += f"""
<div class="conversation" id="conv-{conv_id}">
<div class="conv-header">
<strong>對話 ID:</strong> {conv_id}
<span class="timestamp">{conv_data['timestamp']}</span>
</div>
<div class="user-message">
<strong>用戶:</strong> {conv_data['user_message']}
</div>
<textarea id="reply-{conv_id}" placeholder="輸入您的回覆..."></textarea>
<button onclick="sendReply('{conv_id}')">送出回覆</button>
</div>
"""
else:
conversations_html = '<p class="no-messages">目前沒有待處理的訊息</p>'
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理員後台 - 人工回覆系統</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}}
.container {{
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 30px;
}}
h1 {{
color: #333;
margin-bottom: 10px;
font-size: 28px;
}}
.subtitle {{
color: #666;
margin-bottom: 30px;
font-size: 14px;
}}
.conversation {{
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 20px;
margin-bottom: 20px;
border-radius: 8px;
animation: slideIn 0.3s ease;
}}
@keyframes slideIn {{
from {{ opacity: 0; transform: translateY(-10px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
.conv-header {{
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #dee2e6;
}}
.timestamp {{
float: right;
color: #6c757d;
font-size: 12px;
}}
.user-message {{
background: white;
padding: 15px;
border-radius: 6px;
margin-bottom: 15px;
border: 1px solid #e0e0e0;
}}
textarea {{
width: 100%;
min-height: 100px;
padding: 12px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
resize: vertical;
transition: border-color 0.3s;
}}
textarea:focus {{
outline: none;
border-color: #667eea;
}}
button {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 30px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
margin-top: 10px;
transition: transform 0.2s, box-shadow 0.2s;
}}
button:hover {{
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}}
button:active {{
transform: translateY(0);
}}
.no-messages {{
text-align: center;
color: #6c757d;
padding: 40px;
font-style: italic;
}}
.status {{
position: fixed;
top: 20px;
right: 20px;
background: white;
padding: 10px 20px;
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: none;
}}
.status.show {{
display: block;
animation: fadeIn 0.3s ease;
}}
@keyframes fadeIn {{
from {{ opacity: 0; }}
to {{ opacity: 1; }}
}}
</style>
</head>
<body>
<div class="status" id="status"></div>
<div class="container">
<h1>🎯 管理員後台</h1>
<p class="subtitle">人工回覆系統 - 待處理訊息數量: {len(pending_conversations)}</p>
<div id="conversations">
{conversations_html}
</div>
</div>
<script>
function sendReply(convId) {{
const textarea = document.getElementById('reply-' + convId);
const reply = textarea.value.trim();
if (!reply) {{
alert('請輸入回覆內容');
return;
}}
fetch('/admin/reply', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ conversation_id: convId, reply: reply }})
}})
.then(response => response.json())
.then(data => {{
if (data.status === 'success') {{
showStatus('✅ 回覆已送出!');
document.getElementById('conv-' + convId).remove();
// 如果沒有待處理訊息,顯示提示
if (document.querySelectorAll('.conversation').length === 0) {{
document.getElementById('conversations').innerHTML =
'<p class="no-messages">目前沒有待處理的訊息</p>';
}}
}}
}})
.catch(error => {{
showStatus('❌ 送出失敗: ' + error);
}});
}}
function showStatus(message) {{
const status = document.getElementById('status');
status.textContent = message;
status.classList.add('show');
setTimeout(() => status.classList.remove('show'), 3000);
}}
// 每 5 秒自動重新整理
setInterval(() => location.reload(), 5000);
</script>
</body>
</html>
"""
return html
@app.post("/admin/reply")
async def admin_reply(request: Request):
"""接收管理員的回覆"""
data = await request.json()
conversation_id = data.get("conversation_id")
reply = data.get("reply")
if conversation_id and reply:
admin_replies[conversation_id] = reply
print(f"[管理員回覆 {conversation_id}] {reply}")
return {"status": "success"}
return {"status": "error", "message": "Missing conversation_id or reply"}
if __name__ == "__main__":
import uvicorn
print("=" * 60)
print("🚀 人工回覆伺服器啟動中...")
print("=" * 60)
print("📌 管理員後台: http://localhost:8000/admin")
print("📌 API 端點: http://localhost:8000/v1")
print("=" * 60)
print("\n在 Open WebUI 中設定:")
print("1. 進入 Settings → Connections")
print("2. 添加 OpenAI API:")
print(" - API Base URL: http://localhost:8000/v1")
print(" - API Key: 隨便填 (例如: sk-human)")
print("3. 模型名稱會顯示為: human-admin")
print("=" * 60)
uvicorn.run(app, host="0.0.0.0", port=8000)

3
docker/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi==0.104.1
uvicorn==0.24.0
pydantic==2.5.0