Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
199 lines
6.7 KiB
Python
199 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
报告本地服务器 — 自带 CORS 代理
|
||
|
||
功能:
|
||
1. 以 HTTP 方式提供 output/era2/ 下的报告 HTML(解决 file:// CORS 问题)
|
||
2. /proxy/chat/completions → 转发到真实 LLM API(支持流式 SSE)
|
||
|
||
用法:
|
||
python3 serve_report.py # 默认端口 9380
|
||
python3 serve_report.py --port 8080 # 指定端口
|
||
|
||
然后浏览器打开 http://localhost:9380/复旦大学附属中学_报告.html
|
||
"""
|
||
import argparse
|
||
import http.server
|
||
import json
|
||
import sys
|
||
import threading
|
||
import urllib.request
|
||
import urllib.error
|
||
import webbrowser
|
||
from pathlib import Path
|
||
from functools import partial
|
||
|
||
# 自动定位 output/era2 目录
|
||
SCRIPT_DIR = Path(__file__).parent
|
||
OUTPUT_DIR = SCRIPT_DIR.parent.parent / "output" / "era2"
|
||
|
||
# LLM API 配置 — 统一从 backend/app/config.py 读取(唯一真相源)
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
from config_era2 import * # noqa: F401,F403 era2 本地配置(路径/学校映射等)
|
||
|
||
_BACKEND_PATH = str(SCRIPT_DIR.parent.parent / "backend")
|
||
if _BACKEND_PATH not in sys.path:
|
||
sys.path.insert(0, _BACKEND_PATH)
|
||
from app.config import LLM_BASE_URL, LLM_API_KEY # noqa: E402
|
||
|
||
|
||
class ReportHandler(http.server.SimpleHTTPRequestHandler):
|
||
"""扩展 SimpleHTTPRequestHandler,增加 CORS 代理路由"""
|
||
|
||
def __init__(self, *args, llm_base_url=None, llm_api_key=None, **kwargs):
|
||
self.llm_base_url = llm_base_url or LLM_BASE_URL
|
||
self.llm_api_key = llm_api_key or LLM_API_KEY
|
||
super().__init__(*args, **kwargs)
|
||
|
||
def end_headers(self):
|
||
"""所有响应都加 CORS 头"""
|
||
self.send_header("Access-Control-Allow-Origin", "*")
|
||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||
super().end_headers()
|
||
|
||
def do_OPTIONS(self):
|
||
"""处理 CORS 预检请求"""
|
||
self.send_response(204)
|
||
self.end_headers()
|
||
|
||
def do_POST(self):
|
||
"""代理 POST 请求到 LLM API"""
|
||
if self.path == "/proxy/chat/completions":
|
||
self._proxy_chat()
|
||
else:
|
||
self.send_error(404, "Not Found")
|
||
|
||
def _proxy_chat(self):
|
||
"""转发聊天请求到 LLM API,支持流式 SSE"""
|
||
try:
|
||
# 读取请求体
|
||
content_length = int(self.headers.get("Content-Length", 0))
|
||
body = self.rfile.read(content_length)
|
||
|
||
# 构造转发请求
|
||
api_url = self.llm_base_url.rstrip("/") + "/chat/completions"
|
||
req = urllib.request.Request(
|
||
api_url,
|
||
data=body,
|
||
headers={
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {self.llm_api_key}",
|
||
},
|
||
method="POST",
|
||
)
|
||
|
||
# 检查是否为流式请求
|
||
try:
|
||
req_json = json.loads(body)
|
||
is_stream = req_json.get("stream", False)
|
||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||
is_stream = False
|
||
|
||
# 转发请求
|
||
resp = urllib.request.urlopen(req, timeout=120)
|
||
|
||
# 发送响应头
|
||
self.send_response(resp.status)
|
||
# 传递关键响应头
|
||
for header in ["Content-Type"]:
|
||
val = resp.getheader(header)
|
||
if val:
|
||
self.send_header(header, val)
|
||
if is_stream:
|
||
self.send_header("Cache-Control", "no-cache")
|
||
self.send_header("X-Accel-Buffering", "no")
|
||
self.end_headers()
|
||
|
||
# 流式转发
|
||
if is_stream:
|
||
while True:
|
||
chunk = resp.read(1024)
|
||
if not chunk:
|
||
break
|
||
self.wfile.write(chunk)
|
||
self.wfile.flush()
|
||
else:
|
||
self.wfile.write(resp.read())
|
||
|
||
except urllib.error.HTTPError as e:
|
||
error_body = e.read().decode("utf-8", errors="replace")
|
||
self.send_response(e.code)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.end_headers()
|
||
self.wfile.write(json.dumps({
|
||
"error": {"message": f"LLM API error: {e.code}", "detail": error_body}
|
||
}).encode())
|
||
|
||
except Exception as e:
|
||
self.send_response(502)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.end_headers()
|
||
self.wfile.write(json.dumps({
|
||
"error": {"message": f"Proxy error: {str(e)}"}
|
||
}).encode())
|
||
|
||
def log_message(self, format, *args):
|
||
"""美化日志"""
|
||
msg = format % args
|
||
if "/proxy/" in msg:
|
||
sys.stderr.write(f" 🔄 PROXY {msg}\n")
|
||
elif ".html" in msg:
|
||
sys.stderr.write(f" 📄 {msg}\n")
|
||
# 静默其他请求(JS/CSS/图片等)
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="报告本地服务器(带CORS代理)")
|
||
parser.add_argument("--port", type=int, default=9380, help="端口号(默认9380)")
|
||
parser.add_argument("--no-open", action="store_true", help="不自动打开浏览器")
|
||
parser.add_argument("--dir", type=str, default=str(OUTPUT_DIR),
|
||
help=f"报告目录(默认 {OUTPUT_DIR})")
|
||
args = parser.parse_args()
|
||
|
||
serve_dir = Path(args.dir)
|
||
if not serve_dir.exists():
|
||
print(f"❌ 目录不存在: {serve_dir}")
|
||
sys.exit(1)
|
||
|
||
# 列出可用报告
|
||
reports = sorted(serve_dir.glob("*_报告.html"))
|
||
|
||
print(f"{'=' * 60}")
|
||
print(f"🌐 报告本地服务器")
|
||
print(f"{'=' * 60}")
|
||
print(f" 目录: {serve_dir}")
|
||
print(f" 地址: http://localhost:{args.port}")
|
||
print(f" 代理: /proxy/chat/completions → {LLM_BASE_URL}")
|
||
print(f" 报告: {len(reports)} 份")
|
||
for r in reports:
|
||
url = f"http://localhost:{args.port}/{r.name}"
|
||
print(f" 📊 {url}")
|
||
print(f"{'=' * 60}")
|
||
print(f" 按 Ctrl+C 停止\n")
|
||
|
||
# 创建 handler,绑定到报告目录
|
||
handler = partial(
|
||
ReportHandler,
|
||
directory=str(serve_dir),
|
||
llm_base_url=LLM_BASE_URL,
|
||
llm_api_key=LLM_API_KEY,
|
||
)
|
||
|
||
server = http.server.HTTPServer(("0.0.0.0", args.port), handler)
|
||
|
||
# 自动打开第一份报告
|
||
if not args.no_open and reports:
|
||
url = f"http://localhost:{args.port}/{reports[0].name}"
|
||
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
|
||
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("\n\n👋 服务器已停止")
|
||
server.server_close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|