diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6a38d30 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# PostgreSQL 密碼 +DB_PASSWORD=your_secure_password_here + +# Chatwoot Secret Key (使用以下命令生成: openssl rand -hex 64) +CHATWOOT_SECRET_KEY=your_chatwoot_secret_key_here diff --git a/README.md b/README.md index 3a848bb..d47a0cd 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,13 @@ ```powershell # 複製主服務環境變數 Copy-Item .env.example .env -notepad .env # 設定 DB_PASSWORD +notepad .env # 設定 DB_PASSWORD 和 CHATWOOT_SECRET_KEY -# 如需代理服務 -Copy-Item .env.proxy .env.proxy -notepad .env.proxy # 填入 CLOUDFLARE_TUNNEL_TOKEN +# 生成 Chatwoot Secret Key (在 Linux/macOS) +openssl rand -hex 64 + +# 或在 PowerShell 生成 +-join ((1..128) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }) ``` ### 2. 啟動主要服務 @@ -90,9 +92,9 @@ docker-compose -f docker-compose.proxy.yml ps | 服務 | 網址 | 說明 | |------|------|------| -| **Open WebUI** | http://localhost:3000 | 用戶對話介面 | -| **API 伺服器** | http://localhost:8000 | OpenAI API 相容端點 | -| **NocoDB** | http://localhost:8080 | 管理員回覆介面 | +| **Open WebUI** | http://localhost:10060 | 用戶對話介面 | +| **API 伺服器** | http://localhost:18000 | OpenAI API 相容端點 | +| **Chatwoot** | http://localhost:13500 | 管理員對話介面 | | **PostgreSQL** | localhost:5432 | 資料庫 | ### 代理服務(如已啟動) @@ -109,47 +111,29 @@ docker-compose -f docker-compose.proxy.yml ps ## 📋 使用流程 +### 首次設定 Chatwoot + +1. 訪問 http://localhost:13500 +2. 建立管理員帳號 +3. 建立新的 Inbox(收件匣): + - 名稱: TobiichiGPT + - 類型: API +4. 取得 Inbox ID(稍後需要) + ### 設定 Open WebUI -1. 開啟 http://localhost:3000 +1. 開啟 http://localhost:10060 2. 進入 **Settings** → **Connections** 3. 新增 OpenAI Connection: - - **API Base URL**: `http://api:8000/v1` + - **API Base URL**: `http://tobiichiGPT-api:8000/v1` - **API Key**: `sk-human` (任意值) 4. 模型列表會出現 **human-admin** -### 管理員回覆 +### 管理員回覆流程 -1. 訪問 NocoDB: http://localhost:8080 -2. 連接到 PostgreSQL: - - Host: `postgres` - - Port: `5432` - - Database: `tobiichi` - - Username: `tobiichi` - - Password: (你在 .env 設定的密碼) -3. 開啟 `reply_queue` 表格 -4. 查看 `status='pending'` 的訊息 -5. 填入 `admin_reply` 欄位 -6. 將 `status` 改為 `replied` -7. 用戶會在 3 秒內收到回覆 - -### 完整流程 - -``` -用戶在 Open WebUI 發送訊息 - ↓ -API 收到請求,寫入 reply_queue (status='pending') - ↓ -API 每 3 秒檢查該訊息的 status - ↓ -管理員在 NocoDB 看到訊息,填入回覆並改 status='replied' - ↓ -API 讀取 admin_reply 欄位 - ↓ -回傳給 Open WebUI - ↓ -用戶收到回覆 -``` +1. 登入 Chatwoot: http://localhost:13500 +2. 在對話列表查看新訊息 +3. 直接回覆即可,用戶會在 3 秒內收到 ## 🔧 技術架構 diff --git a/api/requirements.txt b/api/requirements.txt index c6f9c17..0093f69 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,5 +1,4 @@ fastapi==0.104.1 uvicorn==0.24.0 pydantic==2.5.0 -psycopg2-binary==2.9.9 asyncpg==0.29.0 diff --git a/api/server.py b/api/server.py index 10ad8e9..a6bae6b 100644 --- a/api/server.py +++ b/api/server.py @@ -2,7 +2,8 @@ API 轉接層 - 偽裝 OpenAI API,將請求轉為人工回覆隊列 """ -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from pydantic import BaseModel import asyncpg import asyncio @@ -74,134 +75,130 @@ async def startup(): async def shutdown(): if db_pool: await db_pool.close() + print("👋 資料庫連接已關閉") @app.get("/") async def root(): - return { - "status": "TobiichiGPT API Running", - "database": "Connected" if db_pool else "Disconnected" - } + """根路徑""" + return {"status": "ok", "service": "TobiichiGPT API"} @app.get("/v1/models") async def list_models(): - """模擬 OpenAI 模型列表""" + """模擬 OpenAI 的 /v1/models 端點""" return { "object": "list", - "data": [{ - "id": "human-admin", - "object": "model", - "created": int(time.time()), - "owned_by": "tobiichi" - }] + "data": [ + { + "id": "human-admin", + "object": "model", + "created": int(time.time()), + "owned_by": "tobiichi", + "permission": [], + "root": "human-admin", + "parent": None + } + ] } @app.post("/v1/chat/completions") async def chat_completions(request: ChatRequest): - """接收用戶訊息,等待管理員回覆""" - - # 生成對話 ID - conv_id = str(uuid.uuid4())[:12] - - # 提取用戶訊息 - user_message = "" + """ + 模擬 OpenAI Chat Completions API + 將用戶訊息寫入資料庫,等待管理員回覆 + """ + # 取得最後一則用戶訊息 + user_message = None for msg in reversed(request.messages): if msg.role == "user": user_message = msg.content break if not user_message: - return { - "id": f"chatcmpl-{conv_id}", - "object": "chat.completion", - "created": int(time.time()), - "model": request.model, - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "無法識別您的訊息,請重新輸入。" - }, - "finish_reason": "stop" - }] - } + return JSONResponse( + status_code=400, + content={"error": "No user message found"} + ) + + # 生成對話 ID + conversation_id = str(uuid.uuid4()) # 寫入資料庫 async with db_pool.acquire() as conn: - await conn.execute(""" + await conn.execute( + """ INSERT INTO reply_queue (conversation_id, user_message, status) VALUES ($1, $2, 'pending') - """, conv_id, user_message) + """, + conversation_id, user_message + ) - print(f"[新對話 {conv_id}] {user_message[:50]}...") + print(f"📝 收到訊息 [{conversation_id}]: {user_message[:50]}...") - # 等待管理員回覆(最多 15 分鐘) - timeout = 900 - start_time = time.time() - reply_content = None + # 等待管理員回覆 (最多 15 分鐘) + max_wait = 900 # 15 分鐘 + check_interval = 3 # 每 3 秒檢查一次 + waited = 0 - while time.time() - start_time < timeout: + 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 - """, conv_id) + 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']: - reply_content = row['admin_reply'] - print(f"[已回覆 {conv_id}] {reply_content[:50]}...") - break - - await asyncio.sleep(3) # 每 3 秒檢查一次 - - # 如果超時 - if not reply_content: - reply_content = "抱歉,管理員暫時無法回覆,請稍後再試。" - async with db_pool.acquire() as conn: - await conn.execute(""" - UPDATE reply_queue - SET status = 'timeout' - WHERE conversation_id = $1 - """, conv_id) + 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"⏰ 等待超時 [{conversation_id}]") return { - "id": f"chatcmpl-{conv_id}", + "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": len(user_message), - "completion_tokens": len(reply_content) if reply_content else 0, - "total_tokens": len(user_message) + (len(reply_content) if reply_content else 0) - } + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "抱歉,管理員目前忙碌中,請稍後再試。" + }, + "finish_reason": "stop" + } + ] } -@app.get("/health") -async def health(): - """健康檢查""" - db_ok = db_pool is not None - return {"status": "healthy" if db_ok else "unhealthy", "database": db_ok} - - if __name__ == "__main__": import uvicorn - print("=" * 60) - print("🚀 TobiichiGPT API 啟動中...") - print("=" * 60) - print(f"📊 資料庫: {DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}") - print(f"🌐 API 端點: http://0.0.0.0:8000/v1") - print("=" * 60) - uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/docker-compose.yml b/docker-compose.yml index 59bc04a..c3be265 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,7 +53,7 @@ services: container_name: tobiichiGPT-ui restart: unless-stopped ports: - - "13000:3000" + - "10060:8080" environment: - DATABASE_URL=postgresql://tobiichi3227:${DB_PASSWORD}@postgres:5432/tobiichiGPT - WEBUI_AUTH=True @@ -65,22 +65,47 @@ services: postgres: condition: service_healthy - # NocoDB - 管理員回覆介面 - nocodb: - image: nocodb/nocodb:latest - container_name: tobiichiGPT-nocodb + # 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 restart: unless-stopped ports: - - "18080:8080" + - "13000:3000" environment: - - NC_DB=pg://postgres:5432?u=tobiichi3227&p=${DB_PASSWORD}&d=tobiichiGPT + - 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: - - nocodb-data:/usr/app/data + - chatwoot-data:/app/storage networks: - tobiichiGPT-network depends_on: - postgres: - condition: service_healthy + - postgres + - redis + command: > + sh -c " + bundle exec rails db:chatwoot_prepare && + bundle exec rails s -b 0.0.0.0 -p 3000 + " networks: tobiichiGPT-network: @@ -88,4 +113,5 @@ networks: name: tobiichiGPT-network # 固定網路名稱,讓 proxy stack 可以連接 volumes: - nocodb-data: + redis-data: + chatwoot-data: