Initial commit

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-13 15:38:41 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 71db82393a
180 changed files with 170640 additions and 0 deletions
+198
View File
@@ -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()