Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
71db82393a
@@ -0,0 +1,198 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user