diff --git a/README.md b/README.md index 7cd5d94..a05da6a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ./launch_companion.command ``` -Windows 双击 `launch_companion.bat`;也可以运行 `python3 scripts/browser_bridge.py`。Companion 只监听 `127.0.0.1:8766`,只接受本站和本机开发 Origin;应用 API 固定调用本仓库的受限功能,模型 API 只能转发到本机 OpenAI 兼容端点。题解、测试、进度、Prompt、模型名和回复都不经过 EC2。LM Studio 使用 `python3 scripts/browser_bridge.py --upstream http://127.0.0.1:1234/v1`。完整 Streamlit 产品仍按下面方式在本机运行。 +Windows 双击 `launch_companion.bat`;也可以运行 `python3 scripts/browser_bridge.py`。Companion 会直接打开 `http://127.0.0.1:8766/`,该页面和 `tonytan.me/leetcode/` 共用 `web-demo/` 源码,但同源访问不依赖浏览器的本地网络权限。Companion 只监听回环地址,静态资源采用固定白名单;应用 API 固定调用本仓库的受限功能,模型 API 只能转发到本机 OpenAI 兼容端点。题解、测试、进度、Prompt、模型名和回复都不经过 EC2。LM Studio 使用 `python3 scripts/browser_bridge.py --upstream http://127.0.0.1:1234/v1`。完整 Streamlit 产品仍按下面方式在本机运行。 ## 最快启动 diff --git a/README_EN.md b/README_EN.md index b36a9af..9e529d9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -12,7 +12,7 @@ LeetTutor is a local-first AI learning workspace for LeetCode and system design. ./launch_companion.command ``` -On Windows, double-click `launch_companion.bat`; `python3 scripts/browser_bridge.py` is the direct equivalent. The companion binds only to `127.0.0.1:8766`, accepts only the portfolio and local-development origins, exposes a fixed allowlist of source-backed app APIs, and forwards model calls only to a loopback OpenAI-compatible endpoint. Code, tests, progress, prompts, model names, and responses never pass through EC2. For LM Studio, pass `--upstream http://127.0.0.1:1234/v1`. The full Streamlit product remains available through the quick-start flow below. +On Windows, double-click `launch_companion.bat`; `python3 scripts/browser_bridge.py` is the direct equivalent. The companion opens `http://127.0.0.1:8766/`, which shares the hosted page's `web-demo/` source but uses same-origin APIs without depending on browser local-network permission. It binds only to loopback, serves a fixed static allowlist, exposes a fixed allowlist of source-backed app APIs, and forwards model calls only to a loopback OpenAI-compatible endpoint. Code, tests, progress, prompts, model names, and responses never pass through EC2. For LM Studio, pass `--upstream http://127.0.0.1:1234/v1`. The full Streamlit product remains available through the quick-start flow below. ## Highlights diff --git a/scripts/browser_bridge.py b/scripts/browser_bridge.py index 0087846..4cea508 100644 --- a/scripts/browser_bridge.py +++ b/scripts/browser_bridge.py @@ -17,6 +17,7 @@ from pathlib import Path import re import sys +import threading from typing import Final from urllib import error as urllib_error from urllib import parse as urllib_parse @@ -49,6 +50,12 @@ "/api/solutions/save", } LOCAL_ORIGIN = re.compile(r"^https?://(?:localhost|127\.0\.0\.1)(?::\d+)?$") +STATIC_PATHS: Final = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/styles.css": ("styles.css", "text/css; charset=utf-8"), +} def validate_upstream(value: str) -> str: @@ -115,6 +122,13 @@ def _headers(self, length: int, content_type: str, status: HTTPStatus) -> None: self.send_header("Content-Length", str(length)) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; style-src 'self'; script-src 'self'; " + "connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; " + "base-uri 'none'; form-action 'self'", + ) origin = self.headers.get("Origin", "") if origin and self._origin_allowed(): self.send_header("Access-Control-Allow-Origin", origin) @@ -148,6 +162,16 @@ def do_GET(self) -> None: # noqa: N802 self._json({"error": "Origin is not allowed."}, HTTPStatus.FORBIDDEN) return path = urllib_parse.urlparse(self.path).path + if path in STATIC_PATHS: + filename, content_type = STATIC_PATHS[path] + try: + body = (self.server.project_root / "web-demo" / filename).read_bytes() + except OSError: + self._json({"error": "Local UI asset unavailable."}, HTTPStatus.INTERNAL_SERVER_ERROR) + return + self._headers(len(body), content_type, HTTPStatus.OK) + self.wfile.write(body) + return if path == "/healthz": self._json({ "ok": True, @@ -339,6 +363,7 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="LeetTutor loopback model bridge") parser.add_argument("--port", type=int, default=8766) parser.add_argument("--upstream", type=validate_upstream, default="http://127.0.0.1:11434") + parser.add_argument("--no-browser", action="store_true", help="Do not open the local source UI") parser.add_argument( "--allow-origin", action="append", @@ -359,6 +384,10 @@ def main() -> int: server.progress_path = Path.home() / ".leettutor" / "progress.json" print(f"LeetTutor bridge: http://127.0.0.1:{args.port} -> {args.upstream}") print("Prompts and responses stay between this browser and your computer.") + if not args.no_browser: + import webbrowser + + threading.Timer(0.6, lambda: webbrowser.open(f"http://127.0.0.1:{args.port}/")).start() try: server.serve_forever(poll_interval=0.25) except KeyboardInterrupt: diff --git a/tests/test_browser_bridge.py b/tests/test_browser_bridge.py index d8eabce..a912d99 100644 --- a/tests/test_browser_bridge.py +++ b/tests/test_browser_bridge.py @@ -86,6 +86,16 @@ def test_catalog_and_real_code_runner_are_available_to_first_party_ui(self) -> N "https://tonytan.me", ) + with urllib_request.urlopen(base_url + "/", timeout=3) as response: + self.assertEqual(response.status, 200) + self.assertIn(b"LeetTutor", response.read()) + self.assertEqual( + response.headers["Content-Security-Policy"], + "default-src 'self'; style-src 'self'; script-src 'self'; " + "connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; " + "base-uri 'none'; form-action 'self'", + ) + body = json.dumps( { "source": "class Solution:\n def add(self, a, b):\n return a + b\n", diff --git a/web-demo/app.js b/web-demo/app.js index b461f31..97702ab 100644 --- a/web-demo/app.js +++ b/web-demo/app.js @@ -13,9 +13,9 @@ const copy = { connectionEyebrow: "LOCAL COMPANION", connectionTitle: "Connect the hosted UI to the source on this computer.", connectionBody: "The companion imports LeetTutor’s real curriculum, code runner and problem client, and forwards model requests only to loopback Ollama or LM Studio. EC2 never receives code, prompts or responses.", bridgeUrl: "Fixed loopback address", model: "Local model", test: "Connect local app", notConnected: "Local companion is not connected.", - setup: "Start the real local app", setup1: "Clone or update the Leetcode repository, then start Ollama or LM Studio.", setup3: "Return here and choose “Connect local app.”", source: "Get source and companion ↗", + setup: "Start the real local app", setup1: "Clone or update the Leetcode repository, then start Ollama or LM Studio.", setup3: "The launcher opens this same source UI locally; hosted direct-connect remains optional.", openLocal: "Open locally ↗", source: "Get source and companion ↗", connected: "Connected: {problems} algorithm missions, {systems} system-design missions, {models} local model(s).", - failed: "Local companion unavailable. Run python3 scripts/browser_bridge.py, allow tonytan.me local-network access in the browser, then retry.", + failed: "Local companion unavailable. Run python3 scripts/browser_bridge.py, then use the local window it opens or retry here.", loading: "Loading from this computer…", imported: "Imported {title} through the local source client.", importFailed: "Could not import this problem locally.", ready: "READY", running: "RUNNING", passed: "PASSED", failedRun: "FAILED", saved: "Saved as {path} on this computer.", saveFailed: "Could not save; an existing solution is never overwritten automatically.", thinking: "JARVIS is thinking on your computer…", coldStart: "The local model is still loading on this computer; the first answer can take longer.", requestFailed: "The local model did not answer. Check the companion terminal and selected model.", @@ -34,9 +34,9 @@ const copy = { connectionEyebrow: "本机 COMPANION", connectionTitle: "把托管界面连接到这台电脑上的真实源码。", connectionBody: "Companion 直接导入 LeetTutor 的真实题库、代码运行器和题目客户端,并且只把模型请求转发给回环地址上的 Ollama 或 LM Studio;EC2 收不到代码、Prompt 或回答。", bridgeUrl: "固定回环地址", model: "本机模型", test: "连接本机应用", notConnected: "尚未连接本机 companion。", - setup: "启动真实本机应用", setup1: "克隆或更新 Leetcode 仓库,然后启动 Ollama 或 LM Studio。", setup3: "回到这里点击“连接本机应用”。", source: "获取源码与 companion ↗", + setup: "启动真实本机应用", setup1: "克隆或更新 Leetcode 仓库,然后启动 Ollama 或 LM Studio。", setup3: "启动器会在本机打开同一套源码界面;托管页直连仍可选。", openLocal: "在本机打开 ↗", source: "获取源码与 companion ↗", connected: "已连接:{problems} 个算法任务、{systems} 个系统设计任务、{models} 个本机模型。", - failed: "无法连接本机 companion。请在仓库运行 python3 scripts/browser_bridge.py,并允许浏览器授予 tonytan.me 本地网络访问权限后重试。", + failed: "无法连接本机 companion。请在仓库运行 python3 scripts/browser_bridge.py,然后使用自动打开的本机窗口或在此重试。", loading: "正在从这台电脑加载…", imported: "已通过本机源码客户端导入 {title}。", importFailed: "无法在本机导入这道题。", ready: "就绪", running: "运行中", passed: "通过", failedRun: "未通过", saved: "已保存到这台电脑:{path}", saveFailed: "保存失败;现有题解绝不会被自动覆盖。", thinking: "JARVIS 正在你的电脑上思考…", coldStart: "本机模型仍在加载;第一次回答可能更慢。", requestFailed: "本机模型没有返回;请检查 companion 终端和所选模型。", diff --git a/web-demo/index.html b/web-demo/index.html index 24fbfeb..96c190d 100644 --- a/web-demo/index.html +++ b/web-demo/index.html @@ -12,7 +12,7 @@
- + LT LeetTutorJARVIS Learning System @@ -106,6 +106,7 @@

This round

+ Open locally ↗

Local companion is not connected.

diff --git a/web-demo/styles.css b/web-demo/styles.css index 1187143..5ca9da2 100644 --- a/web-demo/styles.css +++ b/web-demo/styles.css @@ -99,7 +99,8 @@ pre { overflow: auto; padding: 15px; border-radius: 10px; background: var(--pane .editor-pane .saved { color: #89d5a0; } #code { width: 100%; min-height: 500px; resize: none; border: 0; outline: 0; padding: 28px; background: transparent; color: #e8eef6; font: 14px/1.75 "SFMono-Regular", Consolas, monospace; tab-size: 4; } .editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid #283140; color: #96a4b6; font-size: .7rem; } -.editor-footer button, .chat-form button, .connection-form button { padding: 10px 14px; border: 0; border-radius: 8px; background: var(--accent); color: #18130a; font-weight: 850; } +.editor-footer button, .chat-form button, .connection-form button, .connection-form .local-open { padding: 10px 14px; border: 0; border-radius: 8px; background: var(--accent); color: #18130a; font-weight: 850; } +.connection-form .local-open { display: block; text-align: center; text-decoration: none; } .editor-actions { display: flex; justify-content: flex-end; gap: 8px; } .editor-actions .secondary { width: auto; border-color: #3a4658; background: transparent; color: #d7e0ec; }