Files
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 71db82393a Initial commit
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-07-13 15:38:41 +08:00

199 lines
6.7 KiB
Python
Raw Permalink 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.
#!/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()