#!/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()