385 lines
12 KiB
Python
385 lines
12 KiB
Python
"""
|
||
人工回覆伺服器 - 偽裝成 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)
|