diff --git a/AGENTS.md b/AGENTS.md index 1c60ff1..dc53066 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,8 @@ LeetTutor has one product experience with two maintained implementations: progress, local/cloud providers, and the floating JARVIS; - trusted-LAN host mode for that same application; - the browser-hosted application in `web-demo`, which preserves the original - split-workspace and floating-JARVIS UX while connecting directly to Ollama on - `127.0.0.1:11434`. It requires no local Python or Streamlit process. + training controls, split workspace, floating/docked JARVIS, system-design + mode, and provider settings. It requires no local Python or Streamlit process. Treat `app.py` as the product reference. A hosted UX change must be checked against the Streamlit behavior, translated in Chinese and English, and usable @@ -18,11 +18,13 @@ Streamlit implementations use different rendering runtimes. ## Safety invariants The public portfolio server must never proxy model traffic. The browser UI may -call only Ollama's loopback API; Caddy must allow the exact 11434 loopback -origins without exposing that port publicly. Hosted Python execution stays in a -bounded Web Worker via Pyodide, and browser drafts/progress stay in local -storage. The legacy browser bridge must retain its loopback bind, reviewed -origins, bounded API allowlists, and loopback-only model upstreams. The +call only the explicitly configured provider directly: reviewed loopback ports +for Ollama, LM Studio, and AMD Metal, or the official OpenAI/Gemini HTTPS API. +Caddy must keep an exact `connect-src` allowlist without exposing local ports +publicly. Cloud API keys are session-only and must never enter local storage. +Hosted Python execution stays in a bounded Web Worker via Pyodide, and browser +drafts/progress stay in local storage. The legacy browser bridge must retain its +loopback bind, reviewed origins, bounded API allowlists, and loopback-only model upstreams. The Streamlit app must continue to use `leettutor.code_runner` and `SolutionStore`. Do not store prompts, code, API keys, responses, model files, or progress on EC2. diff --git a/README.md b/README.md index 919a1d6..a8a79d9 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ ## 在线入口 + 本机 Ollama -[tonytan.me/leetcode/](https://tonytan.me/leetcode/) 是完整的浏览器版产品:左右题目/代码工作区、漂浮 JARVIS、学习进度、中英文和明暗主题都直接由主页托管。它不再加载本机 Streamlit,也不要求克隆仓库或启动 Python。 +[tonytan.me/leetcode/](https://tonytan.me/leetcode/) 以 `app.py` 为产品规格保留完整浏览器体验:导师训练控制台、左右题目/代码工作区、可停靠或拖动的 JARVIS、真实浏览器 Python 测试、系统设计 Mission Control / JARVIS Live、学习进度、中英文、明暗主题和手机底部导航都由主页直接托管。它不再加载本机 Streamlit,也不要求克隆仓库或启动 Python。 -页面会直接检测 `127.0.0.1:11434`:Ollama 已运行时自动读取本机模型;未运行时可通过 `ollama://` 请求系统打开;仍不可用时会区分浏览器权限、`OLLAMA_ORIGINS`、缺少应用和缺少模型,并显示对应操作。Python 测试在浏览器隔离 Worker 中运行;草稿和进度保存在这个浏览器;Prompt 与回答只在浏览器和本机 Ollama 之间传输,不经过 EC2。 +页面默认直接检测 `127.0.0.1:11434`:Ollama 已运行时自动读取本机模型;未运行时可通过 `ollama://` 请求系统打开;仍不可用时会区分浏览器权限、`OLLAMA_ORIGINS`、缺少应用和缺少模型,并显示对应操作。左上角 **LT** 模型中心也保留原版的 LM Studio、AMD Metal、OpenAI 和 Gemini Provider;本地端点直接访问 loopback,云端 API Key 只存在当前标签页。Python 测试在浏览器隔离 Worker 中运行;草稿和进度保存在这个浏览器;Prompt 与回答只在浏览器和所选 Provider 之间传输,不经过 EC2。 `app.py`、局域网模式和 `scripts/browser_bridge.py` 仍保留为本地开发、可信局域网和兼容调试入口,但都不是在线版的前置依赖。 diff --git a/README_EN.md b/README_EN.md index 88ad427..0c96073 100644 --- a/README_EN.md +++ b/README_EN.md @@ -6,9 +6,9 @@ LeetTutor is a local-first AI learning workspace for LeetCode and system design. ## Hosted entry + local Ollama -[tonytan.me/leetcode/](https://tonytan.me/leetcode/) is the complete browser product. The split problem/code workspace, floating JARVIS, progress, bilingual copy, and system/light/dark appearance are hosted on the portfolio itself. It no longer embeds local Streamlit and does not require cloning the repository or starting Python. +[tonytan.me/leetcode/](https://tonytan.me/leetcode/) uses `app.py` as its product specification and preserves the full browser experience: tutor mission controls, the split problem/code workspace, draggable or docked JARVIS, real browser-side Python tests, System Design Mission Control / JARVIS Live, progress, bilingual copy, system/light/dark appearance, and mobile bottom navigation. It no longer embeds local Streamlit and does not require cloning the repository or starting Python. -The page detects `127.0.0.1:11434` directly. It reads local models when Ollama is ready, requests the operating system to open Ollama through `ollama://` when it is stopped, and distinguishes browser permission, `OLLAMA_ORIGINS`, missing-app, and missing-model states when the API remains unavailable. Python tests run in an isolated browser Worker, drafts and progress stay in this browser, and prompts and answers travel only between the browser and local Ollama—not through EC2. +The page detects `127.0.0.1:11434` directly by default. It reads local models when Ollama is ready, requests the operating system to open Ollama through `ollama://` when it is stopped, and distinguishes browser permission, `OLLAMA_ORIGINS`, missing-app, and missing-model states when the API remains unavailable. The **LT** model center also retains the original LM Studio, AMD Metal, OpenAI, and Gemini providers. Local endpoints remain on loopback, and a cloud API key exists only in the current tab. Python tests run in an isolated browser Worker, drafts and progress stay in this browser, and prompts and answers travel only between the browser and the selected provider—not through EC2. `app.py`, trusted-LAN mode, and `scripts/browser_bridge.py` remain available for local development, private-network access, and compatibility diagnostics, but none is a prerequisite for the hosted product. diff --git a/scripts/export_web_catalog.py b/scripts/export_web_catalog.py index c8569f9..d3467b8 100644 --- a/scripts/export_web_catalog.py +++ b/scripts/export_web_catalog.py @@ -14,17 +14,62 @@ sys.path.insert(0, str(PROJECT_ROOT)) from leettutor.curriculum import PROBLEMS # noqa: E402 +from leettutor.prompts import ( # noqa: E402 + ALGORITHM_SYSTEM_PROMPT, + SYSTEM_DESIGN_SYSTEM_PROMPT, +) from leettutor.system_design_curriculum import SYSTEM_DESIGN_CASES # noqa: E402 +PROBLEM_ENGLISH_COPY: dict[int, tuple[str, str]] = { + 704: ("Establish a closed search interval and its convergence invariant", "At loop exit, what do left and right represent?"), + 35: ("Turn exact lookup into finding the first feasible position", "What condition means mid can still be the answer?"), + 34: ("Unify lower and upper bounds without ambiguous equality cases", "With while left < right, what makes an index feasible for the first target?"), + 153: ("Use right as a reliable reference while preserving the minimum candidate", "What half can nums[mid] versus nums[right] safely eliminate?"), + 33: ("Identify the sorted half and decide whether target lies inside it", "Which half must be sorted on every iteration?"), + 875: ("Advance from searching indices to searching the answer space", "As speed increases, how does completion time change monotonically?"), + 410: ("Rewrite an optimization problem as a feasibility decision", "Given a maximum segment sum limit, how can you decide whether it is feasible?"), + 20: ("Define the stack as the set of unmatched opening tokens", "When a closing bracket arrives, what should be resolved?"), + 155: ("Maintain auxiliary state in lockstep with the main stack", "After a pop, how is the previous minimum restored?"), + 496: ("Understand which elements are waiting to be resolved in a monotonic stack", "What relation between the stack top and current value should trigger a pop?"), + 739: ("Store indices in the stack so distance remains available", "Why is storing temperatures alone insufficient?"), + 394: ("Use a stack to preserve the context of each nesting level", "Which pieces of state must be saved before entering a new bracket?"), + 84: ("Use a monotonic stack to find the first shorter bar on each side", "When does a bar's complete usable width become known?"), + 42: ("Resolve each basin with an explicit height and width", "Geometrically, what does a popped bar represent?"), + 215: ("Keep a fixed-size min-heap", "What should the heap top mean among the retained elements?"), + 347: ("Combine a frequency table with a fixed-size heap", "Why does a size-k min-heap make eviction straightforward?"), + 973: ("State exactly what the heap top and retained set mean", "For a size-k heap, should the top be the nearest or farthest retained point?"), + 23: ("Keep only the current candidate from each sorted list", "Why does the heap never need more than k entries?"), + 295: ("Maintain partition and size invariants with two heaps", "Which half belongs in each heap, and how far may their sizes differ?"), + 70: ("Define a one-dimensional state from the recursive question", "Does dp[i] count ways to reach i or ways remaining after i?"), + 198: ("Write the choices to take or skip the current house", "Which two choices produce dp[i]?"), + 213: ("Split the cycle into two mutually exclusive linear cases", "Why can the first and last houses never appear together?"), + 322: ("Choose an unreachable initial value for minimum-count DP", "Why can the initial array not be all zeroes?"), + 518: ("Use iteration order to distinguish combinations from permutations", "Why does iterating coins first avoid recounting orderings?"), + 300: ("Distinguish a subsequence ending at i from one using the first i items", "If dp[i] means ending at i, must the answer be dp[-1]?"), + 1143: ("Define a two-dimensional prefix state and the character-match choice", "When the last characters differ, which two subproblems should be compared?"), + 72: ("Map insert, delete, and replace to coordinate movement", "After one operation, which of i and j decreases?"), + 312: ("Reverse the choice by selecting the last balloon to burst", "Why is choosing the last balloon easier for defining boundaries than choosing the first?"), +} + + def build_catalog() -> dict[str, object]: return { - "schema_version": 1, + "schema_version": 2, "problems": [ - {**asdict(problem), "url": f"https://leetcode.com/problems/{problem.slug}/"} + { + **asdict(problem), + "focus_en": PROBLEM_ENGLISH_COPY[problem.id][0], + "invariant_prompt_en": PROBLEM_ENGLISH_COPY[problem.id][1], + "url": f"https://leetcode.com/problems/{problem.slug}/", + } for problem in PROBLEMS ], "system_design": [asdict(case) for case in SYSTEM_DESIGN_CASES], + "prompts": { + "algorithm": ALGORITHM_SYSTEM_PROMPT, + "system_design": SYSTEM_DESIGN_SYSTEM_PROMPT, + }, } diff --git a/tests/test_browser_bridge.py b/tests/test_browser_bridge.py index e2628d9..cf3bb71 100644 --- a/tests/test_browser_bridge.py +++ b/tests/test_browser_bridge.py @@ -47,6 +47,31 @@ def test_committed_browser_catalog_matches_python_source(self) -> None: committed = json.loads((ROOT / "web-demo" / "catalog.json").read_text(encoding="utf-8")) self.assertEqual(committed, build_catalog()) + def test_hosted_ui_keeps_the_original_product_surfaces(self) -> None: + index_source = (ROOT / "web-demo" / "index.html").read_text(encoding="utf-8") + app_source = (ROOT / "web-demo" / "app.js").read_text(encoding="utf-8") + catalog = json.loads((ROOT / "web-demo" / "catalog.json").read_text(encoding="utf-8")) + for element_id in ( + "settings-drawer", + "algorithm-session-title", + "algorithm-workspace", + "problem-pane", + "code-pane", + "docked-mentor", + "system-mission-title", + "system-live-content", + "system-command-form", + "mobile-nav", + ): + with self.subTest(element_id=element_id): + self.assertIn(f'id="{element_id}"', index_source) + self.assertIn("chooseNextProblem", app_source) + self.assertIn("executePython", app_source) + self.assertIn("askJarvis", app_source) + self.assertIn("renderSystem", app_source) + self.assertTrue(catalog["prompts"]["algorithm"]) + self.assertTrue(catalog["prompts"]["system_design"]) + def test_accepts_loopback_model_servers(self) -> None: self.assertEqual(validate_upstream("http://127.0.0.1:11434/"), "http://127.0.0.1:11434") self.assertEqual(validate_upstream("http://localhost:1234/v1"), "http://localhost:1234/v1") diff --git a/web-demo/app.js b/web-demo/app.js index 4f6fcbd..15a0a74 100644 --- a/web-demo/app.js +++ b/web-demo/app.js @@ -1,304 +1,239 @@ "use strict"; -const OLLAMA_BASE = "http://127.0.0.1:11434"; +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; +const STORAGE = "leettutor-web-v2"; const RECOMMENDED_MODEL = "qwen3.5:9b"; -const $ = (selector) => document.querySelector(selector); -const $$ = (selector) => [...document.querySelectorAll(selector)]; +const PROVIDERS = { + ollama: { endpoint: "http://127.0.0.1:11434", model: RECOMMENDED_MODEL }, + lmstudio: { endpoint: "http://127.0.0.1:1234", model: "local-model" }, + amdmetal: { endpoint: "http://127.0.0.1:11435", model: "qwen3.5-9b" }, + openai: { endpoint: "https://api.openai.com", model: "gpt-5-mini", key: true }, + gemini: { endpoint: "https://generativelanguage.googleapis.com", model: "gemini-2.5-flash", key: true }, +}; const copy = { en: { - skip: "Skip to workspace", checkingModel: "Checking local Ollama…", algorithms: "Algorithms", systemDesign: "System design", hideProblem: "Hide problem", - connection: "Connection", settings: "Settings", fixConnection: "Fix connection", problem: "Problem", code: "Code", - track: "Track", mission: "Mission", objective: "Think first", openProblem: "Open on LeetCode ↗", newMission: "New mission", - markReview: "Mark for review", markMastered: "Mark mastered", savedLocally: "Saved in this browser", editorLabel: "Solution editor", - editorHelp: "Tab indent · Shift+Tab outdent · ⌘/Ctrl+F search", testsAndRun: "Tests and run settings", - methodName: "Solution method", timeout: "Timeout", testCases: "JSON test cases", run: "▶ Run in browser", - runAndAsk: "Run and ask JARVIS", mentorRole: "AI interview copilot", mentorOpening: "Connect your local Ollama model and I will coach against the code and results visible in this browser.", - yourReasoning: "Your reasoning", messagePlaceholder: "Explain your next step…", resetPosition: "Reset position", send: "Send", - localModel: "LOCAL MODEL", connectOllama: "Connect Ollama", model: "Local model", continue: "Continue to workspace", - openOllama: "Open Ollama", retry: "Check again", launchExplanation: "Your browser may ask permission to open the Ollama app. This site cannot start software without your confirmation.", - missingExplanation: "If Ollama did not open, it may not be installed. Install it once, open it, then return here. LeetTutor itself requires no installation.", - downloadOllama: "Download Ollama ↗", installedRetry: "I installed/opened it — check again", originExplanation: "Ollama is running, but it has not allowed tonytan.me. Run this one-time command, quit Ollama completely, and reopen it.", - copy: "Copy", permissionExplanation: "Allow Local network access for tonytan.me in the browser site settings, refresh this page, and check again.", - noModelsExplanation: "Ollama is ready but no local model was found. Download the recommended tutor model directly into Ollama.", - modelToDownload: "Model to download", downloadModel: "Download model", privacyTitle: "Privacy and requirements", - privacyBody: "Prompts, code, model names, and answers go directly between this browser and Ollama on 127.0.0.1. They are not relayed through tonytan.me.", - preferences: "PREFERENCES", languageAppearance: "Language and appearance", language: "Interface language", theme: "Appearance", - checkingTitle: "Checking this computer", checkingBody: "Looking for Ollama on the loopback-only API.", - readyTitle: "Local Ollama is ready", readyBody: "Connected directly to {count} model(s) on this computer.", - offlineTitle: "Ollama was not reached", offlineBody: "It may be closed or not installed. Try opening it first; if no app opens, use the installation guide.", - launchingTitle: "Waiting for Ollama", launchingBody: "Approve the browser prompt if it appears. LeetTutor will connect automatically when the local API is ready.", - missingTitle: "Ollama may be missing", missingBody: "No local API appeared after the launch attempt. Install Ollama, open it, and check again.", - permissionTitle: "Browser permission is blocked", permissionBody: "tonytan.me cannot inspect localhost until Local network access is allowed for this site.", - originTitle: "Ollama is running but blocked this website", originBody: "The local API responded, but browser access from tonytan.me is not in OLLAMA_ORIGINS.", - noModelsTitle: "Ollama has no local models", noModelsBody: "The app is running. Download one model before asking JARVIS.", - modelReady: "Ollama · {model} · local only", modelUnavailable: "Local Ollama unavailable", modelMissing: "Ollama ready · model required", - connectionNeeded: "Connect a local model", connectionNeededBody: "Code practice works in the browser; JARVIS needs Ollama on this computer.", - noModelBanner: "Download a local model", noModelBannerBody: "Ollama is connected but its model list is empty.", - copied: "Command copied", pullStarting: "Starting local model download…", pullProgress: "Downloading {percent}% · {status}", pullComplete: "Model downloaded. Refreshing the local model list…", pullFailed: "Model download failed: {error}", - loadingCatalog: "Loading the curriculum…", catalogFailed: "The curriculum could not be loaded.", - progressReview: "Marked for review", progressMastered: "Mastered", progressNew: "New mission", - pythonOnly: "The in-browser runner currently supports Python. Java remains editable and locally saved.", - runnerLoading: "Loading the private in-browser Python runtime for the first run…", runnerRunning: "Running in an isolated browser worker…", runnerTimeout: "Execution timed out and the worker was reset.", - runnerFailed: "Browser runner failed: {error}", testsPassed: "{passed}/{total} tests passed", jarvisNeedsModel: "Open the connection panel and choose a local Ollama model first.", - jarvisThinking: "JARVIS is thinking on this computer…", jarvisFailed: "The local model did not answer: {error}", emptyMessage: "Explain your reasoning or ask a question first.", - topicsAll: "All topics", systemTrack: "System design cases", genericFocus: "Load the original problem statement on LeetCode, define the invariant, then implement and test your solution.", + skip: "Skip to workspace", modelService: "Model service", apiKeyNote: "Kept only in this tab and never sent to tonytan.me.", hardwareSetup: "Hardware detection and model installation", openOllama: "Open Ollama", setupGuide: "Setup guide", refreshModels: "Check service and refresh models", detectedModels: "Detected models", manualModel: "Enter manually…", autoTune: "Auto-tune for hardware and model", generationSettings: "Generation settings", timeoutSeconds: "Timeout (seconds)", contextTokens: "Context tokens", reasoningEffort: "Reasoning effort", maxOutput: "Maximum output tokens", rolePrompts: "Role prompts (editable)", algorithmInterviewer: "Algorithm interviewer", systemArchitect: "System architect", saveSettings: "Save settings", clearChat: "Clear chat", openVscode: "Open repository in VS Code", localPrivacy: "Code runs only when requested. Local-provider prompts travel directly from this browser to the configured loopback endpoint.", + checkingModel: "Checking local model…", algorithms: "Algorithms", systemDesign: "System design", language: "Language", interfaceLanguage: "Interface language", statementLanguage: "LeetCode statement", layout: "Layout", visiblePanes: "Visible panes", problem: "Problem", code: "Code", tutor: "Tutor", tutorStyle: "Tutor style", docked: "Docked", floating: "Floating", restoreLayout: "Restore default layout", startCoaching: "Start coaching", nextProblem: "JARVIS, next problem", trainingSettings: "Training settings", trainingTrack: "Training track", difficulty: "Difficulty", progressive: "Progressive", openLeetcode: "Open LeetCode", markMastered: "Mark mastered", reviewLater: "Review later", visualMap: "Visual map · See the structure first", mentalModel: "Current mental model", visualMapHelp: "This is a scalable scaffold, not the full answer. JARVIS can redraw it from your current work.", redrawJarvis: "Ask JARVIS to redraw my current state", hideProblem: "Hide problem", roundGoal: "Round goal:", thinkFirst: "Think first:", statementMissing: "The full statement is not loaded yet. Load it once and it stays beside your editor.", loadStatement: "Load full statement and Python template", changeProblem: "Change problem / Import manually", problemReference: "LeetCode URL or slug", importWorkspace: "Import into workspace", hideCode: "Hide code", loadSolution: "Load an existing solution", newFile: "(New file)", load: "Load", upload: "Upload", editorLabel: "Solution editor", editorHelp: "Tab indent · Shift+Tab outdent · auto-indent · line numbers and syntax highlighting · ⌘/Ctrl+F search", testsAndRun: "Tests and run settings", methodName: "Solution method", testCases: "Test cases (JSON)", run: "▶ Run", runAndAsk: "Run and ask tutor", saveRepository: "Save to repository", filename: "Filename", saveCode: "Save current code", allowOverwrite: "Allow overwrite", mentorRole: "AI INTERVIEW COPILOT", watching: "Watching:", turnRule: "Each turn: one short hint and one question. I wait for your answer before continuing.", sendJarvis: "Send to JARVIS", stuck: "I'm stuck", nextStep: "Next step", allHidden: "All three workspace panes are hidden. Use Layout to restore them.", + startStress: "Start stress test", assignMission: "JARVIS, assign mission", missionSettings: "Mission settings", missionTrack: "Mission track", autoRotation: "Auto rotation", scaling: "Scaling", reliability: "Reliability", realtime: "Realtime", dataPlatform: "Data platform", transactions: "Transactions", currentCheckpoint: "Current checkpoint:", redrawArchitecture: "Ask JARVIS to redraw my architecture", customMission: "Customize the mission", designRequirement: "Design requirement", liveSubtitle: "The page shows this turn; the floating window keeps full history", ready: "Ready", liveEmpty: "Answer the current checkpoint or use Next step below; progress and the new reply will appear here.", commandDock: "JARVIS command bar · full conversation at bottom-right", send: "Send", nextStepOnly: "Next step only", replyJarvis: "Reply to JARVIS", dockRight: "Dock to right pane", hideTutor: "Hide tutor", + localModel: "LOCAL MODEL", connectOllama: "Connect Ollama", continue: "Continue to workspace", retry: "Check again", launchExplanation: "Your browser may ask permission to open Ollama. This site cannot start software without your confirmation.", missingExplanation: "If Ollama did not open, install it once, open it, and return here. LeetTutor itself requires no installation.", downloadOllama: "Download Ollama ↗", installedRetry: "I installed/opened it — check again", originExplanation: "Ollama is running but has not allowed tonytan.me. Run this command, quit Ollama completely, and reopen it.", copy: "Copy", permissionExplanation: "Allow Local network access for tonytan.me in browser site settings, refresh, and check again.", noModelsExplanation: "Ollama is ready but no local model was found.", modelToDownload: "Model to download", downloadModel: "Download model", privacyTitle: "Privacy and requirements", privacyBody: "Prompts, code, model names, and answers go directly between this browser and the selected provider. Local-provider traffic is not relayed through tonytan.me.", + apiKeyPlaceholder: "Session-only API key", autoDetect: "Auto detect", customRequirementPlaceholder: "Describe a custom system-design requirement…", mentorWorkspacePlaceholder: "Explain your reasoning or ask for one hint…", messagePlaceholder: "Reply with your reasoning…", systemCommandPlaceholder: "Answer this checkpoint or ask JARVIS…", }, zh: { - skip: "跳到工作区", checkingModel: "正在检测本机 Ollama…", algorithms: "算法", systemDesign: "系统设计", hideProblem: "隐藏题目", - connection: "连接", settings: "设置", fixConnection: "修复连接", problem: "题目", code: "代码", - track: "专题", mission: "任务", objective: "先想清楚", openProblem: "在 LeetCode 打开 ↗", newMission: "新任务", - markReview: "标记复习", markMastered: "标记掌握", savedLocally: "已保存在这个浏览器", editorLabel: "题解编辑器", - editorHelp: "Tab 缩进 · Shift+Tab 反缩进 · ⌘/Ctrl+F 查找", testsAndRun: "测试用例与运行设置", - methodName: "Solution 方法名", timeout: "超时", testCases: "JSON 测试用例", run: "▶ 在浏览器运行", - runAndAsk: "运行并问 JARVIS", mentorRole: "AI 面试副驾", mentorOpening: "连接本机 Ollama 后,我会结合这个浏览器里的代码和运行结果进行辅导。", - yourReasoning: "你的推理", messagePlaceholder: "解释下一步怎么做…", resetPosition: "复位位置", send: "发送", - localModel: "本机模型", connectOllama: "连接 Ollama", model: "本机模型", continue: "进入工作区", - openOllama: "打开 Ollama", retry: "重新检测", launchExplanation: "浏览器可能询问是否打开 Ollama;未经你的确认,网站不能启动本机软件。", - missingExplanation: "如果 Ollama 没有打开,它可能尚未安装。只需安装一次并打开,然后回到这里;LeetTutor 本身无需安装。", - downloadOllama: "下载 Ollama ↗", installedRetry: "我已安装/打开,重新检测", originExplanation: "Ollama 正在运行,但尚未允许 tonytan.me。执行下面的一次性命令,完全退出 Ollama 后重新打开。", - copy: "复制", permissionExplanation: "请在浏览器的网站设置里允许 tonytan.me 的“本地网络访问”,刷新页面后重新检测。", - noModelsExplanation: "Ollama 已就绪,但没有找到本机模型。可以直接把推荐导师模型下载进 Ollama。", - modelToDownload: "要下载的模型", downloadModel: "下载模型", privacyTitle: "隐私与要求", - privacyBody: "Prompt、代码、模型名称和回答只在这个浏览器与 127.0.0.1 上的 Ollama 之间传输,不经过 tonytan.me 中转。", - preferences: "偏好设置", languageAppearance: "语言与外观", language: "界面语言", theme: "显示主题", - checkingTitle: "正在检测这台电脑", checkingBody: "正在查找只监听回环地址的 Ollama API。", - readyTitle: "本机 Ollama 已就绪", readyBody: "已直接连接这台电脑上的 {count} 个模型。", - offlineTitle: "没有连接到 Ollama", offlineBody: "它可能尚未启动或尚未安装。请先尝试打开;如果没有应用响应,再按安装说明操作。", - launchingTitle: "正在等待 Ollama", launchingBody: "如果浏览器弹出询问,请允许打开。Ollama API 就绪后会自动连接。", - missingTitle: "可能尚未安装 Ollama", missingBody: "尝试启动后仍未出现本机 API。请安装并打开 Ollama,然后重新检测。", - permissionTitle: "浏览器权限被阻止", permissionBody: "允许 tonytan.me 的“本地网络访问”之前,网页不能检测 localhost。", - originTitle: "Ollama 已运行,但拒绝了这个网站", originBody: "本机 API 有响应,但 OLLAMA_ORIGINS 还没有允许 tonytan.me。", - noModelsTitle: "Ollama 中没有本机模型", noModelsBody: "应用已经运行;向 JARVIS 提问前需要先下载一个模型。", - modelReady: "Ollama · {model} · 仅限本机", modelUnavailable: "本机 Ollama 不可用", modelMissing: "Ollama 已就绪 · 需要模型", - connectionNeeded: "连接本机模型", connectionNeededBody: "代码练习可直接在浏览器运行;JARVIS 需要这台电脑上的 Ollama。", - noModelBanner: "下载一个本机模型", noModelBannerBody: "Ollama 已连接,但模型列表为空。", - copied: "命令已复制", pullStarting: "正在开始下载本机模型…", pullProgress: "正在下载 {percent}% · {status}", pullComplete: "模型下载完成,正在刷新列表…", pullFailed: "模型下载失败:{error}", - loadingCatalog: "正在载入课程…", catalogFailed: "无法载入课程数据。", - progressReview: "已标记复习", progressMastered: "已掌握", progressNew: "新任务", - pythonOnly: "浏览器运行器目前支持 Python;Java 仍可编辑并保存在本机浏览器。", - runnerLoading: "首次运行正在载入浏览器内的私有 Python Runtime…", runnerRunning: "正在隔离的浏览器 Worker 中运行…", runnerTimeout: "执行超时,运行 Worker 已重置。", - runnerFailed: "浏览器运行失败:{error}", testsPassed: "通过 {passed}/{total} 个测试", jarvisNeedsModel: "请先打开连接面板并选择一个本机 Ollama 模型。", - jarvisThinking: "JARVIS 正在这台电脑上思考…", jarvisFailed: "本机模型没有回答:{error}", emptyMessage: "请先解释推理或提出问题。", - topicsAll: "全部专题", systemTrack: "系统设计案例", genericFocus: "先在 LeetCode 载入原题,定义不变量,再实现并测试题解。", + skip: "跳到训练工作区", modelService: "模型服务", apiKeyNote: "仅保存在当前标签页,不会发送到 tonytan.me。", hardwareSetup: "硬件检测与模型安装", openOllama: "打开 Ollama", setupGuide: "配置指南", refreshModels: "检查服务并刷新模型", detectedModels: "检测到的模型", manualModel: "手动输入…", autoTune: "根据硬件和模型自动调优", generationSettings: "生成参数", timeoutSeconds: "超时(秒)", contextTokens: "上下文长度", reasoningEffort: "推理强度", maxOutput: "最大输出长度", rolePrompts: "角色提示词(可编辑)", algorithmInterviewer: "算法面试官", systemArchitect: "系统架构师", saveSettings: "保存设置", clearChat: "清空对话", openVscode: "在 VS Code 中打开仓库", localPrivacy: "代码只在你点击运行后执行。本地模型的提示词由浏览器直接发送到你配置的本机端口。", + checkingModel: "正在检查本地模型…", algorithms: "算法", systemDesign: "系统设计", language: "语言", interfaceLanguage: "界面语言", statementLanguage: "LeetCode 题面", layout: "布局", visiblePanes: "显示面板", problem: "题目", code: "代码", tutor: "导师", tutorStyle: "导师样式", docked: "停靠", floating: "悬浮", restoreLayout: "恢复默认布局", startCoaching: "开始辅导", nextProblem: "JARVIS,下一题", trainingSettings: "训练设置", trainingTrack: "训练方向", difficulty: "难度", progressive: "循序渐进", openLeetcode: "打开 LeetCode", markMastered: "标记已掌握", reviewLater: "稍后复习", visualMap: "视觉地图 · 先看清结构", mentalModel: "当前心智模型", visualMapHelp: "这是可扩展的思考支架,不是完整答案。JARVIS 可以根据你当前的工作重新绘制。", redrawJarvis: "让 JARVIS 重画当前状态", hideProblem: "隐藏题目", roundGoal: "本轮目标:", thinkFirst: "先想清:", statementMissing: "完整题面尚未载入。载入一次后会一直显示在编辑器旁。", loadStatement: "载入完整题面和 Python 模板", changeProblem: "更换题目 / 手动导入", problemReference: "LeetCode URL 或 slug", importWorkspace: "导入工作区", hideCode: "隐藏代码", loadSolution: "载入已有解法", newFile: "(新文件)", load: "载入", upload: "上传", editorLabel: "解法编辑器", editorHelp: "Tab 缩进 · Shift+Tab 反缩进 · 自动缩进 · 行号与语法高亮 · ⌘/Ctrl+F 搜索", testsAndRun: "测试与运行设置", methodName: "Solution 方法", testCases: "测试用例(JSON)", run: "▶ 运行", runAndAsk: "运行并询问导师", saveRepository: "保存到仓库", filename: "文件名", saveCode: "保存当前代码", allowOverwrite: "允许覆盖", mentorRole: "AI 面试副驾驶", watching: "正在观察:", turnRule: "每轮只给一个短提示和一个问题;等你回答后再继续。", sendJarvis: "发送给 JARVIS", stuck: "我卡住了", nextStep: "下一步", allHidden: "三个工作面板都已隐藏,请从“布局”中恢复。", + startStress: "开始压力测试", assignMission: "JARVIS,分配任务", missionSettings: "任务设置", missionTrack: "任务方向", autoRotation: "自动轮换", scaling: "扩展性", reliability: "可靠性", realtime: "实时系统", dataPlatform: "数据平台", transactions: "事务", currentCheckpoint: "当前检查点:", redrawArchitecture: "让 JARVIS 重画我的架构", customMission: "自定义任务", designRequirement: "设计需求", liveSubtitle: "页面展示本轮;右下角悬浮窗保留完整对话", ready: "就绪", liveEmpty: "回答当前检查点,或点击下方“仅下一步”;进度和新回复会显示在这里。", commandDock: "JARVIS 指令栏 · 完整对话在右下角", send: "发送", nextStepOnly: "仅下一步", replyJarvis: "回复 JARVIS", dockRight: "停靠到右侧", hideTutor: "隐藏导师", + localModel: "本地模型", connectOllama: "连接 Ollama", continue: "继续进入工作区", retry: "再次检查", launchExplanation: "浏览器可能询问是否打开 Ollama;未经你确认,网站不能启动本机软件。", missingExplanation: "如果 Ollama 没有打开,请安装一次、启动后回到这里;LeetTutor 本身无需安装。", downloadOllama: "下载 Ollama ↗", installedRetry: "已安装/打开——再次检查", originExplanation: "Ollama 正在运行,但尚未允许 tonytan.me。运行此命令,彻底退出 Ollama 后重新打开。", copy: "复制", permissionExplanation: "在浏览器网站设置中允许 tonytan.me 访问本地网络,刷新后再检查。", noModelsExplanation: "Ollama 已就绪,但未发现本地模型。", modelToDownload: "要下载的模型", downloadModel: "下载模型", privacyTitle: "隐私与要求", privacyBody: "提示词、代码、模型名和回答直接在浏览器与所选服务之间传输;本地模型流量不经过 tonytan.me。", + apiKeyPlaceholder: "仅当前会话使用的 API Key", autoDetect: "自动检测", customRequirementPlaceholder: "描述一个自定义系统设计需求…", mentorWorkspacePlaceholder: "说明你的思路,或只问一个提示…", messagePlaceholder: "回复你的推理过程…", systemCommandPlaceholder: "回答当前检查点,或询问 JARVIS…", }, }; -const topicNames = { - "二分": { en: "Binary search", zh: "二分" }, "栈": { en: "Stack", zh: "栈" }, "堆": { en: "Heap", zh: "堆" }, - "动态规划": { en: "Dynamic programming", zh: "动态规划" }, "区间动态规划": { en: "Interval DP", zh: "区间动态规划" }, -}; - const state = { - locale: "en", mode: "algorithm", catalog: { problems: [], system_design: [] }, current: null, - ollamaStatus: "checking", models: [], selectedModel: "", lastRun: null, launchAttempts: 0, + catalog: { problems: [], system_design: [], prompts: {} }, locale: "en", statementLocale: "en", theme: "system", mode: "algorithm", + currentProblem: null, currentSystem: null, language: "python", progress: {}, settings: {}, models: [], runtime: { status: "checking", message: "" }, + lastRun: null, histories: { algorithm: [], system: [] }, layout: { problem: true, code: true, mentor: true, mentorStyle: "floating", mobilePane: "code", problemWidth: 43 }, }; -function t(key, values = {}) { - let result = copy[state.locale]?.[key] || copy.en[key] || key; - Object.entries(values).forEach(([name, value]) => { result = result.replaceAll(`{${name}}`, String(value)); }); - return result; +function t(key, vars = {}) { + let value = copy[state.locale]?.[key] || copy.en[key] || key; + for (const [name, replacement] of Object.entries(vars)) value = value.replaceAll(`{${name}}`, String(replacement)); + return value; } -function detectedLocale() { - return navigator.languages?.some((value) => value.toLowerCase().startsWith("zh")) ? "zh" : "en"; -} +function readJson(key, fallback) { try { return JSON.parse(localStorage.getItem(`${STORAGE}:${key}`) || "null") ?? fallback; } catch (_) { return fallback; } } +function writeJson(key, value) { localStorage.setItem(`${STORAGE}:${key}`, JSON.stringify(value)); } +function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch])); } +function localized(item, field) { return state.locale === "zh" ? (item?.[`${field}_cn`] || item?.[field] || "") : (item?.[field] || item?.[`${field}_cn`] || ""); } +function problemCopy(item, field) { return state.locale === "zh" ? (item?.[field] || "") : (item?.[`${field}_en`] || item?.[field] || ""); } +function showToast(message) { const node = $("#toast"); node.textContent = message; node.hidden = false; clearTimeout(showToast.timer); showToast.timer = setTimeout(() => { node.hidden = true; }, 3000); } -function applyPreferences() { - const language = localStorage.getItem("leettutor-language") || "system"; - const theme = localStorage.getItem("leettutor-theme") || "system"; - state.locale = language === "system" ? detectedLocale() : language; - const resolvedTheme = theme === "system" ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : theme; - document.documentElement.lang = state.locale === "zh" ? "zh-CN" : "en"; - document.documentElement.dataset.theme = resolvedTheme; - $("#language").value = language; - $("#theme").value = theme; - $$('[data-copy]').forEach((node) => { node.textContent = t(node.dataset.copy); }); - $$('[data-placeholder]').forEach((node) => { node.placeholder = t(node.dataset.placeholder); }); - $$('[data-copy-title]').forEach((node) => { node.title = t(node.dataset.copyTitle); }); - renderCatalogControls(false); - renderCurrent(false); - renderRuntime(); - renderDiagnostic(); +function defaultSettings() { + const provider = "ollama"; + return { provider, endpoint: PROVIDERS[provider].endpoint, model: PROVIDERS[provider].model, autoTune: true, temperature: .2, topP: .9, timeout: 180, context: 8192, reasoning: "none", maxOutput: 768, algorithmPrompt: "", systemPrompt: "" }; } -function timeoutSignal(milliseconds) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), milliseconds); - return { signal: controller.signal, cancel: () => clearTimeout(timer) }; +function loadState() { + const preferred = readJson("preferences", {}); + const browserChinese = (navigator.languages || [navigator.language]).some((language) => language.toLowerCase().startsWith("zh")); + state.locale = preferred.locale === "system" || !preferred.locale ? (browserChinese ? "zh" : "en") : preferred.locale; + state.statementLocale = preferred.statementLocale || (browserChinese ? "zh" : "en"); + state.theme = preferred.theme || "system"; + state.progress = readJson("progress", {}); + state.settings = { ...defaultSettings(), ...readJson("settings", {}) }; + state.layout = { ...state.layout, ...readJson("layout", {}) }; + state.histories = { ...state.histories, ...readJson("histories", {}) }; } -async function loopbackFetch(path, options = {}, timeoutMs = 6_000) { - const timeout = timeoutSignal(timeoutMs); - const headers = { Accept: "application/json", ...(options.headers || {}) }; - try { - return await fetch(`${OLLAMA_BASE}${path}`, { - ...options, - headers, - cache: "no-store", - signal: timeout.signal, - targetAddressSpace: "loopback", - }); - } finally { - timeout.cancel(); - } +function savePreferences() { + writeJson("preferences", { locale: $("#language").value, statementLocale: state.statementLocale, theme: state.theme }); } -async function loopbackPermission() { - if (!navigator.permissions?.query) return "unknown"; - for (const name of ["loopback-network", "local-network-access"]) { - try { - const result = await navigator.permissions.query({ name }); - if (result.state === "denied") return "denied"; - if (result.state === "granted") return "granted"; - } catch (_error) { /* Browser does not expose this permission name yet. */ } - } - return "unknown"; +function applyPreferences() { + const dark = state.theme === "dark" || (state.theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches); + document.documentElement.dataset.theme = dark ? "dark" : "light"; + document.documentElement.lang = state.locale === "zh" ? "zh-CN" : "en"; + $("#theme-toggle span").textContent = state.theme === "system" ? "◐" : state.theme === "dark" ? "☾" : "☀"; + $("#theme-toggle").title = state.theme === "system" ? (state.locale === "zh" ? "外观:跟随系统" : "Appearance: system") : `${state.locale === "zh" ? "外观" : "Appearance"}: ${state.theme}`; + $$('[data-copy]').forEach((node) => { node.textContent = t(node.dataset.copy); }); + $$('[data-copy-title]').forEach((node) => { node.title = t(node.dataset.copyTitle); node.setAttribute("aria-label", t(node.dataset.copyTitle)); }); + $$('[data-placeholder]').forEach((node) => { node.placeholder = t(node.dataset.placeholder); }); + renderAll(); } -async function probeOllama() { +async function loadCatalog() { + const response = await fetch("./catalog.json", { cache: "no-store" }); + if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`); + state.catalog = await response.json(); + if (!state.settings.algorithmPrompt) state.settings.algorithmPrompt = state.catalog.prompts?.algorithm || ""; + if (!state.settings.systemPrompt) state.settings.systemPrompt = state.catalog.prompts?.system_design || ""; + state.currentProblem = state.catalog.problems.find((item) => item.id === Number(readJson("current-problem", 33))) || state.catalog.problems[0]; + state.currentSystem = state.catalog.system_design.find((item) => item.id === readJson("current-system", "SD-01")) || state.catalog.system_design[0]; + populateSettings(); +} + +function populateSettings() { + const settings = state.settings; + $("#provider-select").value = settings.provider; + $("#endpoint-input").value = settings.endpoint; + $("#manual-model").value = settings.model; + $("#auto-tune").checked = settings.autoTune; + $("#temperature").value = settings.temperature; $("#temperature-output").value = Number(settings.temperature).toFixed(2); + $("#top-p").value = settings.topP; $("#top-p-output").value = Number(settings.topP).toFixed(2); + $("#model-timeout").value = settings.timeout; $("#context-tokens").value = settings.context; + $("#reasoning-effort").value = settings.reasoning; $("#max-output").value = settings.maxOutput; + $("#algorithm-prompt").value = settings.algorithmPrompt; $("#system-prompt").value = settings.systemPrompt; + renderProviderFields(); +} + +function renderProviderFields() { + const provider = $("#provider-select").value; + $("#api-key-field").hidden = !PROVIDERS[provider].key; + $("#runtime-setup").hidden = provider !== "ollama"; + const hardware = [navigator.hardwareConcurrency ? `${navigator.hardwareConcurrency} CPU threads` : "CPU unknown", navigator.deviceMemory ? `${navigator.deviceMemory} GB browser memory hint` : "browser memory hidden"].join(" · "); + $("#device-summary").textContent = hardware; + $("#auto-summary").textContent = $("#auto-tune").checked ? (state.locale === "zh" ? `自动:${$("#context-tokens").value} 上下文,${$("#max-output").value} 输出` : `Auto: ${$("#context-tokens").value} context, ${$("#max-output").value} output`) : ""; +} + +function saveSettingsFromDrawer() { + state.settings = { + provider: $("#provider-select").value, endpoint: $("#endpoint-input").value.replace(/\/$/, ""), model: $("#model-select").value || $("#manual-model").value.trim(), + autoTune: $("#auto-tune").checked, temperature: Number($("#temperature").value), topP: Number($("#top-p").value), timeout: Number($("#model-timeout").value), + context: Number($("#context-tokens").value), reasoning: $("#reasoning-effort").value, maxOutput: Number($("#max-output").value), algorithmPrompt: $("#algorithm-prompt").value, systemPrompt: $("#system-prompt").value, + }; + writeJson("settings", state.settings); + showToast(state.locale === "zh" ? "模型设置已保存" : "Model settings saved"); + probeProvider(); +} + +function openDrawer(open = true) { + $("#settings-drawer").classList.toggle("is-open", open); $("#settings-drawer").setAttribute("aria-hidden", String(!open)); $("#drawer-backdrop").hidden = !open; +} + +function providerUrl(path) { return `${state.settings.endpoint.replace(/\/$/, "")}${path}`; } +async function timedFetch(url, options = {}, timeout = 7000) { + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); + const loopback = /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?(?:\/|$)/i.test(url); + try { return await fetch(url, { ...(loopback ? { targetAddressSpace: "loopback" } : {}), ...options, signal: controller.signal }); } + finally { clearTimeout(timer); } +} + +function runtimeCopy(status, detail = "") { + const zh = state.locale === "zh"; + const table = { + checking: [zh ? "正在检查模型服务" : "Checking model service", zh ? "正在检测本机端口和可用模型。" : "Detecting the configured endpoint and available models."], + ready: [zh ? "模型服务已就绪" : "Model service is ready", detail || (zh ? "可以开始 JARVIS 对练。" : "JARVIS coaching is available.")], + "no-models": [zh ? "服务可用,但没有模型" : "Service ready, no models", zh ? "先下载或载入一个模型。" : "Download or load a model first."], + origin: [zh ? "本地服务拒绝了网页来源" : "Local service blocked this website", zh ? "需要允许 tonytan.me 访问该本机端口。" : "Allow tonytan.me in the local service origin list."], + permission: [zh ? "浏览器阻止了本地网络访问" : "Local network permission blocked", zh ? "请在网站权限中允许本地网络。" : "Allow Local network access in site permissions."], + offline: [zh ? "未连接到模型服务" : "Model service not reached", zh ? "请先启动本地服务;Ollama 缺失时可从配置指南安装。" : "Start the local service first; install Ollama from the guide if missing."], + error: [zh ? "模型服务出错" : "Model service error", detail], + }; + return table[status] || table.error; +} + +function setRuntime(status, detail = "") { + state.runtime = { status, message: detail }; + const [title, body] = runtimeCopy(status, detail); + $("#runtime-label").textContent = status === "ready" ? `${state.settings.provider} · ${state.settings.model} · ${state.settings.provider === "ollama" ? "local only" : "ready"}` : title; + $("#drawer-runtime-title").textContent = title; $("#drawer-runtime-body").textContent = body; + $("#runtime-dot").dataset.status = status; $("#drawer-runtime-dot").dataset.status = status; +} + +function classifyFetchError(error) { + const text = `${error?.name || ""} ${error?.message || ""}`.toLowerCase(); + if (text.includes("private network") || text.includes("local network") || text.includes("permission")) return "permission"; + if (text.includes("cors") || text.includes("origin")) return "origin"; + return "offline"; +} + +async function probeProvider({ openGuide = false } = {}) { + saveSettingsSilently(); setRuntime("checking"); try { - const response = await loopbackFetch("/api/tags"); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const payload = await response.json(); - return { status: "ready", models: Array.isArray(payload.models) ? payload.models : [] }; - } catch (corsOrOfflineError) { - const permission = await loopbackPermission(); - if (permission === "denied") return { status: "permission", error: corsOrOfflineError }; - try { - const response = await loopbackFetch("/api/version", { mode: "no-cors" }, 3_000); - if (response.type === "opaque" || response.ok) return { status: "origin", error: corsOrOfflineError }; - } catch (_offlineError) { /* No readable or opaque loopback response. */ } - return { status: "offline", error: corsOrOfflineError }; - } -} - -function clearDiagnosticSections() { - for (const selector of ["#ready-actions", "#launch-actions", "#install-actions", "#origin-actions", "#permission-actions", "#model-actions"]) { - $(selector).hidden = true; + const provider = state.settings.provider; + let models = []; + if (provider === "ollama") { + const response = await timedFetch(providerUrl("/api/tags")); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); models = (payload.models || []).map((item) => item.name || item.model).filter(Boolean); + } else if (provider === "gemini") { + const key = $("#api-key-input").value.trim(); if (!key) throw new Error("API key required"); + const response = await timedFetch(`${providerUrl("/v1beta/models")}?key=${encodeURIComponent(key)}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); models = (payload.models || []).filter((item) => item.supportedGenerationMethods?.includes("generateContent")).map((item) => item.name.replace("models/", "")); + } else { + const headers = {}; if (PROVIDERS[provider].key) { const key = $("#api-key-input").value.trim(); if (!key) throw new Error("API key required"); headers.Authorization = `Bearer ${key}`; } + const response = await timedFetch(providerUrl("/v1/models"), { headers }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); models = (payload.data || []).map((item) => item.id).filter(Boolean); + } + state.models = models; populateModelSelect(); + if (models.length && !models.includes(state.settings.model)) state.settings.model = models[0]; + setRuntime(models.length ? "ready" : "no-models", models.length ? `${models.length} model(s) detected` : ""); + if (openGuide && state.settings.provider === "ollama") showConnectionDialog(models.length ? "ready" : "no-models"); + } catch (error) { + let status = classifyFetchError(error); + if (state.settings.provider === "ollama" && status === "offline") { + try { + await timedFetch(providerUrl("/api/tags"), { mode: "no-cors" }, 2500); + status = "origin"; + } catch (diagnosticError) { + status = classifyFetchError(diagnosticError); + } + } + setRuntime(status, error.message); + if (openGuide && state.settings.provider === "ollama") showConnectionDialog(status); } } -function setDiagnostic(status, { models = [] } = {}) { - state.ollamaStatus = status; - if (status === "ready" || status === "no-models") { - state.models = models; - populateModels(); - } - renderRuntime(); - renderDiagnostic(); -} - -function renderRuntime() { - document.body.classList.toggle("ollama-ready", state.ollamaStatus === "ready"); - document.body.classList.toggle("ollama-error", ["offline", "permission", "origin", "missing"].includes(state.ollamaStatus)); - const label = $("#runtime-label"); - const banner = $("#connection-banner"); - if (!label || !banner) return; - if (state.ollamaStatus === "ready") { - label.textContent = t("modelReady", { model: state.selectedModel || state.models[0]?.name || "Ollama" }); - banner.hidden = true; - } else if (state.ollamaStatus === "no-models") { - label.textContent = t("modelMissing"); - $("#banner-title").textContent = t("noModelBanner"); - $("#banner-body").textContent = t("noModelBannerBody"); - banner.hidden = false; - } else if (state.ollamaStatus === "checking" || state.ollamaStatus === "launching") { - label.textContent = t("checkingModel"); - banner.hidden = true; - } else { - label.textContent = t("modelUnavailable"); - $("#banner-title").textContent = t("connectionNeeded"); - $("#banner-body").textContent = t("connectionNeededBody"); - banner.hidden = false; - } +function saveSettingsSilently() { + state.settings.provider = $("#provider-select").value; state.settings.endpoint = $("#endpoint-input").value.replace(/\/$/, ""); state.settings.model = $("#model-select").value || $("#manual-model").value.trim() || PROVIDERS[state.settings.provider].model; } -function renderDiagnostic() { - const title = $("#diagnostic-title"); - const body = $("#diagnostic-body"); - const icon = $("#diagnostic-icon"); - if (!title || !body || !icon) return; - clearDiagnosticSections(); - icon.dataset.status = "checking"; - const status = state.ollamaStatus; - if (status === "checking") { - title.textContent = t("checkingTitle"); body.textContent = t("checkingBody"); - } else if (status === "launching") { - title.textContent = t("launchingTitle"); body.textContent = t("launchingBody"); $("#launch-actions").hidden = false; - } else if (status === "ready") { - icon.dataset.status = "ready"; title.textContent = t("readyTitle"); body.textContent = t("readyBody", { count: state.models.length }); $("#ready-actions").hidden = false; - } else if (status === "no-models") { - title.textContent = t("noModelsTitle"); body.textContent = t("noModelsBody"); $("#model-actions").hidden = false; - } else if (status === "permission") { - icon.dataset.status = "error"; title.textContent = t("permissionTitle"); body.textContent = t("permissionBody"); $("#permission-actions").hidden = false; - } else if (status === "origin") { - icon.dataset.status = "error"; title.textContent = t("originTitle"); body.textContent = t("originBody"); $("#origin-actions").hidden = false; - } else if (status === "missing") { - icon.dataset.status = "error"; title.textContent = t("missingTitle"); body.textContent = t("missingBody"); $("#install-actions").hidden = false; $("#launch-actions").hidden = false; - } else { - icon.dataset.status = "error"; title.textContent = t("offlineTitle"); body.textContent = t("offlineBody"); $("#launch-actions").hidden = false; if (state.launchAttempts > 0) $("#install-actions").hidden = false; - } +function populateModelSelect() { + const select = $("#model-select"); select.replaceChildren(); + const manual = document.createElement("option"); manual.value = ""; manual.textContent = t("manualModel"); select.append(manual); + state.models.forEach((model) => { const option = document.createElement("option"); option.value = model; option.textContent = model; select.append(option); }); + if (state.models.includes(state.settings.model)) select.value = state.settings.model; + $("#manual-model-field").hidden = Boolean(select.value); } -function showConnectionDialog() { +function showConnectionDialog(status = state.runtime.status) { const dialog = $("#connection-dialog"); + ["ready", "launch", "install", "origin", "permission", "model"].forEach((name) => { const node = $(`#${name}-actions`); if (node) node.hidden = true; }); + const mapping = { ready: "ready", "no-models": "model", origin: "origin", permission: "permission", offline: "launch", error: "launch", checking: "launch" }; + const section = mapping[status] || "launch"; $(`#${section}-actions`).hidden = false; + const [title, body] = runtimeCopy(status); $("#diagnostic-title").textContent = title; $("#diagnostic-body").textContent = body; $("#diagnostic-icon").dataset.status = status === "ready" ? "ready" : ["offline", "origin", "permission", "error"].includes(status) ? "error" : "checking"; if (!dialog.open) dialog.showModal(); } -async function diagnoseOllama({ showDialog = false } = {}) { - setDiagnostic("checking"); - if (showDialog) showConnectionDialog(); - const result = await probeOllama(); - if (result.status === "ready") { - setDiagnostic(result.models.length ? "ready" : "no-models", { models: result.models }); - if (!result.models.length) showConnectionDialog(); - else if (!showDialog && $("#connection-dialog").open) $("#connection-dialog").close(); - } else { - setDiagnostic(result.status); - showConnectionDialog(); - } - return result; -} - -function populateModels() { - const select = $("#model-select"); - const preferred = localStorage.getItem("leettutor-model") || state.selectedModel; - select.replaceChildren(); - for (const item of state.models) { - const name = item.name || item.model; - if (!name) continue; - const option = document.createElement("option"); - option.value = name; option.textContent = name; select.append(option); - } - if (preferred && [...select.options].some((option) => option.value === preferred)) select.value = preferred; - state.selectedModel = select.value || ""; - if (state.selectedModel) localStorage.setItem("leettutor-model", state.selectedModel); -} - -function triggerOllamaDeepLink() { +async function openAndWaitForOllama() { const link = document.createElement("a"); link.href = "ollama://"; link.hidden = true; document.body.append(link); link.click(); link.remove(); -} - -async function openAndWaitForOllama() { - state.launchAttempts += 1; - setDiagnostic("launching"); - triggerOllamaDeepLink(); - for (let attempt = 0; attempt < 18; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, attempt < 5 ? 900 : 1_400)); - const result = await probeOllama(); - if (result.status === "ready") { - setDiagnostic(result.models.length ? "ready" : "no-models", { models: result.models }); - return; - } - if (["permission", "origin"].includes(result.status)) { setDiagnostic(result.status); return; } - } - setDiagnostic("missing"); + setRuntime("checking"); + for (let attempt = 0; attempt < 14; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, attempt < 4 ? 800 : 1300)); await probeProvider(); if (["ready", "no-models", "origin", "permission"].includes(state.runtime.status)) { showConnectionDialog(); return; } } + showConnectionDialog("offline"); $("#launch-actions").hidden = true; $("#install-actions").hidden = false; } function platformOriginCommand() { @@ -309,250 +244,313 @@ function platformOriginCommand() { } async function pullModel() { - const name = $("#pull-model-name").value.trim() || RECOMMENDED_MODEL; - const progress = $("#pull-progress"); - const status = $("#pull-status"); - progress.hidden = false; progress.removeAttribute("value"); status.textContent = t("pullStarting"); + const name = $("#pull-model-name").value.trim() || RECOMMENDED_MODEL; const progress = $("#pull-progress"); const status = $("#pull-status"); + progress.hidden = false; progress.removeAttribute("value"); status.textContent = state.locale === "zh" ? "正在开始下载…" : "Starting download…"; try { - const response = await loopbackFetch("/api/pull", { - method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: name, stream: true }), - }, 30 * 60 * 1_000); + const response = await timedFetch(providerUrl("/api/pull"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: name, stream: true }) }, 30 * 60 * 1000); if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); buffer = lines.pop() || ""; - for (const line of lines) { - if (!line.trim()) continue; - const update = JSON.parse(line); - const percent = update.total ? Math.round((update.completed || 0) / update.total * 100) : 0; - if (update.total) { progress.value = percent; status.textContent = t("pullProgress", { percent, status: update.status || "" }); } - else status.textContent = update.status || t("pullStarting"); - if (update.error) throw new Error(update.error); - } - } - progress.value = 100; status.textContent = t("pullComplete"); localStorage.setItem("leettutor-model", name); - await diagnoseOllama({ showDialog: true }); - } catch (error) { - progress.hidden = true; status.textContent = t("pullFailed", { error: error.message }); - } + const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.trim()) continue; const update = JSON.parse(line); if (update.total) { progress.value = Math.round(((update.completed || 0) / update.total) * 100); status.textContent = `${update.status || "Downloading"} ${progress.value}%`; } else status.textContent = update.status || status.textContent; if (update.error) throw new Error(update.error); } } + state.settings.model = name; $("#manual-model").value = name; await probeProvider({ openGuide: true }); + } catch (error) { progress.hidden = true; status.textContent = error.message; } } -function progressKey() { return `leettutor-progress:${state.mode}:${state.current?.id || "none"}`; } -function draftKey(language = selectedLanguage()) { return `leettutor-draft:${state.mode}:${state.current?.id || "none"}:${language}`; } -function selectedLanguage() { return $(".language-switch button.is-active")?.dataset.language || "python"; } +function progressSummary() { + const values = Object.values(state.progress); return { mastered: values.filter((x) => x.status === "mastered").length, review: values.filter((x) => x.status === "review").length, attempted: values.filter((x) => Number(x.attempts) > 0).length, total: state.catalog.problems.length }; +} -function defaultSource(item, language = selectedLanguage()) { - if (state.mode === "system") return `# ${state.locale === "zh" ? item.title_cn : item.title}\n\n## Requirements and estimates\n\n## API and data model\n\n## Architecture\n\n## Reliability and trade-offs\n`; +function chooseNextProblem(excludeId = state.currentProblem?.id) { + const track = $("#topic-select").value || "auto"; const difficulty = $("#difficulty-select").value || "progressive"; + let candidates = state.catalog.problems.filter((item) => item.id !== excludeId); + if (track !== "auto") candidates = candidates.filter((item) => item.topic === track); + if (["Easy", "Medium", "Hard"].includes(difficulty)) candidates = candidates.filter((item) => item.difficulty === difficulty); + if (!candidates.length) return state.currentProblem; + const topicScores = Object.fromEntries([...new Set(state.catalog.problems.map((item) => item.topic))].map((topic) => { const items = state.catalog.problems.filter((item) => item.topic === topic); return [topic, items.filter((item) => state.progress[item.id]?.status === "mastered").length / items.length]; })); + const rank = { review: 0, new: 1, in_progress: 2, mastered: 3 }; + return candidates.sort((a, b) => { + const pa = state.progress[a.id] || {}, pb = state.progress[b.id] || {}; + const sa = [track === "auto" ? topicScores[a.topic] : 0, rank[pa.status || "new"], pa.attempts || 0, state.catalog.problems.indexOf(a)]; + const sb = [track === "auto" ? topicScores[b.topic] : 0, rank[pb.status || "new"], pb.attempts || 0, state.catalog.problems.indexOf(b)]; + for (let index = 0; index < sa.length; index += 1) if (sa[index] !== sb[index]) return sa[index] - sb[index]; + return 0; + })[0]; +} + +function updateProgress(status) { + const id = state.currentProblem.id; const current = state.progress[id] || { attempts: 0 }; + state.progress[id] = { ...current, status, attempts: current.attempts + (status === "in_progress" ? 1 : 0), topic: state.currentProblem.topic, updatedAt: new Date().toISOString() }; + writeJson("progress", state.progress); renderAlgorithm(); +} + +function defaultSource(problem, language = state.language) { if (language === "java") return `class Solution {\n public int solve(int[] nums) {\n // Define the invariant first.\n return 0;\n }\n}\n`; - if (Number(item.id) === 704) return "class Solution:\n def search(self, nums, target):\n left, right = 0, len(nums) - 1\n while left <= right:\n mid = left + (right - left) // 2\n if nums[mid] == target:\n return mid\n if nums[mid] < target:\n left = mid + 1\n else:\n right = mid - 1\n return -1\n"; + if (problem?.id === 704) return "class Solution:\n def search(self, nums, target):\n left, right = 0, len(nums) - 1\n while left <= right:\n mid = left + (right - left) // 2\n if nums[mid] == target:\n return mid\n if nums[mid] < target:\n left = mid + 1\n else:\n right = mid - 1\n return -1\n"; return "class Solution:\n def solve(self, *args):\n # Define the invariant, then implement the solution.\n pass\n"; } -function defaultTests(item) { - if (Number(item?.id) === 704) return '[\n {"args":[[-1,0,3,5,9,12],9],"expected":4},\n {"args":[[-1,0,3,5,9,12],2],"expected":-1}\n]'; +function defaultTests(problem) { + if (problem?.id === 704) return '[\n {"args":[[-1,0,3,5,9,12],9],"expected":4},\n {"args":[[-1,0,3,5,9,12],2],"expected":-1}\n]'; return '[\n {"args":[],"expected":null}\n]'; } +function draftKey() { return `draft:${state.currentProblem?.id}:${state.language}`; } -function renderCatalogControls(resetCurrent = true) { - if (!$("#topic-select") || !state.catalog.problems.length) return; - const topics = state.mode === "algorithm" ? [...new Set(state.catalog.problems.map((item) => item.topic))] : ["system"]; - const topicSelect = $("#topic-select"); - const priorTopic = topicSelect.value; - topicSelect.replaceChildren(); - const allOption = document.createElement("option"); allOption.value = "all"; allOption.textContent = state.mode === "algorithm" ? t("topicsAll") : t("systemTrack"); topicSelect.append(allOption); - if (state.mode === "algorithm") for (const topic of topics) { const option = document.createElement("option"); option.value = topic; option.textContent = topicNames[topic]?.[state.locale] || topic; topicSelect.append(option); } - if (priorTopic && [...topicSelect.options].some((option) => option.value === priorTopic)) topicSelect.value = priorTopic; - const items = state.mode === "algorithm" ? state.catalog.problems : state.catalog.system_design; - const filtered = state.mode === "algorithm" && topicSelect.value !== "all" ? items.filter((item) => item.topic === topicSelect.value) : items; - const select = $("#problem-select"); - const priorId = state.current?.id; - select.replaceChildren(); - for (const item of filtered) { const option = document.createElement("option"); option.value = String(item.id); option.textContent = `${item.id} · ${state.locale === "zh" ? (item.title_cn || item.title) : item.title}`; select.append(option); } - if (!resetCurrent && priorId && filtered.some((item) => String(item.id) === String(priorId))) select.value = String(priorId); - state.current = filtered.find((item) => String(item.id) === select.value) || filtered[0] || null; -} - -function renderCurrent(resetEditor = true) { - const item = state.current; - if (!item || !$("#problem-title")) return; - const algorithm = state.mode === "algorithm"; - $("#problem-number").textContent = algorithm ? `LEETCODE ${item.id}` : item.id; - $("#problem-title").textContent = state.locale === "zh" ? (item.title_cn || item.title) : item.title; - $("#difficulty").textContent = item.difficulty || ""; - $("#difficulty").dataset.level = String(item.difficulty || "").toLowerCase(); - $("#problem-focus").textContent = algorithm ? (item.focus || t("genericFocus")) : (state.locale === "zh" ? item.requirement_cn : item.requirement); - $("#objective").textContent = algorithm ? (item.invariant_prompt || t("genericFocus")) : (state.locale === "zh" ? item.first_question_cn : item.first_question); - $("#problem-link").hidden = !algorithm; $("#problem-link").href = algorithm ? item.url : "#"; - $("#editor-filename").textContent = algorithm ? `${item.id}.${item.slug}.${selectedLanguage() === "python" ? "py" : "java"}` : `${item.id}.architecture-notes.md`; - $("#method-name").value = Number(item.id) === 704 ? "search" : "solve"; - $("#test-cases").value = defaultTests(item); - $(".test-settings").hidden = !algorithm; - $("#run-code").hidden = !algorithm; $("#run-review").textContent = algorithm ? t("runAndAsk") : t("send"); - if (resetEditor) $("#code-editor").value = localStorage.getItem(draftKey()) || defaultSource(item); - const progress = localStorage.getItem(progressKey()) || "new"; - $("#progress-label").textContent = progress === "mastered" ? t("progressMastered") : progress === "review" ? t("progressReview") : t("progressNew"); - if (resetEditor) { state.lastRun = null; $("#run-result").textContent = ""; } +function populateTrackSelect() { + const select = $("#topic-select"); const prior = select.value || "auto"; select.replaceChildren(); + const auto = document.createElement("option"); auto.value = "auto"; auto.textContent = state.locale === "zh" ? "自动补弱" : "Auto · weakest track"; select.append(auto); + const topicEnglish = { "二分": "Binary search", "栈": "Stack", "优先队列": "Priority queue", "DP": "Dynamic programming" }; + [...new Set(state.catalog.problems.map((item) => item.topic))].forEach((topic) => { const option = document.createElement("option"); option.value = topic; option.textContent = state.locale === "zh" ? topic : (topicEnglish[topic] || topic); select.append(option); }); + select.value = [...select.options].some((option) => option.value === prior) ? prior : "auto"; } -async function loadCatalog() { - try { - const response = await fetch("./catalog.json", { cache: "no-store" }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - state.catalog = await response.json(); - renderCatalogControls(); renderCurrent(); - } catch (error) { - showToast(`${t("catalogFailed")} ${error.message}`); - } +function renderAlgorithm(resetEditor = false) { + const item = state.currentProblem; if (!item) return; + populateTrackSelect(); const summary = progressSummary(); const entry = state.progress[item.id] || { attempts: 0, status: "new" }; + $("#algorithm-session-title").textContent = `${state.locale === "zh" ? "导师训练" : "Tutor session"} · ${item.id}. ${localized(item, "title")} · ${item.difficulty}`; + $("#algorithm-session-summary").textContent = state.locale === "zh" ? `已掌握 ${summary.mastered}/${summary.total} · 待复习 ${summary.review} · 已练习 ${summary.attempted} · 当前第 ${Math.max(1, entry.attempts)} 次` : `Mastered ${summary.mastered}/${summary.total} · Review ${summary.review} · Attempted ${summary.attempted} · Attempt ${Math.max(1, entry.attempts)}`; + $("#problem-title").textContent = `${item.id}. ${localized(item, "title")} · ${item.difficulty}`; + $("#problem-focus").textContent = problemCopy(item, "focus"); $("#objective").textContent = problemCopy(item, "invariant_prompt"); $("#problem-link").href = item.url; + $("#algorithm-map-focus").textContent = problemCopy(item, "focus"); renderAlgorithmDiagram(item); + $("#method-name").value = $("#method-name").value || (item.id === 704 ? "search" : "solve"); + $("#save-filename").value = `${item.id}.${item.slug}.${state.language === "python" ? "py" : "java"}`; + if (resetEditor || !$("#code-editor").value) { $("#code-editor").value = localStorage.getItem(`${STORAGE}:${draftKey()}`) || defaultSource(item); $("#test-cases").value = defaultTests(item); updateEditor(); } + $("#mentor-context").textContent = `${item.id}. ${localized(item, "title")} · ${item.difficulty}`; $("#docked-watching").textContent = $("#mentor-context").textContent; + renderSavedSolutions(); renderMessages(); updateLayout(); +} + +function renderAlgorithmDiagram(item) { + const patterns = { + "二分": ["Search range", "mid", "Keep one half", "Converge"], "栈": ["Input", "Stack invariant", "Resolve top", "Answer"], + "优先队列": ["Stream", "Heap boundary", "Evict", "Top-K"], "DP": ["State", "Choice", "Transition", "Base case"], + }; + renderFlow($("#algorithm-diagram"), patterns[item.topic] || ["Input", "Invariant", "Decision", "Output"]); +} + +function renderFlow(root, labels) { + root.replaceChildren(); labels.forEach((label, index) => { const node = document.createElement("span"); node.className = "diagram-node"; node.textContent = label; root.append(node); if (index < labels.length - 1) { const arrow = document.createElement("i"); arrow.textContent = "→"; root.append(arrow); } }); +} + +function chooseNextSystem() { + let candidates = state.catalog.system_design; const track = $("#system-track").value; const difficulty = $("#system-difficulty").value; + if (track !== "auto") candidates = candidates.filter((item) => item.track === track); if (["Easy", "Medium", "Hard"].includes(difficulty)) candidates = candidates.filter((item) => item.difficulty === difficulty); + if (!candidates.length) return state.currentSystem; const index = candidates.findIndex((item) => item.id === state.currentSystem?.id); return candidates[(index + 1) % candidates.length]; +} + +function renderSystem() { + const item = state.currentSystem; if (!item) return; + $("#system-mission-title").textContent = state.locale === "zh" ? "系统设计面试训练" : "System design interview training"; + $("#system-mission-summary").textContent = `${item.id} · ${localized(item, "title")} · ${item.difficulty}`; + $("#system-case-title").textContent = `${item.id} · ${localized(item, "title")} · ${item.difficulty}`; $("#system-requirement").textContent = localized(item, "requirement"); $("#system-checkpoint").textContent = localized(item, "first_question"); $("#system-map-focus").textContent = localized(item, "focus"); + const diagram = { scaling: ["Client", "Edge", "Service", "Cache / DB"], reliability: ["Client", "Limiter", "Counter", "Fallback"], realtime: ["Client", "Gateway", "Message log", "Recipients"], data: ["Producers", "Ingest", "Storage", "Query"], transactions: ["Client", "API", "Ledger", "Reconcile"] }; + renderFlow($("#system-diagram"), diagram[item.track] || ["Client", "API", "Data", "Failure boundary"]); + $("#mentor-context").textContent = `${item.id} · ${localized(item, "title")} · ${item.difficulty}`; renderMessages(); +} + +function renderAll() { if (!state.catalog.problems.length) return; renderAlgorithm(); renderSystem(); renderMode(); renderLayoutControls(); } + +function renderMode() { + $("#algorithm-mode").hidden = state.mode !== "algorithm"; $("#system-mode").hidden = state.mode !== "system"; + $$('.mode-switch [data-mode]').forEach((button) => button.classList.toggle("is-active", button.dataset.mode === state.mode)); + $("#dock-mentor").hidden = state.mode === "system"; $("#layout-popover").hidden = state.mode === "system"; + $("#mentor-context").textContent = state.mode === "algorithm" ? `${state.currentProblem.id}. ${localized(state.currentProblem, "title")}` : `${state.currentSystem.id} · ${localized(state.currentSystem, "title")}`; + renderMessages(); +} + +function renderLayoutControls() { + $("#show-problem").checked = state.layout.problem; $("#show-code").checked = state.layout.code; $("#show-mentor").checked = state.layout.mentor; + const radio = $(`input[name="mentor-style"][value="${state.layout.mentorStyle}"]`); if (radio) radio.checked = true; + document.documentElement.style.setProperty("--problem-width", `${state.layout.problemWidth}%`); +} + +function updateLayout() { + const docked = state.layout.mentor && state.layout.mentorStyle === "docked"; + $("#problem-pane").hidden = !state.layout.problem || (innerWidth <= 760 && state.layout.mobilePane !== "problem"); + $("#code-pane").hidden = !state.layout.code || (innerWidth <= 760 && state.layout.mobilePane !== "code"); + $("#docked-mentor").hidden = !docked || (innerWidth <= 760 && state.layout.mobilePane !== "jarvis"); + $("#splitter").hidden = !state.layout.problem || !state.layout.code || innerWidth <= 760; + $("#jarvis-launcher").hidden = !state.layout.mentor || docked; + if (!state.layout.mentor) $("#jarvis-panel").hidden = true; + $("#all-panes-hidden").hidden = state.layout.problem || state.layout.code || state.layout.mentor; + $("#algorithm-workspace").dataset.columns = [state.layout.problem ? "problem" : "", state.layout.code ? "code" : "", docked ? "mentor" : ""].filter(Boolean).join("-"); + $$('#mobile-nav [data-mobile-pane]').forEach((button) => button.classList.toggle("is-active", button.dataset.mobilePane === state.layout.mobilePane)); + writeJson("layout", state.layout); +} + +function switchMode(mode) { state.mode = mode; renderMode(); if (innerWidth <= 760 && mode === "algorithm") updateLayout(); } + +function openingForProblem() { + const item = state.currentProblem; const attempts = state.progress[item.id]?.attempts || 1; + return state.locale === "zh" ? `提示:这道题用来训练“${problemCopy(item, "focus")}”。当前是${attempts <= 1 ? "第一次接触" : `第 ${attempts} 次练习`},我们一次只过一关,先不写代码。\n\n轮到你:用自己的话说清 LeetCode ${item.id}(${item.title_cn})的输入、输出,并给一个最小样例,好吗?` : `Hint: This problem trains one core invariant: ${problemCopy(item, "focus")}. We will clear one checkpoint at a time and hold off on code.\n\nYour turn: In your own words, what are the input and output of LeetCode ${item.id} (${item.title})? Give one minimal example too.`; +} + +function beginCoaching() { updateProgress("in_progress"); if (!state.histories.algorithm.length) appendMessage("assistant", openingForProblem(), "algorithm"); openMentor(); } +function assignNextProblem() { state.currentProblem = chooseNextProblem(); writeJson("current-problem", state.currentProblem.id); $("#method-name").value = ""; $("#code-editor").value = ""; state.histories.algorithm = []; writeJson("histories", state.histories); renderAlgorithm(true); beginCoaching(); } + +function beginSystemStress() { + $("#system-brief").hidden = false; const item = state.currentSystem; const opening = state.locale === "zh" ? `我们先锁定需求边界,不选组件。\n\n轮到你:${item.first_question_cn}` : `We will lock the requirement boundary before choosing components.\n\nYour turn: ${item.first_question}`; + if (!state.histories.system.length) appendMessage("assistant", opening, "system"); $("#system-latest-question").hidden = false; $("#system-latest-question").textContent = localized(item, "first_question"); $("#system-live-content").textContent = opening; openMentor(); } -let pythonWorker = null; -let pythonReady = null; -let resolvePythonReady = null; -let rejectPythonReady = null; -const pendingRuns = new Map(); +function assignNextSystem() { state.currentSystem = chooseNextSystem(); writeJson("current-system", state.currentSystem.id); state.histories.system = []; writeJson("histories", state.histories); $("#system-brief").hidden = false; renderSystem(); beginSystemStress(); } + +function editorHighlight(code) { + let html = escapeHtml(code); const placeholders = []; + html = html.replace(/("{3}[\s\S]*?"{3}|'{3}[\s\S]*?'{3}|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[^\n]*)/g, (match) => { + const tokenClass = match.startsWith("#") ? "tok-comment" : "tok-string"; + const index = placeholders.push(`${match}`) - 1; + return `\uE000${String.fromCharCode(0xE100 + index)}\uE001`; + }); + html = html.replace(/\b(class|def|return|if|else|elif|for|while|in|import|from|as|try|except|finally|with|lambda|pass|break|continue|True|False|None|public|private|static|void|int|new)\b/g, '$1').replace(/\b(\d+(?:\.\d+)?)\b/g, '$1'); + return html.replace(/\uE000([\uE100-\uE1FF])\uE001/g, (_, marker) => placeholders[marker.charCodeAt(0) - 0xE100]); +} -function resetPythonWorker() { - pythonWorker?.terminate(); pythonWorker = null; pythonReady = null; pendingRuns.clear(); +function updateEditor() { + const field = $("#code-editor"); const lines = field.value.split("\n").length; $("#editor-lines").textContent = Array.from({ length: lines }, (_, index) => index + 1).join("\n"); $("#editor-highlight").innerHTML = `${editorHighlight(field.value)}\n`; + localStorage.setItem(`${STORAGE}:${draftKey()}`, field.value); syncEditorScroll(); } +function syncEditorScroll() { $("#editor-highlight").scrollTop = $("#code-editor").scrollTop; $("#editor-highlight").scrollLeft = $("#code-editor").scrollLeft; $("#editor-lines").scrollTop = $("#code-editor").scrollTop; } +let pythonWorker = null; let pythonReady = null; const pendingRuns = new Map(); +function resetPythonWorker() { pythonWorker?.terminate(); pythonWorker = null; pythonReady = null; pendingRuns.clear(); } function ensurePythonWorker() { if (pythonReady) return pythonReady; - pythonReady = new Promise((resolve, reject) => { resolvePythonReady = resolve; rejectPythonReady = reject; }); - pythonWorker = new Worker("./python-worker.js"); - pythonWorker.addEventListener("message", (event) => { - if (event.data.type === "ready") { resolvePythonReady?.(); return; } - if (event.data.type === "init-error") { rejectPythonReady?.(new Error(event.data.error)); resetPythonWorker(); return; } - const pending = pendingRuns.get(event.data.id); - if (!pending) return; - clearTimeout(pending.timer); pendingRuns.delete(event.data.id); - if (event.data.type === "result") pending.resolve(event.data.result); else pending.reject(new Error(event.data.error)); - }); - pythonWorker.addEventListener("error", (event) => { rejectPythonReady?.(new Error(event.message)); resetPythonWorker(); }); + pythonReady = new Promise((resolve, reject) => { pythonWorker = new Worker("./python-worker.js"); pythonWorker.addEventListener("message", (event) => { if (event.data.type === "ready") return resolve(); if (event.data.type === "init-error") { resetPythonWorker(); return reject(new Error(event.data.error)); } const pending = pendingRuns.get(event.data.id); if (!pending) return; clearTimeout(pending.timer); pendingRuns.delete(event.data.id); event.data.type === "result" ? pending.resolve(event.data.result) : pending.reject(new Error(event.data.error)); }); pythonWorker.addEventListener("error", (event) => { resetPythonWorker(); reject(new Error(event.message)); }); }); return pythonReady; } async function executePython() { - const output = $("#run-result"); - if (selectedLanguage() !== "python") { output.textContent = t("pythonOnly"); return null; } - output.textContent = t("runnerLoading"); + const output = $("#run-result"); output.hidden = false; + if (state.language !== "python") { output.textContent = state.locale === "zh" ? "浏览器运行器目前支持 Python;Java 仍可编辑和保存。" : "The browser runner currently supports Python; Java can still be edited and saved."; return null; } + output.textContent = state.locale === "zh" ? "正在加载安全的浏览器 Python 运行器…" : "Loading the isolated browser Python runner…"; + try { + await ensurePythonWorker(); const id = crypto.randomUUID(); const timeout = Number($("#run-timeout").value || 3) * 1000; output.textContent = state.locale === "zh" ? "正在运行测试…" : "Running tests…"; + const result = await new Promise((resolve, reject) => { const timer = setTimeout(() => { pendingRuns.delete(id); resetPythonWorker(); reject(new Error(state.locale === "zh" ? "运行超时,已重置隔离运行器。" : "Run timed out; isolated runner reset.")); }, timeout); pendingRuns.set(id, { resolve, reject, timer }); pythonWorker.postMessage({ id, source: $("#code-editor").value, methodName: $("#method-name").value, testCases: $("#test-cases").value }); }); + state.lastRun = result; output.textContent = [`${result.passed === result.total ? "✓" : "✗"} ${state.locale === "zh" ? "通过" : "passed"} ${result.passed}/${result.total}`, ...(result.cases || []).map((item, index) => `#${index + 1} ${item.passed ? "✓" : "✗"} actual=${JSON.stringify(item.actual)} expected=${JSON.stringify(item.expected)}`), result.stdout ? `stdout:\n${result.stdout}` : "", result.error || ""].filter(Boolean).join("\n"); + if (result.total) updateProgress(result.passed === result.total ? "mastered" : "review"); return result; + } catch (error) { output.textContent = `${state.locale === "zh" ? "运行失败" : "Run failed"}: ${error.message}`; return null; } +} + +function savedSolutions() { return readJson("solutions", []); } +function renderSavedSolutions() { const select = $("#saved-solution-select"); const current = select.value; select.replaceChildren(); const blank = document.createElement("option"); blank.value = ""; blank.textContent = t("newFile"); select.append(blank); savedSolutions().forEach((item, index) => { const option = document.createElement("option"); option.value = String(index); option.textContent = `${item.filename} · ${new Date(item.savedAt).toLocaleDateString()}`; select.append(option); }); select.value = [...select.options].some((option) => option.value === current) ? current : ""; } +async function saveSolution() { + const filename = $("#save-filename").value.trim() || `${state.currentProblem.id}.${state.language === "python" ? "py" : "java"}`; const solutions = savedSolutions(); const existing = solutions.findIndex((item) => item.filename === filename); + if (existing >= 0 && !$("#allow-overwrite").checked) { showToast(state.locale === "zh" ? "文件已存在;勾选允许覆盖后再保存。" : "File exists; enable overwrite to save."); return; } + const record = { filename, source: $("#code-editor").value, language: state.language, problemId: state.currentProblem.id, savedAt: new Date().toISOString() }; if (existing >= 0) solutions[existing] = record; else solutions.unshift(record); writeJson("solutions", solutions.slice(0, 40)); renderSavedSolutions(); try { - await ensurePythonWorker(); - output.textContent = t("runnerRunning"); - const id = crypto.randomUUID(); - const timeoutMs = Number($("#run-timeout").value || 4) * 1000; - const result = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { pendingRuns.delete(id); resetPythonWorker(); reject(new Error(t("runnerTimeout"))); }, timeoutMs); - pendingRuns.set(id, { resolve, reject, timer }); - pythonWorker.postMessage({ id, source: $("#code-editor").value, methodName: $("#method-name").value, testCases: $("#test-cases").value }); - }); - state.lastRun = result; - output.textContent = [t("testsPassed", { passed: result.passed, total: result.total }), ...(result.cases || []).map((item, index) => `#${index + 1} ${item.passed ? "✓" : "✗"} actual=${JSON.stringify(item.actual)} expected=${JSON.stringify(item.expected)}`), result.stdout, result.error].filter(Boolean).join("\n"); - localStorage.setItem(progressKey(), result.passed === result.total ? "mastered" : "review"); renderCurrent(false); - return result; + if (window.showSaveFilePicker) { const handle = await window.showSaveFilePicker({ suggestedName: filename, types: [{ description: "Source code", accept: { "text/plain": [`.${filename.split(".").pop()}`] } }] }); const writable = await handle.createWritable(); await writable.write(record.source); await writable.close(); } + else { const link = document.createElement("a"); link.href = URL.createObjectURL(new Blob([record.source], { type: "text/plain" })); link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(link.href), 1000); } + showToast(state.locale === "zh" ? "已保存到浏览器并导出文件" : "Saved in browser and exported"); + } catch (error) { if (error.name !== "AbortError") showToast(error.message); else showToast(state.locale === "zh" ? "代码已保存在浏览器中" : "Code saved in browser"); } +} + +async function loadStatement() { + const item = state.currentProblem; const button = $("#load-statement"); button.disabled = true; button.textContent = state.locale === "zh" ? "正在从 LeetCode 载入…" : "Loading from LeetCode…"; + try { + const host = state.statementLocale === "zh" ? "https://leetcode.cn/graphql/" : "https://leetcode.com/graphql/"; + const query = `query questionData($titleSlug: String!) { question(titleSlug: $titleSlug) { content translatedContent codeSnippets { lang langSlug code } } }`; + const response = await timedFetch(host, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables: { titleSlug: item.slug } }) }, 12000); + if (!response.ok) throw new Error(`HTTP ${response.status}`); const question = (await response.json()).data?.question; if (!question) throw new Error("No statement returned"); + const raw = state.statementLocale === "zh" ? (question.translatedContent || question.content) : question.content; const parsed = new DOMParser().parseFromString(raw || "", "text/html"); parsed.querySelectorAll("script,style,iframe,object,embed,form").forEach((node) => node.remove()); parsed.querySelectorAll("*").forEach((node) => [...node.attributes].forEach((attribute) => { if (attribute.name.startsWith("on") || ["style", "srcset"].includes(attribute.name)) node.removeAttribute(attribute.name); if (["href", "src"].includes(attribute.name) && !/^(https?:|data:image\/)/i.test(attribute.value)) node.removeAttribute(attribute.name); })); + $("#problem-statement").replaceChildren(...parsed.body.childNodes); $("#problem-statement").hidden = false; $("#statement-status").hidden = true; + const snippet = question.codeSnippets?.find((entry) => entry.langSlug === state.language); if (snippet?.code && !localStorage.getItem(`${STORAGE}:${draftKey()}`)) { $("#code-editor").value = snippet.code; updateEditor(); } + button.hidden = true; } catch (error) { - output.textContent = error.message === t("runnerTimeout") ? error.message : t("runnerFailed", { error: error.message }); - return null; + $("#statement-status").hidden = false; $("#statement-status").innerHTML = `${escapeHtml(state.locale === "zh" ? "浏览器被 LeetCode 的跨域策略阻止,无法直接导入题面。" : "LeetCode's cross-origin policy blocked direct import in this browser.")} ${escapeHtml(t("openLeetcode"))} ↗`; + showToast(error.message); + } finally { button.disabled = false; button.textContent = t("loadStatement"); } +} + +function importProblem() { + const value = $("#problem-reference").value.trim(); const slug = value.replace(/\?.*$/, "").replace(/\/$/, "").split("/").pop(); const item = state.catalog.problems.find((problem) => problem.slug === slug || String(problem.id) === value); + if (!item) { showToast(state.locale === "zh" ? "课程目录中没有匹配的题目。" : "No matching problem in the curriculum."); return; } + state.currentProblem = item; writeJson("current-problem", item.id); $("#code-editor").value = ""; renderAlgorithm(true); $("#statement-status").hidden = false; $("#problem-statement").hidden = true; $("#load-statement").hidden = false; +} + +function renderRichMessage(root, text) { + root.replaceChildren(); const pieces = String(text).split(/```mermaid\s*([\s\S]*?)```/i); + pieces.forEach((piece, index) => { if (index % 2 === 0) { if (!piece.trim()) return; const p = document.createElement("p"); p.textContent = piece.trim(); root.append(p); } else { const visual = document.createElement("div"); visual.className = "mermaid-lite"; const edges = [...piece.matchAll(/([\w\u4e00-\u9fff]+)(?:\[[^\]]*\])?\s*--+>?\s*([\w\u4e00-\u9fff]+)(?:\[[^\]]*\])?/g)].map((match) => [match[1], match[2]]); const labels = edges.length ? [...new Set(edges.flat())] : piece.split("\n").filter(Boolean).slice(0, 8); renderFlow(visual, labels); root.append(visual); } }); +} + +function renderMessages() { + const history = state.histories[state.mode] || []; [$("#messages"), $("#docked-messages")].forEach((root) => { root.replaceChildren(); history.forEach((entry) => { const article = document.createElement("article"); article.className = `message ${entry.role}`; const badge = document.createElement("span"); badge.textContent = entry.role === "assistant" ? "J" : "YOU"; const body = document.createElement("div"); body.className = "message-body"; renderRichMessage(body, entry.content); article.append(badge, body); root.append(article); }); root.scrollTop = root.scrollHeight; }); + if (state.mode === "system" && history.length) { const latest = [...history].reverse().find((entry) => entry.role === "assistant"); if (latest) renderRichMessage($("#system-live-content"), latest.content); } +} + +function appendMessage(role, content, mode = state.mode) { state.histories[mode].push({ role, content, createdAt: Date.now() }); writeJson("histories", state.histories); renderMessages(); } + +function workspaceRequest(question, trigger = "User request") { + if (state.mode === "system") { + const requirement = $("#custom-requirement").value.trim() || localized(state.currentSystem, "requirement"); return `This is an interactive system-design interview.\nRequirement: ${requirement}\nCurrent checkpoint: ${localized(state.currentSystem, "first_question")}\nTrigger: ${trigger}\nStudent: ${question}\nAdvance exactly one decision, include one small fenced Mermaid diagram, then ask exactly one question.`; } + return `This is a coaching turn grounded in the current browser workspace. Do not provide a full replacement solution unless the student explicitly asks for 求最优解代码.\nTrigger: ${trigger}\nQuestion: ${question}\nProblem: ${state.currentProblem.id}. ${localized(state.currentProblem, "title")}\nFocus: ${problemCopy(state.currentProblem, "focus")}\nLanguage: ${state.language}\nCurrent code:\n\`\`\`${state.language}\n${$("#code-editor").value.slice(0, 12000)}\n\`\`\`\nTests: ${$("#test-cases").value.slice(0, 4000)}\nLast run: ${JSON.stringify(state.lastRun)}\nGive one short hint and exactly one question.`; } -function appendMessage(role, message) { - const article = document.createElement("article"); article.className = `message ${role}`; - const badge = document.createElement("span"); badge.textContent = role === "assistant" ? "J" : "YOU"; - const paragraph = document.createElement("p"); paragraph.textContent = message; - article.append(badge, paragraph); $("#messages").append(article); article.scrollIntoView({ behavior: "smooth", block: "nearest" }); - return paragraph; +async function callModel(messages) { + const settings = state.settings; const provider = settings.provider; const model = settings.model; + if (provider === "ollama") { + const response = await timedFetch(providerUrl("/api/chat"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, stream: false, think: settings.reasoning !== "none", options: { temperature: settings.temperature, top_p: settings.topP, num_ctx: settings.context, num_predict: settings.maxOutput }, messages }) }, settings.timeout * 1000); + if (!response.ok) throw new Error(`HTTP ${response.status}`); return (await response.json()).message?.content || ""; + } + if (provider === "gemini") { + const key = $("#api-key-input").value.trim(); const system = messages.find((entry) => entry.role === "system")?.content || ""; const contents = messages.filter((entry) => entry.role !== "system").map((entry) => ({ role: entry.role === "assistant" ? "model" : "user", parts: [{ text: entry.content }] })); + const response = await timedFetch(`${providerUrl(`/v1beta/models/${encodeURIComponent(model)}:generateContent`)}?key=${encodeURIComponent(key)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ systemInstruction: { parts: [{ text: system }] }, contents, generationConfig: { temperature: settings.temperature, topP: settings.topP, maxOutputTokens: settings.maxOutput } }) }, settings.timeout * 1000); if (!response.ok) throw new Error(`HTTP ${response.status}`); return (await response.json()).candidates?.[0]?.content?.parts?.map((part) => part.text).join("") || ""; + } + const headers = { "Content-Type": "application/json" }; if (PROVIDERS[provider].key) headers.Authorization = `Bearer ${$("#api-key-input").value.trim()}`; + const response = await timedFetch(providerUrl("/v1/chat/completions"), { method: "POST", headers, body: JSON.stringify({ model, messages, temperature: settings.temperature, top_p: settings.topP, max_tokens: settings.maxOutput }) }, settings.timeout * 1000); if (!response.ok) throw new Error(`HTTP ${response.status}`); return (await response.json()).choices?.[0]?.message?.content || ""; } -async function askJarvis(message) { - if (!message.trim()) { showToast(t("emptyMessage")); return; } - if (state.ollamaStatus !== "ready" || !state.selectedModel) { appendMessage("assistant", t("jarvisNeedsModel")); showConnectionDialog(); return; } - appendMessage("user", message.trim()); - const pending = appendMessage("assistant", t("jarvisThinking")); +async function askJarvis(question, trigger = "User request") { + const text = question.trim(); if (!text) { showToast(state.locale === "zh" ? "先写下你的思路或问题。" : "Write your reasoning or question first."); return; } + if (state.runtime.status !== "ready") { appendMessage("assistant", state.locale === "zh" ? "本地模型尚未连接。代码仍可在浏览器运行;JARVIS 需要先在左上角模型中心连接服务。" : "The model is not connected. Browser code practice still works; connect a model from the LT model center for JARVIS."); if (state.settings.provider === "ollama") showConnectionDialog(); else openDrawer(true); return; } + appendMessage("user", text); const pending = { role: "assistant", content: state.locale === "zh" ? "JARVIS 正在分析当前现场…" : "JARVIS is analyzing the current workspace…", createdAt: Date.now() }; state.histories[state.mode].push(pending); renderMessages(); try { - const response = await loopbackFetch("/api/chat", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: state.selectedModel, stream: false, think: false, - options: { temperature: 0.2, top_p: 0.9, num_predict: 700, num_ctx: 8192 }, - messages: [ - { role: "system", content: `You are JARVIS, a concise Socratic interview tutor. Reply in ${state.locale === "zh" ? "Chinese" : "English"}. Give one specific observation, one short hint, and exactly one next question. Never invent execution results.` }, - { role: "user", content: `Mission: ${$("#problem-title").textContent}\nContext: ${$("#problem-focus").textContent}\nObjective: ${$("#objective").textContent}\nCurrent work:\n${$("#code-editor").value}\nLast browser run: ${JSON.stringify(state.lastRun)}\n\nStudent: ${message.trim()}` }, - ], - }), - }, 180_000); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const payload = await response.json(); pending.textContent = payload.message?.content || t("jarvisFailed", { error: "empty response" }); - } catch (error) { pending.textContent = t("jarvisFailed", { error: error.message }); } -} - -function showToast(message) { - const toast = $("#toast"); toast.textContent = message; toast.hidden = false; - clearTimeout(showToast.timer); showToast.timer = setTimeout(() => { toast.hidden = true; }, 2_800); + const system = state.mode === "algorithm" ? state.settings.algorithmPrompt : state.settings.systemPrompt; const prior = state.histories[state.mode].slice(-12, -1).map((entry) => ({ role: entry.role, content: entry.content })); const answer = await callModel([{ role: "system", content: `${system}\nUse ${state.locale === "zh" ? "Chinese" : "English"} for this turn.` }, ...prior, { role: "user", content: workspaceRequest(text, trigger) }]); pending.content = answer || (state.locale === "zh" ? "模型返回了空响应。" : "The model returned an empty response."); setRuntime("ready"); + } catch (error) { pending.content = `${state.locale === "zh" ? "模型请求失败" : "Model request failed"}: ${error.message}`; setRuntime("error", error.message); } + writeJson("histories", state.histories); renderMessages(); } +function openMentor() { if (state.layout.mentorStyle === "docked" && state.mode === "algorithm") { state.layout.mentor = true; updateLayout(); return; } $("#jarvis-panel").hidden = false; $("#jarvis-launcher").setAttribute("aria-expanded", "true"); } +function quickAction(action) { if (action === "hide") { state.layout.mentor = false; updateLayout(); return; } const prompts = { stuck: state.locale === "zh" ? "我卡住了。只给我一个最小提示,再问一个问题。" : "I'm stuck. Give one minimal hint, then ask one question.", next: state.locale === "zh" ? "仅推进下一步,不要复述前文。" : "Advance only the next step without repeating prior context.", review: state.locale === "zh" ? "Review 当前工作,只指出最关键的一个问题。" : "Review the current work and identify only the single most important issue." }; askJarvis(prompts[action] || action, action); } + function setupSplitter() { - const splitter = $("#splitter"); - splitter.addEventListener("pointerdown", (event) => { - splitter.setPointerCapture(event.pointerId); document.body.classList.add("is-resizing"); - const move = (moveEvent) => { const bounds = $("#workspace").getBoundingClientRect(); const percent = Math.min(67, Math.max(28, (moveEvent.clientX - bounds.left) / bounds.width * 100)); document.documentElement.style.setProperty("--problem-width", `${percent}%`); localStorage.setItem("leettutor-problem-width", String(percent)); }; - const stop = () => { document.body.classList.remove("is-resizing"); splitter.removeEventListener("pointermove", move); splitter.removeEventListener("pointerup", stop); }; - splitter.addEventListener("pointermove", move); splitter.addEventListener("pointerup", stop); - }); - const saved = Number(localStorage.getItem("leettutor-problem-width")); if (saved >= 28 && saved <= 67) document.documentElement.style.setProperty("--problem-width", `${saved}%`); + const splitter = $("#splitter"); splitter.addEventListener("pointerdown", (event) => { splitter.setPointerCapture(event.pointerId); document.body.classList.add("is-resizing"); const move = (moveEvent) => { const bounds = $("#algorithm-workspace").getBoundingClientRect(); state.layout.problemWidth = Math.min(67, Math.max(28, ((moveEvent.clientX - bounds.left) / bounds.width) * 100)); document.documentElement.style.setProperty("--problem-width", `${state.layout.problemWidth}%`); }; const stop = () => { document.body.classList.remove("is-resizing"); writeJson("layout", state.layout); splitter.removeEventListener("pointermove", move); splitter.removeEventListener("pointerup", stop); }; splitter.addEventListener("pointermove", move); splitter.addEventListener("pointerup", stop); }); } function setupMentorDrag() { - const panel = $("#jarvis-panel"); const handle = $("#mentor-drag-handle"); - const restore = () => { try { const point = JSON.parse(localStorage.getItem("leettutor-mentor-position") || "null"); if (point && innerWidth > 620) { panel.style.left = `${Math.max(7, Math.min(point.left, innerWidth - panel.offsetWidth - 7))}px`; panel.style.top = `${Math.max(7, Math.min(point.top, innerHeight - panel.offsetHeight - 7))}px`; panel.style.right = "auto"; panel.style.bottom = "auto"; } } catch (_error) {} }; - handle.addEventListener("pointerdown", (event) => { - if (event.target.closest("button")) return; - const rect = panel.getBoundingClientRect(); const offsetX = event.clientX - rect.left; const offsetY = event.clientY - rect.top; handle.setPointerCapture(event.pointerId); - const move = (moveEvent) => { const left = Math.max(7, Math.min(moveEvent.clientX - offsetX, innerWidth - panel.offsetWidth - 7)); const top = Math.max(7, Math.min(moveEvent.clientY - offsetY, innerHeight - panel.offsetHeight - 7)); panel.style.left = `${left}px`; panel.style.top = `${top}px`; panel.style.right = "auto"; panel.style.bottom = "auto"; }; - const stop = () => { const current = panel.getBoundingClientRect(); localStorage.setItem("leettutor-mentor-position", JSON.stringify({ left: current.left, top: current.top })); handle.removeEventListener("pointermove", move); handle.removeEventListener("pointerup", stop); }; - handle.addEventListener("pointermove", move); handle.addEventListener("pointerup", stop); - }); - $("#dock-mentor").addEventListener("click", () => { localStorage.removeItem("leettutor-mentor-position"); panel.removeAttribute("style"); }); - addEventListener("resize", restore); restore(); + const panel = $("#jarvis-panel"), handle = $("#mentor-drag-handle"); const stored = readJson("mentor-position", null); if (stored && innerWidth > 760) Object.assign(panel.style, { left: `${stored.left}px`, top: `${stored.top}px`, right: "auto", bottom: "auto" }); + handle.addEventListener("pointerdown", (event) => { if (event.target.closest("button") || innerWidth <= 760) return; const rect = panel.getBoundingClientRect(), offsetX = event.clientX - rect.left, offsetY = event.clientY - rect.top; handle.setPointerCapture(event.pointerId); const move = (moveEvent) => { panel.style.left = `${Math.max(8, Math.min(moveEvent.clientX - offsetX, innerWidth - panel.offsetWidth - 8))}px`; panel.style.top = `${Math.max(8, Math.min(moveEvent.clientY - offsetY, innerHeight - panel.offsetHeight - 8))}px`; panel.style.right = "auto"; panel.style.bottom = "auto"; }; const stop = () => { const now = panel.getBoundingClientRect(); writeJson("mentor-position", { left: now.left, top: now.top }); handle.removeEventListener("pointermove", move); handle.removeEventListener("pointerup", stop); }; handle.addEventListener("pointermove", move); handle.addEventListener("pointerup", stop); }); } function bindEvents() { - $("#language").addEventListener("change", (event) => { localStorage.setItem("leettutor-language", event.target.value); applyPreferences(); }); - $("#theme").addEventListener("change", (event) => { localStorage.setItem("leettutor-theme", event.target.value); applyPreferences(); }); - matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyPreferences); - $("#settings-button").addEventListener("click", () => $("#settings-dialog").showModal()); - $("#connection-button").addEventListener("click", () => diagnoseOllama({ showDialog: true })); - $("#banner-action").addEventListener("click", showConnectionDialog); - for (const selector of ["#retry-connection", "#installed-retry", "#origin-retry", "#permission-retry"]) $(selector).addEventListener("click", () => diagnoseOllama({ showDialog: true })); - $("#open-ollama").addEventListener("click", openAndWaitForOllama); - $("#pull-model").addEventListener("click", pullModel); - $("#model-select").addEventListener("change", (event) => { state.selectedModel = event.target.value; localStorage.setItem("leettutor-model", state.selectedModel); renderRuntime(); }); - $("#enter-workspace").addEventListener("click", () => $("#connection-dialog").close()); - $("#origin-command").textContent = platformOriginCommand(); - $("#copy-origin-command").addEventListener("click", async () => { await navigator.clipboard.writeText($("#origin-command").textContent); showToast(t("copied")); }); - $$(".mode-switch button").forEach((button) => button.addEventListener("click", () => { state.mode = button.dataset.mode; $$(".mode-switch button").forEach((item) => item.classList.toggle("is-active", item === button)); renderCatalogControls(); renderCurrent(); })); - $("#topic-select").addEventListener("change", () => { renderCatalogControls(); renderCurrent(); }); - $("#problem-select").addEventListener("change", (event) => { const items = state.mode === "algorithm" ? state.catalog.problems : state.catalog.system_design; state.current = items.find((item) => String(item.id) === event.target.value) || null; renderCurrent(); }); - $$(".language-switch button").forEach((button) => button.addEventListener("click", () => { $$(".language-switch button").forEach((item) => item.classList.toggle("is-active", item === button)); renderCurrent(); })); - $("#code-editor").addEventListener("input", () => { if (!state.current) return; localStorage.setItem(draftKey(), $("#code-editor").value); $("#draft-status").textContent = t("savedLocally"); }); - $("#code-editor").addEventListener("keydown", (event) => { if (event.key !== "Tab") return; event.preventDefault(); const field = event.currentTarget; const before = field.value.slice(0, field.selectionStart); const after = field.value.slice(field.selectionEnd); const insert = event.shiftKey ? "" : " "; field.value = before + insert + after; field.selectionStart = field.selectionEnd = before.length + insert.length; field.dispatchEvent(new Event("input")); }); - $("#mark-review").addEventListener("click", () => { localStorage.setItem(progressKey(), "review"); renderCurrent(false); }); - $("#mark-mastered").addEventListener("click", () => { localStorage.setItem(progressKey(), "mastered"); renderCurrent(false); }); - $("#run-code").addEventListener("click", executePython); - $("#run-review").addEventListener("click", async () => { if (state.mode === "algorithm") await executePython(); await askJarvis(state.locale === "zh" ? "请结合当前代码和运行结果,只提示下一步。" : "Review the current code and run result, then hint only the next step."); }); - $("#jarvis-launcher").addEventListener("click", () => { const panel = $("#jarvis-panel"); panel.hidden = !panel.hidden; $("#jarvis-launcher").setAttribute("aria-expanded", String(!panel.hidden)); }); - $("#hide-mentor").addEventListener("click", () => { $("#jarvis-panel").hidden = true; $("#jarvis-launcher").setAttribute("aria-expanded", "false"); }); - $("#chat-form").addEventListener("submit", async (event) => { event.preventDefault(); const field = $("#message"); const value = field.value; field.value = ""; await askJarvis(value); }); - setupSplitter(); setupMentorDrag(); + $("#product-mark").addEventListener("click", () => openDrawer(!$("#settings-drawer").classList.contains("is-open"))); $("#close-drawer").addEventListener("click", () => openDrawer(false)); $("#drawer-backdrop").addEventListener("click", () => openDrawer(false)); + $("#provider-select").addEventListener("change", (event) => { const config = PROVIDERS[event.target.value]; $("#endpoint-input").value = config.endpoint; $("#manual-model").value = config.model; renderProviderFields(); }); + $("#model-select").addEventListener("change", () => { $("#manual-model-field").hidden = Boolean($("#model-select").value); }); $("#refresh-models").addEventListener("click", () => probeProvider({ openGuide: false })); $("#save-settings").addEventListener("click", saveSettingsFromDrawer); + $("#temperature").addEventListener("input", () => { $("#temperature-output").value = Number($("#temperature").value).toFixed(2); }); $("#top-p").addEventListener("input", () => { $("#top-p-output").value = Number($("#top-p").value).toFixed(2); }); $("#auto-tune").addEventListener("change", renderProviderFields); + $("#clear-chat").addEventListener("click", () => { state.histories[state.mode] = []; writeJson("histories", state.histories); renderMessages(); }); $("#open-vscode").addEventListener("click", () => { location.href = "vscode://vscode.git/clone?url=https://github.com/widmonstertony/Leetcode.git"; }); + $("#runtime-guide").addEventListener("click", () => showConnectionDialog()); $("#open-runtime").addEventListener("click", openAndWaitForOllama); + $$('.mode-switch [data-mode]').forEach((button) => button.addEventListener("click", () => switchMode(button.dataset.mode))); + $("#language").addEventListener("change", (event) => { const browserChinese = navigator.language.toLowerCase().startsWith("zh"); state.locale = event.target.value === "system" ? (browserChinese ? "zh" : "en") : event.target.value; savePreferences(); applyPreferences(); }); $("#statement-language").addEventListener("change", (event) => { state.statementLocale = event.target.value; savePreferences(); }); + $("#theme-toggle").addEventListener("click", () => { state.theme = state.theme === "system" ? "light" : state.theme === "light" ? "dark" : "system"; savePreferences(); applyPreferences(); }); matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { if (state.theme === "system") applyPreferences(); }); + $("#start-coaching").addEventListener("click", beginCoaching); $("#next-problem").addEventListener("click", assignNextProblem); $("#mark-mastered").addEventListener("click", () => updateProgress("mastered")); $("#mark-review").addEventListener("click", () => updateProgress("review")); + $("#load-statement").addEventListener("click", loadStatement); $("#import-problem").addEventListener("click", importProblem); $("#redraw-algorithm").addEventListener("click", () => askJarvis(state.locale === "zh" ? "视觉解释:根据当前代码重画不变量。" : "Visual explanation: redraw the invariant from my current code.", "visual map")); + $("#hide-problem").addEventListener("click", () => { state.layout.problem = false; updateLayout(); }); $("#hide-code").addEventListener("click", () => { state.layout.code = false; updateLayout(); }); $("#hide-docked-mentor").addEventListener("click", () => { state.layout.mentor = false; updateLayout(); }); $("#float-mentor").addEventListener("click", () => { state.layout.mentorStyle = "floating"; updateLayout(); openMentor(); }); + ["problem", "code", "mentor"].forEach((name) => $(`#show-${name}`).addEventListener("change", (event) => { state.layout[name] = event.target.checked; updateLayout(); })); $$('input[name="mentor-style"]').forEach((radio) => radio.addEventListener("change", () => { state.layout.mentorStyle = radio.value; updateLayout(); })); $("#restore-layout").addEventListener("click", () => { state.layout = { ...state.layout, problem: true, code: true, mentor: true, mentorStyle: "floating", problemWidth: 43 }; renderLayoutControls(); updateLayout(); }); + $$('.language-switch [data-language]').forEach((button) => button.addEventListener("click", () => { state.language = button.dataset.language; $$('.language-switch [data-language]').forEach((item) => item.classList.toggle("is-active", item === button)); $("#code-editor").value = localStorage.getItem(`${STORAGE}:${draftKey()}`) || defaultSource(state.currentProblem); renderAlgorithm(); updateEditor(); })); + $("#code-editor").addEventListener("input", updateEditor); $("#code-editor").addEventListener("scroll", syncEditorScroll); $("#code-editor").addEventListener("keydown", (event) => { if (event.key !== "Tab") return; event.preventDefault(); const field = event.currentTarget; const start = field.selectionStart, end = field.selectionEnd; if (!event.shiftKey) { field.setRangeText(" ", start, end, "end"); } else { const lineStart = field.value.lastIndexOf("\n", start - 1) + 1; const remove = Math.min(4, (field.value.slice(lineStart).match(/^ +/) || [""])[0].length); field.setRangeText("", lineStart, lineStart + remove, "end"); } updateEditor(); }); + $("#run-code").addEventListener("click", executePython); $("#run-review").addEventListener("click", async () => { await executePython(); await askJarvis(state.locale === "zh" ? "结合当前代码和运行结果,只提示下一步。" : "Use the current code and run result; hint only the next step.", "run and review"); }); $("#save-solution").addEventListener("click", saveSolution); + $("#load-solution").addEventListener("click", () => { const item = savedSolutions()[Number($("#saved-solution-select").value)]; if (!item) return; state.language = item.language; $("#code-editor").value = item.source; $$('.language-switch [data-language]').forEach((button) => button.classList.toggle("is-active", button.dataset.language === state.language)); updateEditor(); }); $("#upload-solution").addEventListener("click", () => $("#solution-file").click()); $("#solution-file").addEventListener("change", async (event) => { const file = event.target.files?.[0]; if (!file) return; state.language = file.name.endsWith(".java") ? "java" : "python"; $("#code-editor").value = await file.text(); $("#save-filename").value = file.name; $$('.language-switch [data-language]').forEach((button) => button.classList.toggle("is-active", button.dataset.language === state.language)); updateEditor(); }); + $("#start-stress").addEventListener("click", beginSystemStress); $("#next-system-mission").addEventListener("click", assignNextSystem); $("#redraw-system").addEventListener("click", () => askJarvis(state.locale === "zh" ? "根据目前确认的设计,重画当前架构图。" : "Redraw the architecture using only confirmed decisions.", "visual map")); + $("#system-command-form").addEventListener("submit", async (event) => { event.preventDefault(); const field = $("#system-command"); const value = field.value; field.value = ""; await askJarvis(value, "command dock"); }); $("#system-next-step").addEventListener("click", () => quickAction("next")); + $("#jarvis-launcher").addEventListener("click", openMentor); $("#hide-mentor").addEventListener("click", () => { $("#jarvis-panel").hidden = true; $("#jarvis-launcher").setAttribute("aria-expanded", "false"); }); $("#dock-mentor").addEventListener("click", () => { state.layout.mentorStyle = "docked"; state.layout.mentor = true; $("#jarvis-panel").hidden = true; updateLayout(); }); + [["#chat-form", "#message"], ["#docked-chat-form", "#docked-message"]].forEach(([formSelector, fieldSelector]) => $(formSelector).addEventListener("submit", async (event) => { event.preventDefault(); const field = $(fieldSelector); const value = field.value; field.value = ""; await askJarvis(value); })); $$('[data-action]').forEach((button) => button.addEventListener("click", () => quickAction(button.dataset.action))); + $$('#mobile-nav [data-mobile-pane]').forEach((button) => button.addEventListener("click", () => { state.layout.mobilePane = button.dataset.mobilePane; if (state.layout.mobilePane === "jarvis" && state.layout.mentorStyle === "floating") openMentor(); updateLayout(); })); + $$('.dialog-close').forEach((button) => button.addEventListener("click", () => button.closest("dialog").close())); $("#enter-workspace").addEventListener("click", () => $("#connection-dialog").close()); ["#retry-connection", "#installed-retry", "#origin-retry", "#permission-retry"].forEach((selector) => $(selector).addEventListener("click", () => probeProvider({ openGuide: true }))); $("#open-ollama").addEventListener("click", openAndWaitForOllama); $("#pull-model").addEventListener("click", pullModel); $("#copy-origin-command").addEventListener("click", async () => { await navigator.clipboard.writeText($("#origin-command").textContent); showToast(state.locale === "zh" ? "命令已复制" : "Command copied"); }); + addEventListener("resize", updateLayout); setupSplitter(); setupMentorDrag(); } async function initialize() { - applyPreferences(); bindEvents(); - await Promise.all([loadCatalog(), diagnoseOllama()]); + loadState(); bindEvents(); $("#language").value = readJson("preferences", {}).locale || "system"; $("#statement-language").value = state.statementLocale; $("#origin-command").textContent = platformOriginCommand(); + try { await loadCatalog(); } catch (error) { showToast(error.message); return; } + applyPreferences(); renderLayoutControls(); updateLayout(); updateEditor(); probeProvider(); } document.addEventListener("DOMContentLoaded", initialize); diff --git a/web-demo/catalog.json b/web-demo/catalog.json index 38ddfbc..b0d9967 100644 --- a/web-demo/catalog.json +++ b/web-demo/catalog.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "problems": [ { "id": 704, @@ -10,6 +10,8 @@ "difficulty": "Easy", "focus": "建立闭区间与收敛不变量", "invariant_prompt": "循环结束时,left/right 分别代表什么?", + "focus_en": "Establish a closed search interval and its convergence invariant", + "invariant_prompt_en": "At loop exit, what do left and right represent?", "url": "https://leetcode.com/problems/binary-search/" }, { @@ -21,6 +23,8 @@ "difficulty": "Easy", "focus": "把“找到值”改写为“找第一个可行位置”", "invariant_prompt": "什么条件能说明 mid 仍可能是答案?", + "focus_en": "Turn exact lookup into finding the first feasible position", + "invariant_prompt_en": "What condition means mid can still be the answer?", "url": "https://leetcode.com/problems/search-insert-position/" }, { @@ -32,6 +36,8 @@ "difficulty": "Medium", "focus": "统一 lower bound / upper bound,消灭等号混乱", "invariant_prompt": "若使用 while left < right,第一次出现 target 的可行条件是什么?", + "focus_en": "Unify lower and upper bounds without ambiguous equality cases", + "invariant_prompt_en": "With while left < right, what makes an index feasible for the first target?", "url": "https://leetcode.com/problems/find-first-and-last-position-of-element-in-sorted-array/" }, { @@ -43,6 +49,8 @@ "difficulty": "Medium", "focus": "用 right 作为可靠参照物保留候选", "invariant_prompt": "nums[mid] 与 nums[right] 的比较能排除哪一半?", + "focus_en": "Use right as a reliable reference while preserving the minimum candidate", + "invariant_prompt_en": "What half can nums[mid] versus nums[right] safely eliminate?", "url": "https://leetcode.com/problems/find-minimum-in-rotated-sorted-array/" }, { @@ -54,6 +62,8 @@ "difficulty": "Medium", "focus": "识别有序半边并判断 target 是否在其中", "invariant_prompt": "每轮至少有哪一半一定有序?", + "focus_en": "Identify the sorted half and decide whether target lies inside it", + "invariant_prompt_en": "Which half must be sorted on every iteration?", "url": "https://leetcode.com/problems/search-in-rotated-sorted-array/" }, { @@ -65,6 +75,8 @@ "difficulty": "Medium", "focus": "从二分下标进阶到二分答案", "invariant_prompt": "速度越大,完成时间如何单调变化?", + "focus_en": "Advance from searching indices to searching the answer space", + "invariant_prompt_en": "As speed increases, how does completion time change monotonically?", "url": "https://leetcode.com/problems/koko-eating-bananas/" }, { @@ -76,6 +88,8 @@ "difficulty": "Hard", "focus": "把最优化问题改写为可行性判定", "invariant_prompt": "给定最大段和 limit,如何判断它是否可行?", + "focus_en": "Rewrite an optimization problem as a feasibility decision", + "invariant_prompt_en": "Given a maximum segment sum limit, how can you decide whether it is feasible?", "url": "https://leetcode.com/problems/split-array-largest-sum/" }, { @@ -87,6 +101,8 @@ "difficulty": "Easy", "focus": "明确栈中保存尚未匹配的对象", "invariant_prompt": "遇到右括号时,谁应该被结算?", + "focus_en": "Define the stack as the set of unmatched opening tokens", + "invariant_prompt_en": "When a closing bracket arrives, what should be resolved?", "url": "https://leetcode.com/problems/valid-parentheses/" }, { @@ -98,6 +114,8 @@ "difficulty": "Medium", "focus": "维护与主栈同步的辅助状态", "invariant_prompt": "弹出元素后,最小值如何恢复?", + "focus_en": "Maintain auxiliary state in lockstep with the main stack", + "invariant_prompt_en": "After a pop, how is the previous minimum restored?", "url": "https://leetcode.com/problems/min-stack/" }, { @@ -109,6 +127,8 @@ "difficulty": "Easy", "focus": "理解单调栈中谁等待被结算", "invariant_prompt": "栈顶与当前元素满足什么关系时应该 pop?", + "focus_en": "Understand which elements are waiting to be resolved in a monotonic stack", + "invariant_prompt_en": "What relation between the stack top and current value should trigger a pop?", "url": "https://leetcode.com/problems/next-greater-element-i/" }, { @@ -120,6 +140,8 @@ "difficulty": "Medium", "focus": "栈中保存下标以计算距离", "invariant_prompt": "为什么栈里不能只存温度?", + "focus_en": "Store indices in the stack so distance remains available", + "invariant_prompt_en": "Why is storing temperatures alone insufficient?", "url": "https://leetcode.com/problems/daily-temperatures/" }, { @@ -131,6 +153,8 @@ "difficulty": "Medium", "focus": "用栈保存嵌套层级的现场", "invariant_prompt": "进入新的方括号前需要保存哪些状态?", + "focus_en": "Use a stack to preserve the context of each nesting level", + "invariant_prompt_en": "Which pieces of state must be saved before entering a new bracket?", "url": "https://leetcode.com/problems/decode-string/" }, { @@ -142,6 +166,8 @@ "difficulty": "Hard", "focus": "用单调栈确定左右第一个更矮位置", "invariant_prompt": "柱子在什么时候才能确定自己的完整宽度?", + "focus_en": "Use a monotonic stack to find the first shorter bar on each side", + "invariant_prompt_en": "When does a bar's complete usable width become known?", "url": "https://leetcode.com/problems/largest-rectangle-in-histogram/" }, { @@ -153,6 +179,8 @@ "difficulty": "Hard", "focus": "理解凹槽被结算时的高与宽", "invariant_prompt": "pop 出来的柱子在几何上代表什么?", + "focus_en": "Resolve each basin with an explicit height and width", + "invariant_prompt_en": "Geometrically, what does a popped bar represent?", "url": "https://leetcode.com/problems/trapping-rain-water/" }, { @@ -164,6 +192,8 @@ "difficulty": "Medium", "focus": "固定大小的小顶堆", "invariant_prompt": "堆顶应该代表当前保留元素中的什么?", + "focus_en": "Keep a fixed-size min-heap", + "invariant_prompt_en": "What should the heap top mean among the retained elements?", "url": "https://leetcode.com/problems/kth-largest-element-in-an-array/" }, { @@ -175,6 +205,8 @@ "difficulty": "Medium", "focus": "频率表与固定大小堆组合", "invariant_prompt": "为什么大小为 k 的小顶堆更容易淘汰元素?", + "focus_en": "Combine a frequency table with a fixed-size heap", + "invariant_prompt_en": "Why does a size-k min-heap make eviction straightforward?", "url": "https://leetcode.com/problems/top-k-frequent-elements/" }, { @@ -186,6 +218,8 @@ "difficulty": "Medium", "focus": "说清堆顶和保留集合的含义", "invariant_prompt": "使用大小为 k 的堆时,堆顶应该是最近还是最远?", + "focus_en": "State exactly what the heap top and retained set mean", + "invariant_prompt_en": "For a size-k heap, should the top be the nearest or farthest retained point?", "url": "https://leetcode.com/problems/k-closest-points-to-origin/" }, { @@ -197,6 +231,8 @@ "difficulty": "Hard", "focus": "堆只保存每条链的当前候选", "invariant_prompt": "为什么堆大小最多只需要 k?", + "focus_en": "Keep only the current candidate from each sorted list", + "invariant_prompt_en": "Why does the heap never need more than k entries?", "url": "https://leetcode.com/problems/merge-k-sorted-lists/" }, { @@ -208,6 +244,8 @@ "difficulty": "Hard", "focus": "双堆维护分区与大小不变量", "invariant_prompt": "两个堆分别保存哪一半,大小最多能差多少?", + "focus_en": "Maintain partition and size invariants with two heaps", + "invariant_prompt_en": "Which half belongs in each heap, and how far may their sizes differ?", "url": "https://leetcode.com/problems/find-median-from-data-stream/" }, { @@ -219,6 +257,8 @@ "difficulty": "Easy", "focus": "从递归问题定义一维状态", "invariant_prompt": "dp[i] 究竟表示到达 i 的方法数还是剩余方法数?", + "focus_en": "Define a one-dimensional state from the recursive question", + "invariant_prompt_en": "Does dp[i] count ways to reach i or ways remaining after i?", "url": "https://leetcode.com/problems/climbing-stairs/" }, { @@ -230,6 +270,8 @@ "difficulty": "Medium", "focus": "写清选择当前与跳过当前", "invariant_prompt": "dp[i] 的两个来源分别对应什么选择?", + "focus_en": "Write the choices to take or skip the current house", + "invariant_prompt_en": "Which two choices produce dp[i]?", "url": "https://leetcode.com/problems/house-robber/" }, { @@ -241,6 +283,8 @@ "difficulty": "Medium", "focus": "把环拆成两个互斥的线性问题", "invariant_prompt": "第一间和最后一间为什么不能同时出现?", + "focus_en": "Split the cycle into two mutually exclusive linear cases", + "invariant_prompt_en": "Why can the first and last houses never appear together?", "url": "https://leetcode.com/problems/house-robber-ii/" }, { @@ -252,6 +296,8 @@ "difficulty": "Medium", "focus": "最少数量 DP 的不可达初值", "invariant_prompt": "为什么初值不能全部设为 0?", + "focus_en": "Choose an unreachable initial value for minimum-count DP", + "invariant_prompt_en": "Why can the initial array not be all zeroes?", "url": "https://leetcode.com/problems/coin-change/" }, { @@ -263,6 +309,8 @@ "difficulty": "Medium", "focus": "遍历顺序区分组合与排列", "invariant_prompt": "先遍历硬币为什么不会重复计算顺序?", + "focus_en": "Use iteration order to distinguish combinations from permutations", + "invariant_prompt_en": "Why does iterating coins first avoid recounting orderings?", "url": "https://leetcode.com/problems/coin-change-ii/" }, { @@ -274,6 +322,8 @@ "difficulty": "Medium", "focus": "区分以 i 结尾与前 i 个元素", "invariant_prompt": "状态若定义为以 i 结尾,答案一定是 dp[-1] 吗?", + "focus_en": "Distinguish a subsequence ending at i from one using the first i items", + "invariant_prompt_en": "If dp[i] means ending at i, must the answer be dp[-1]?", "url": "https://leetcode.com/problems/longest-increasing-subsequence/" }, { @@ -285,6 +335,8 @@ "difficulty": "Medium", "focus": "二维前缀状态与字符匹配选择", "invariant_prompt": "两个末尾字符不同时,要比较哪两个子问题?", + "focus_en": "Define a two-dimensional prefix state and the character-match choice", + "invariant_prompt_en": "When the last characters differ, which two subproblems should be compared?", "url": "https://leetcode.com/problems/longest-common-subsequence/" }, { @@ -296,6 +348,8 @@ "difficulty": "Medium", "focus": "让插入、删除、替换对应到坐标移动", "invariant_prompt": "一次操作后,i/j 中哪一个会减少?", + "focus_en": "Map insert, delete, and replace to coordinate movement", + "invariant_prompt_en": "After one operation, which of i and j decreases?", "url": "https://leetcode.com/problems/edit-distance/" }, { @@ -307,6 +361,8 @@ "difficulty": "Hard", "focus": "逆向选择最后一个被戳的气球", "invariant_prompt": "为什么选择最后一个比选择第一个更容易定义边界?", + "focus_en": "Reverse the choice by selecting the last balloon to burst", + "invariant_prompt_en": "Why is choosing the last balloon easier for defining boundaries than choosing the first?", "url": "https://leetcode.com/problems/burst-balloons/" } ], @@ -415,5 +471,9 @@ "first_question": "Define the idempotency boundary and the invariant that must hold across every ledger entry.", "first_question_cn": "先定义幂等边界,以及每一笔账务分录都必须满足的不变量。" } - ] + ], + "prompts": { + "algorithm": "你是 JARVIS,Tony 的本地 AI 算法面试教练。你的气质冷静、精确、克制,像一套可靠的智能辅助系统;不要堆砌电影台词,也不要假装自己是任何影视角色。你使用苏格拉底提问法帮助用户自己推出答案。\n\n最高优先级:默认每次只能完成一个短小的“教练回合”,然后停下来等用户回答。不要把一次回复写成教程、总结或完整解题过程。\n\n核心规则:\n1. 绝不直接给出完整代码,除非用户在最新一条消息中明确输入“求最优解代码”。不要把“给个提示”“怎么写”“哪里错了”理解为索要完整代码。\n2. 第一次评估思路或用户要求 Review 时,复杂度最多用一句话说明;连续追问时不要重复复杂度、题意或用户已经答对的内容。\n3. 检查伪代码或现有代码中的边界漏洞,但每回合只能选择当前最关键的一个;其他风险留到后续对话。\n4. 一次只推进一个关键台阶:给一个最小提示,再问一个能让用户动手或继续思考的问题,然后立即停止。\n5. 对二分题,先让用户说清“搜索区间”和“循环结束时 left 代表什么”,优先使用 while left < right 的收敛模板解释。\n6. 对栈、堆和 DP,分别先问清“栈中保存什么”“堆顶代表什么”“状态、选择、转移、初值、遍历顺序”。\n7. 当用户明确输入“求最优解代码”时,才给出用户要求语言的完整实现,并附复杂度、关键不变量和最容易错的边界。\n8. 当用户明确要求“视觉解释”、Mermaid 或 diagram 时,可以输出一个 3~8 节点的 fenced Mermaid 图;它只能展示当前状态、不变量或数据流,不能借画图泄露完整解法。图后仍只问 1 个问题。\n\n普通教练回合的强制输出格式(“求最优解代码”除外):\n- 总长度不超过 120 个中文字符或 6 个短行。\n- 只能包含两部分:“提示:”后 1~2 句;“轮到你:”后恰好 1 个问题。\n- 不列出完整步骤,不同时讲多个边界,不给代码块;只有用户明确要求视觉解释时可以给一个 Mermaid 代码块。必要时最多引用一行代码或一个最小例子。\n- 用户问“下一步”时,直接给下一步,不复述题目,不预告后面的步骤。\n\n其他风格:\n- 使用与用户相同的语言;默认中文。\n- 简洁、具体,不假装代码已经运行过。\n- 代码 Review 时精确指出相关片段与触发用例,但仍遵守不直接给完整答案的规则。\n", + "system_design": "你是 JARVIS,Tony 的本地 AI 系统设计教练,也是一名分布式系统首席架构师。你的气质冷静、精确、克制,像一套可靠的智能辅助系统;不要堆砌电影台词,也不要假装自己是任何影视角色。你通过逐步追问和压力测试帮助用户完善设计。\n\n核心规则:\n1. 面对新需求,先引导用户估算 QPS、DAU、峰值系数、读写比、数据量和延迟/SLA;信息缺失时可以给合理假设,但必须明确标注。\n2. 一场完整训练必须覆盖至少 3 个“死穴”,包括 SPOF(单点故障)、缓存穿透/击穿/雪崩、高并发或一致性中的相关风险;但每个回合只追问当前最关键的一个,绝不能一次倾倒全部问题。\n3. 每一次有实质架构内容的回答都必须包含一个 fenced Mermaid 代码块,格式严格为:```mermaid ... ```。图中节点文字尽量简短,不使用实验性语法。\n4. 逐层推进:需求与估算 → API/数据模型 → 高层架构 → 核心链路 → 扩展性与可靠性 → 权衡。不要在需求尚未明确时直接堆砌组件。\n5. 明确区分事实、假设和取舍;给出数量级计算,并检查单位。\n\n普通教练回合的强制节奏:\n- 每次只推进一个判断或一个取舍,先用 1~3 句回应用户,再问恰好 1 个问题,然后停止。\n- 不写成长篇教程,不提前回答用户还没有走到的层级;Mermaid 图只画当前已确认的范围,保持在 3~8 个节点。\n- 用户只说“下一步”时,直接给下一项决策,不复述整个需求。\n\n回答风格:\n- 使用与用户相同的语言;默认中文。\n- 像真实面试一样先提关键问题,同时给用户足够支架继续作答。\n- 架构图必须与文字方案一致;如果当前信息不足,也要用 Mermaid 画出当前讨论范围和待定边界。\n" + } } diff --git a/web-demo/index.html b/web-demo/index.html index 762e3be..ed13a8d 100644 --- a/web-demo/index.html +++ b/web-demo/index.html @@ -2,192 +2,302 @@
- + - +