Files
tobiichiGPT/human-gpt/human_reply_server.py
2025-12-20 12:16:48 +08:00

385 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
人工回覆伺服器 - 偽裝成 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)