Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 产品仍按下面方式在本机运行。

## 最快启动

Expand Down
2 changes: 1 addition & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions scripts/browser_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions tests/test_browser_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions web-demo/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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 终端和所选模型。",
Expand Down
3 changes: 2 additions & 1 deletion web-demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<body>
<a class="skip-link" href="#workspace">Skip to workspace</a>
<header class="topbar">
<a class="brand" href="/projects/leetcode" aria-label="Back to LeetTutor case study">
<a class="brand" href="https://tonytan.me/projects/leetcode" aria-label="Back to LeetTutor case study">
<span class="brand-mark">LT</span>
<span><strong>LeetTutor</strong><small>JARVIS Learning System</small></span>
</a>
Expand Down Expand Up @@ -106,6 +106,7 @@ <h2 data-copy="objective">This round</h2>
<label><span data-copy="bridgeUrl">Fixed loopback address</span><input id="bridge-url" value="http://127.0.0.1:8766" readonly /></label>
<label><span data-copy="model">Local model</span><select id="model"><option value="">Start companion to detect models</option></select></label>
<button id="test-connection" type="button" data-copy="test">Connect local app</button>
<a class="local-open" href="http://127.0.0.1:8766/" target="_blank" rel="noopener noreferrer" data-copy="openLocal">Open locally ↗</a>
<p id="connection-status" role="status" data-copy="notConnected">Local companion is not connected.</p>
</div>
<details open>
Expand Down
3 changes: 2 additions & 1 deletion web-demo/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down