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
36 changes: 20 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
# LeetTutor maintainer and AI guide

LeetTutor has two supported surfaces:
LeetTutor has one product UI with three supported access paths:

- the full local Streamlit application, including code execution, progress,
local/cloud providers, and trusted-LAN host mode;
- the hosted Local App UI in `web-demo`, which calls the repository's real
curriculum, system-design catalog, constrained runner, importer, solution
store, progress store, and local model through `scripts/browser_bridge.py`
on the visitor's own loopback interface.
- the original Streamlit application in `app.py`, including code execution,
progress, local/cloud providers, and the floating JARVIS;
- trusted-LAN host mode for that same application;
- the hosted shell in `web-demo`, which embeds the unchanged original UI from
`127.0.0.1:8501` after the visitor starts `scripts/launch.py --hosted`.

Do not build or ship a second LeetTutor workspace in `web-demo`. It is only the
bilingual connection shell for the original app. UX changes belong in `app.py`
so local, hosted, and LAN access cannot drift apart again.

## Safety invariants

The public portfolio server must never proxy model traffic. The bridge must
bind only to `127.0.0.1`, allow only reviewed origins, expose only the explicit
model/app endpoint allowlists, accept bounded non-streaming JSON, and forward
model traffic only to another loopback URL. Code execution must continue to use
`leettutor.code_runner`; solution access must continue to use `SolutionStore`.
Do not store prompts, code, API keys, or responses on EC2. Do not add a public
bind flag to the companion.
The public portfolio server must never proxy the Streamlit app or model traffic.
Hosted mode must bind the original app only to `127.0.0.1`; the Caddy boundary
may frame that exact loopback origin but must not expose it publicly. The legacy
browser bridge must retain its loopback bind, reviewed origins, bounded API
allowlists, and loopback-only model upstreams. Code execution must continue to
use `leettutor.code_runner`; solution access must continue to use
`SolutionStore`. Do not store prompts, code, API keys, or responses on EC2.

Host mode is for a trusted private LAN and is not an Internet deployment.
Preserve its access-code/device-token boundary and the warnings in
Expand All @@ -29,8 +32,9 @@ Run `python -m pytest`, `python -m compileall -q app.py leettutor scripts tests`
and `node --check web-demo/app.js`. Branch from protected `master`, open a PR,
and merge after `LeetTutor quality and demo deployment` passes. A merge packages
only `web-demo`, then the repository-scoped runner atomically deploys it to
`https://tonytan.me/leetcode/`. The Python companion, full Streamlit app, user
solutions, progress, and local model are never installed on EC2.
`https://tonytan.me/leetcode/`. The shell contains no replacement workspace.
The Python process, original Streamlit UI, user solutions, progress, and local
model are never installed on EC2.

Shared Caddy, server accounts, sudo rules, and rollback policy live in the
`Personal-Website/ops` repository. Never commit `config.json`, `.env`,
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@

**LeetTutor · Made by Tony** 是互动式 LeetCode 与系统设计学习助手。内置导师 **JARVIS** 会根据薄弱项和练习进度安排下一题;界面运行在浏览器中,可以直接导入题目、写 Python、跑自定义测试,并把当前代码现场交给 AI。模型既可完全留在本机,也可选择 OpenAI API 或 Gemini API;任意能运行本项目的电脑都可以开启局域网主机模式,让同一 Wi-Fi 内的手机或平板继续刷题。

## 在线真实应用 + 本机源码 / 模型
## 在线入口 + 原版应用 / 本机模型

[tonytan.me/leetcode/](https://tonytan.me/leetcode/) 只从服务器加载仓库内的 UI 资源,不运行假题目或服务端模型。启动本机 companion 后,网页会直接调用这个仓库里的 `curriculum`、`system_design_curriculum`、`code_runner`、`leetcode_client` 与 `SolutionStore`:可以切换真实课程、导入任意 LeetCode 题目、在本机受限进程跑测试、保存到克隆仓库、记录进度,并让 JARVIS 结合真实代码和运行结果辅导
[tonytan.me/leetcode/](https://tonytan.me/leetcode/) 不再维护另一套简化 UI。启动本机应用后,网站会在当前标签页加载仓库中原本的 `app.py`:漂浮 JARVIS、左右工作区、Ace 编辑器、学习进度、模型设置和手机导航全部与直接运行原项目共用同一份 Streamlit 源码,不会再出现线上版和原版 UX 分叉

```bash
./launch_companion.command
```

浏览器版本的主入口是 `https://tonytan.me/leetcode/`。用户先自行启动 Ollama(或 LM Studio)和 `python3 scripts/browser_bridge.py`,再回到网页点击连接;之后选题、运行代码、保存进度和 JARVIS 辅导都在浏览器 UI 中完成。后端只监听回环地址,静态资源和 API 都采用固定白名单,题解、测试、进度、Prompt、模型名和回复不经过 EC2。LM Studio 使用 `python3 scripts/browser_bridge.py --upstream http://127.0.0.1:1234/v1`。需要排查浏览器本地网络权限时,可传 `--open-browser` 打开同源备用界面。完整 Streamlit 产品仍按下面方式在本机运行。
浏览器入口仍是 `https://tonytan.me/leetcode/`。用户先自行启动 Ollama(或 LM Studio),再运行 `python3 scripts/launch.py --hosted`,回到网站点击“打开原版 LeetTutor”。Hosted 模式只把原版 Streamlit 绑定到 `127.0.0.1:8501`,不会另开本机标签页;网站只负责在当前页面显示它。题解、测试、进度、Prompt、模型名和回复都留在本机,不经过 EC2。无法使用浏览器本地网络权限时,可直接打开 `http://127.0.0.1:8501/` 作为备用入口。

`scripts/browser_bridge.py` 仍保留为兼容与 API 调试工具,但不再承载主产品 UI。

## 最快启动

Expand Down
8 changes: 5 additions & 3 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@

LeetTutor is a local-first AI learning workspace for LeetCode and system design. Its JARVIS mentor chooses the next exercise from your weak areas, reads the live problem/code/test context, and teaches through short Socratic prompts instead of dumping an answer. You can run Python solutions in the browser, use either local or cloud models, and continue on a phone through a trusted home-network host.

## Hosted real app + source/model on your computer
## Hosted entry + original app/model on your computer

[tonytan.me/leetcode/](https://tonytan.me/leetcode/) downloads only versioned UI assets from the server; it does not use fake missions or a server-side model. Once the local companion is running, the page calls this repository's real curriculum, system-design catalog, constrained code runner, LeetCode importer, SolutionStore, and progress store. It can import a problem, execute tests locally, save into the clone, and give JARVIS the real code and run result.
[tonytan.me/leetcode/](https://tonytan.me/leetcode/) no longer maintains a separate simplified workspace. After the local app starts, the site loads the repository's original `app.py` in the current tab. The floating JARVIS, split workspace, Ace editor, progress, model settings, and mobile navigation therefore share the same Streamlit source as the directly launched product.

```bash
./launch_companion.command
```

The browser version lives at `https://tonytan.me/leetcode/`. The user starts Ollama (or LM Studio) and `python3 scripts/browser_bridge.py`, returns to the website, and connects; problem selection, code execution, progress, and JARVIS then stay in the browser UI. The backend binds only to loopback and serves fixed static and API allowlists. Code, tests, progress, prompts, model names, and responses never pass through EC2. For LM Studio, pass `--upstream http://127.0.0.1:1234/v1`. Use `--open-browser` only when the same-origin fallback is needed to troubleshoot browser local-network permission. The full Streamlit product remains available through the quick-start flow below.
The browser entry remains `https://tonytan.me/leetcode/`. Start Ollama (or LM Studio), run `python3 scripts/launch.py --hosted`, return to the site, and choose “Open original LeetTutor.” Hosted mode binds the original Streamlit app only to `127.0.0.1:8501` and does not open a second local tab. Code, tests, progress, prompts, model names, and responses never pass through EC2. If Chrome local-network permission is unavailable, open `http://127.0.0.1:8501/` as the direct fallback.

`scripts/browser_bridge.py` remains available for compatibility and API diagnostics, but it no longer provides the primary product UI.

## Highlights

Expand Down
4 changes: 2 additions & 2 deletions launch_companion.bat
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
setlocal
cd /d "%~dp0"
if exist ".venv\Scripts\python.exe" (
".venv\Scripts\python.exe" scripts\browser_bridge.py
".venv\Scripts\python.exe" scripts\launch.py --hosted
) else (
py -3 scripts\browser_bridge.py
py -3 scripts\launch.py --hosted
)
endlocal
4 changes: 2 additions & 2 deletions launch_companion.command
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ project_dir="$(cd "$(dirname "$0")" && pwd)"
cd "$project_dir"

if [[ -x .venv/bin/python ]]; then
exec .venv/bin/python scripts/browser_bridge.py
exec .venv/bin/python scripts/launch.py --hosted
fi

exec python3 scripts/browser_bridge.py
exec python3 scripts/launch.py --hosted
21 changes: 18 additions & 3 deletions scripts/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Allow password-protected access from other devices on this LAN.",
)
parser.add_argument(
"--hosted",
action="store_true",
help="Run the original UI on loopback for embedding at tonytan.me/leetcode/.",
)
parser.add_argument(
"--port", type=int, default=8501, help="Streamlit port (default: 8501)."
)
Expand All @@ -112,6 +117,12 @@ def parse_args() -> argparse.Namespace:

def main() -> int:
args = parse_args()
if args.lan and args.hosted:
print("[LeetTutor] --lan 与 --hosted 不能同时使用。", file=sys.stderr)
return 2
if args.hosted and args.port != 8501:
print("[LeetTutor] Hosted 模式固定使用回环端口 8501。", file=sys.stderr)
return 2
if not 1024 <= args.port <= 65535:
print("[LeetTutor] 端口必须在 1024 到 65535 之间。", file=sys.stderr)
return 2
Expand All @@ -137,7 +148,7 @@ def main() -> int:
print(f"[LeetTutor] Qwen3.5 9B GPU 服务{owner}:{AMD_METAL_ENDPOINT}")

environment = os.environ.copy()
server_address = "localhost"
server_address = "127.0.0.1" if args.hosted else "localhost"
if args.lan:
server_address = "0.0.0.0"
lan_host = find_lan_ipv4() or f"{socket.gethostname()}.local"
Expand All @@ -159,7 +170,11 @@ def main() -> int:
print("[LeetTutor] 首次验证后,可在该浏览器记住此主机 30 天。")
print("[LeetTutor] 手机与主机必须在同一 Wi-Fi;请勿把端口映射到公网。")

print("[LeetTutor] 正在启动;浏览器会自动打开。按 Ctrl+C 停止。")
if args.hosted:
print("[LeetTutor] 正在启动原版应用;请回到 https://tonytan.me/leetcode/ 点击连接。")
print("[LeetTutor] 本机备用地址:http://127.0.0.1:8501/;按 Ctrl+C 停止。")
else:
print("[LeetTutor] 正在启动;浏览器会自动打开。按 Ctrl+C 停止。")
command = [
str(python),
"-m",
Expand All @@ -168,7 +183,7 @@ def main() -> int:
str(PROJECT_ROOT / "app.py"),
f"--server.address={server_address}",
f"--server.port={args.port}",
"--server.headless=false",
f"--server.headless={'true' if args.hosted else 'false'}",
"--client.toolbarMode=viewer",
]
if metal_runtime is not None:
Expand Down
17 changes: 17 additions & 0 deletions tests/test_app_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ def test_lan_launcher_is_explicit_and_password_protected() -> None:
assert "请勿把端口映射到公网" in launcher


def test_hosted_launcher_runs_the_original_app_without_opening_another_tab() -> None:
project_root = Path(__file__).resolve().parents[1]
launcher = (project_root / "scripts" / "launch.py").read_text(encoding="utf-8")
companion_macos = (project_root / "launch_companion.command").read_text(
encoding="utf-8"
)
companion_windows = (project_root / "launch_companion.bat").read_text(
encoding="utf-8"
)

assert '"--hosted"' in launcher
assert 'server_address = "127.0.0.1" if args.hosted else "localhost"' in launcher
assert "--server.headless={'true' if args.hosted else 'false'}" in launcher
assert "scripts/launch.py --hosted" in companion_macos
assert "scripts\\launch.py --hosted" in companion_windows


def test_lan_access_gate_runs_before_the_workspace(monkeypatch) -> None:
app_path = Path(__file__).resolve().parents[1] / "app.py"
monkeypatch.setenv("LEETTUTOR_ACCESS_CODE", "12345678")
Expand Down
10 changes: 6 additions & 4 deletions tests/test_browser_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@


class BrowserBridgeValidationTests(unittest.TestCase):
def test_hosted_ui_declares_the_companion_as_loopback(self) -> None:
def test_hosted_ui_embeds_the_original_loopback_app(self) -> None:
app_source = (ROOT / "web-demo" / "app.js").read_text(encoding="utf-8")
self.assertIn('targetAddressSpace: "loopback"', app_source)
self.assertNotIn('targetAddressSpace: "local"', app_source)
self.assertIn("if (!hostedPortfolio) testConnection();", app_source)
index_source = (ROOT / "web-demo" / "index.html").read_text(encoding="utf-8")
self.assertIn('http://127.0.0.1:8501/?embed=true', app_source)
self.assertIn('id="original-app"', index_source)
self.assertIn('allow="local-network; loopback-network; local-network-access"', index_source)
self.assertNotIn('class="mentor-pane"', index_source)

def test_accepts_loopback_model_servers(self) -> None:
self.assertEqual(validate_upstream("http://127.0.0.1:11434/"), "http://127.0.0.1:11434")
Expand Down
Loading