From 84d5c691ff852fd118668325664a4f862d49560e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 22:16:38 +0100 Subject: [PATCH] feat(local-model): one button and a chooser window replace the welcome card The read-only card is gone. The welcome screen has one button, "Run a local model" ("Local model" once one is installed), and App Settings > Models has one row; both open the same window. The window follows `install.state`: choose a model (a radio group, with a memory bar filled to what the selected model needs), confirm the sizes and the folder, watch the download and the steps, and end on "Start a session". Paused, failed, installed (running or stopped), Remove and "no model fits" are screens of the same window. It polls the GET once a second while open. Every dynamic string goes in through textContent, labels and sentences come from the payload, no CLI id is compared, and every new string has a zh-CN entry. Co-Authored-By: Claude Fable 5.1 --- config/test-suites.ts | 1 + src/web/CLAUDE.md | 2 +- src/web/public/app.js | 6 + src/web/public/i18n.js | 152 +- src/web/public/index.html | 57 +- src/web/public/local-model-ui.js | 1559 +++++++++++++--- src/web/public/mobile.css | 109 +- src/web/public/settings-ui.js | 5 +- src/web/public/styles.css | 769 ++++++-- src/web/public/terminal-ui.js | 5 +- test/local-model-i18n.test.ts | 119 +- test/local-model-ui.browser.test.ts | 349 ++++ test/local-model-ui.test.ts | 2674 +++++++++++++++++++-------- 13 files changed, 4543 insertions(+), 1264 deletions(-) create mode 100644 test/local-model-ui.browser.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index 49812b3a..ebbc8337 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -60,6 +60,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/fullscreen-tab-switch-capture.browser.test.ts', 'test/terminal-touch-swipe-axis.browser.test.ts', 'test/phone-press-feedback.browser.test.ts', + 'test/local-model-ui.browser.test.ts', ]; /** diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 2f29a211..eb55b626 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -48,7 +48,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module). -**Model on this Mac** (`local-model-ui.js`; welcome card `#localModelCard` + App Settings > Models group `#localModelGroup`; server rules in the root CLAUDE.md, Local model recommendation): a READ-ONLY view of `GET /api/local-model`. ONE builder (`localModelBody`) paints both containers, so the card and the Settings row can never name a different pick; the card folds the alternatives, Settings lists them. ⚠️ Every dynamic string goes in through `textContent` (model, harness and chip names are someone else's text), both containers carry `data-i18n-skip`, and the module translates its own sentences at render time, so a language or display-name change must repaint from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or harnesses, never compares a CLI id, and never contains the word "Recommended" (that headline is the server's to send). ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, and the only non-literal calls are the two payload ones. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never divided here; the installed memory alone is `memClassGb`, a number or nothing (an unknown size is left out, never `0 GB`). ⚠️ The card ships `hidden` and shows only once the server answered for a Mac; the card AND the Settings group stay hidden for `not-macos` (the server is usually Linux, and "this Mac" always means the machine the server runs on), for a refusal and while the first request is in flight; a failed first request shows one sentence in Settings only. ⚠️ Refused means `403` with `errorCode: FORBIDDEN` (a non-admin in multi-user mode: stop asking); the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request that is asked again on `codeman:me`. ⚠️ Each fit and speed pill carries what it measures as `title` + `aria-label` ("Good" is the memory fit, not a rating), and the line saying Codeman cannot download or install yet sits right under the pick and its notes, in `--text-dim` like every other line. The dismissal is a per-device localStorage key named for this stage (`codeman:local-model-dismissed:readonly`), never a server setting: the stage that can set a model up takes a new key so its offer shows once more. At most one request a minute (the server caches its probe for 60 s), shared while in flight, and a failed refresh keeps the answer on screen. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`. +**Run a model on this Mac** (`local-model-ui.js`; welcome button `#welcomeLocalModelBtn`, chooser window `#localModelModal`, App Settings > Models row `#localModelSettings`; server rules in the root CLAUDE.md, Local model): ONE window does everything (choose, confirm, progress, paused, failed, installed, no recommendation); the button and the Settings row only open it. Which screen shows follows `install.state` of `GET /api/local-model`; the page itself holds only the selection and whether the person is on the chooser or the confirm screen. ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, a flat `--accent` fill on a `--control-border-hover` track (the inputs' `--bg-input` is the window's own surface on the dark skins), 10px, no gradient or glow, and its width transition is the only motion (off under `prefers-reduced-motion`); a repaint starts each bar at the last width of its kind, so keep `_settleLocalModelBars()` after every body write. ⚠️ The window polls the GET once a second WHILE OPEN and never otherwise (no SSE event): `closeLocalModelWindow()` clears the timer, a background tab asks nothing, an unchanged answer repaints nothing, and a tick that moved only the download replaces `.lm-meter` alone, so a button is never swapped out under a click. A read that was already under way when a verb answered is dropped (`_localModelVerbs`), or the old state flashes back. ⚠️ The consent is bound to the size shown: the install request carries the row's `installBytes` as `acknowledgedBytes`, and a 409 `size-changed` shows `expectedGb` and sends `expectedBytes` next; never compute a total here. ⚠️ A button exists only for an action in `install.actions`, and after an error exactly one helpful action (`error.next`) plus a quiet Remove. ⚠️ Every dynamic string goes in through `textContent` (model, coding agent and chip names, an error's detail, session names, the folder and the install command are someone else's text); the window body, the button and the row carry `data-i18n-skip` and the module translates its own sentences at render time, so a language or display-name change repaints from the module (the `` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or coding agents, compares no CLI id (the agent select's values are positions in the server's list, the id travels only as data into the install request and `selectCustomModelEntry`) and never contains the word "Recommended". ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, the only non-literal calls are the two payload ones, and a unit alone (`23.1 GB`) is not a translator call. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never turned into a size; the installed memory alone is `memClassGb`. ⚠️ The model list is a real radio group (`role="radiogroup"`, roving `tabindex`, arrows, Home, End, the focus follows through the repaint's own focus restore, keyed by `data-lm-key`); the columns line up through `subgrid` with a fixed-track `@supports` fallback, and under 560px a row is two lines (mobile.css). ⚠️ The window has its OWN `FocusTrap` (`_localModelFocusTrap`, never `activeFocusTrap`: it opens on top of App Settings) and app.js claims its Escape before `closeAllPanels()`. ⚠️ Its buttons are the Git worktrees dialog's `.wt-btn` set, never `.btn-toolbar`: the skin block greys `.btn-toolbar.btn-primary` and the phone block pins every `.btn-toolbar` to 26px with `!important`. ⚠️ "Start a session" re-reads the settings and the CLI list first (`loadAppSettingsFromServer`, `loadCliListForSettings`: the server switched the endpoints flag on and may have installed the agent a moment ago), then calls the existing `selectCustomModelEntry(install.cli, install.endpointId)`. The button ships `hidden` and shows only once the server answered for a Mac ("Local model" once one is installed); button AND Settings group stay hidden for `not-macos`, for a refusal and while the first request is in flight; refused means `403` with `errorCode: FORBIDDEN` (the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request, asked again on `codeman:me`). Remove asks first and deletes the downloaded model unless the box is cleared (nothing else in the UI can free that space later). Not on the phone overview. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`; the visual walk is a scratch Playwright script, not a test. **Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order) diff --git a/src/web/public/app.js b/src/web/public/app.js index 69b46af8..3257792e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1468,6 +1468,12 @@ class CodemanApp { this.handleTabRailSearchKeydown(e); return; } + // The local model window can sit on top of App Settings (it opens from a + // row there): Escape closes the window alone and leaves Settings open. + if (this.isLocalModelWindowOpen?.()) { + this.closeLocalModelWindow(); + return; + } this.closeAllPanels(); this.closeHelp(); if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory(); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 87c18651..47f33f7d 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1407,39 +1407,96 @@ '该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。', 'Git status is not available for a tmux session opened outside Codeman.': '在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。', - // Model on this Mac (local-model-ui.js: the welcome card and its App Settings group). The first - // block is the module's own text; sizes and names fill the {placeholder} slots. + // Run a model on this Mac (local-model-ui.js: the welcome button, the chooser window and its + // App Settings group). The first block is the page's and the module's own text; sizes and + // names fill the {placeholder} slots. 'Model on this Mac': '此 Mac 上的模型', - 'What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.': - '运行 {name} 的这台 Mac 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。', + 'Run a coding model on the Mac that Codeman runs on. No account and no API key.': + '在运行 {name} 的这台 Mac 上运行编程模型。无需账号,也无需 API 密钥。', + 'Run a model on this Mac': '在此 Mac 上运行模型', + 'Run a local model': '运行本地模型', + 'Local model': '本地模型', 'The Mac that Codeman runs on, which is not always the device you are reading on.': '指运行 {name} 的那台 Mac,不一定是你正在使用的这台设备。', - Hide: '隐藏', - 'Hide this card on this device. It stays in App Settings, under Models.': - '在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。', '{chip}, {memory} GB memory, {free} GB free': '{chip},内存 {memory} GB,可用磁盘空间 {free} GB', '{chip}, {memory} GB memory': '{chip},内存 {memory} GB', '{memory} GB memory, {free} GB free': '内存 {memory} GB,可用磁盘空间 {free} GB', '{memory} GB memory': '内存 {memory} GB', - '{model} ({id}) with {harness}': '{model}({id}),搭配 {harness}', - 'Context {context} tokens': '上下文 {context} 个 Token', - '{need} GB of memory': '占用内存 {need} GB', - '{download} GB download': '下载 {download} GB', - 'Needs about {need} GB of the {budget} GB this Mac can give a model.': - '大约需要 {need} GB,此 Mac 可分配给模型的内存为 {budget} GB。', - 'Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).': - '大约需要 {need} GB,此 Mac 可分配给模型的内存约为 {budget} GB(估算)。', - 'About {speed} tokens per second (estimated).': '每秒约 {speed} 个 Token(估算)。', - '{harness} is not installed on this Mac yet.': '此 Mac 上尚未安装 {harness}。', - 'With {harness}, the first answer can take minutes.': '使用 {harness} 时,第一次回答可能需要几分钟。', - 'about {speed} tokens per second': '每秒约 {speed} 个 Token', - // What the fit and speed labels measure (their tooltip and accessible name). - 'Memory fit: needs {share}% of what this Mac can give a model': - '内存占用:需要此 Mac 可分配给模型的内存的 {share}%', - 'No speed estimate for this model on this chip': '此芯片上没有该模型的速度估算', - 'Other models that fit': '其他装得下的模型', - 'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.': - '{name} 目前还不能为你下载或安装它。这是下一步的功能,尚未实现。', + // The App Settings row: where the install stands, in a few words. + 'No model installed': '尚未安装模型', + 'Installing {model}': '正在安装 {model}', + 'The install of {model} is paused': '{model} 的安装已暂停', + 'The install of {model} stopped': '{model} 的安装已中止', + '{model} is running': '{model} 正在运行', + '{model} is installed and stopped': '{model} 已安装,当前已停止', + 'Checking this Mac': '正在检查此 Mac', + // The chooser: the memory bar, the list, the coding agent. + 'About {need} of {budget} GB of memory': '约需 {need} GB 内存,共 {budget} GB 可用', + estimated: '估算', + 'The selected model needs {share}% of the memory this Mac can give a model': + '所选模型需要此 Mac 可分配给模型的内存的 {share}%', + 'The model at {context} tokens of context': '模型在 {context} 个 Token 上下文下的占用', + 'What stays free': '剩余可用', + 'Models that fit this Mac': '此 Mac 装得下的模型', + Speed: '速度', + Download: '下载', + 'speed not known': '速度未知', + '{speed} tokens/s': '{speed} Token/秒', + 'about {speed} tokens/s': '约 {speed} Token/秒', + 'Runs with': '搭配', + 'Not on this Mac yet. It is installed for you.': '此 Mac 上尚未安装,将为你一并安装。', + 'The first answer can take minutes.': '第一次回答可能需要几分钟。', + 'No account and no API key. The model runs only on this Mac.': '无需账号,也无需 API 密钥。模型只在此 Mac 上运行。', + 'Install on this Mac': '安装到此 Mac', + // The confirm screen: what is downloaded, where it goes. + 'Runs with {cli}, at {context} tokens of context.': '搭配 {cli},上下文为 {context} 个 Token。', + 'What is downloaded': '将下载的内容', + 'Ollama {version}, the program that runs the model': 'Ollama {version},用于运行模型的程序', + Total: '合计', + '{cli} is not on this Mac yet. Its own installer is run with this command:': + '此 Mac 上尚未安装 {cli}。将用以下命令运行它自带的安装程序:', + '{cli} is not on this Mac yet. It is installed with its own installer.': + '此 Mac 上尚未安装 {cli}。将用它自带的安装程序进行安装。', + 'The model and the program that runs it go into:': '模型和运行它的程序将存放在:', + 'No administrator password.': '无需管理员密码。', + 'Download and install': '下载并安装', + // Progress, and the same picture paused. + '{done} of {total} GB': '已下载 {done} GB,共 {total} GB', + '{speed} MB per second, {left}': '每秒 {speed} MB,{left}', + '{speed} MB per second': '每秒 {speed} MB', + 'less than a minute left': '剩余不到 1 分钟', + 'about 1 minute left': '剩余约 1 分钟', + 'about {minutes} minutes left': '剩余约 {minutes} 分钟', + 'about 1 hour left': '剩余约 1 小时', + 'about {hours} hours left': '剩余约 {hours} 小时', + 'Getting the runtime': '获取运行时', + 'Downloading the model': '下载模型', + 'Loading and checking': '加载并检查', + 'Installing {cli}': '安装 {cli}', + Connecting: '正在连接', + 'You can close this window. The download continues while Codeman is running.': + '你可以关闭此窗口。只要 {name} 在运行,下载就会继续。', + 'Nothing is downloaded until you continue.': '在你继续之前不会下载任何内容。', + Continue: '继续', + 'Choose another model': '选择其他模型', + // After an error: the one action that helps. + 'Use another port': '改用其他端口', + 'Try {model}': '试试 {model}', + Start: '启动', + 'The server did not accept that.': '服务器未接受该请求。', + // Installed, and the Remove question. + '{model} is ready on this Mac.': '{model} 已在此 Mac 上准备就绪。', + '{model} is installed on this Mac.': '{model} 已安装在此 Mac 上。', + 'Runs with {cli}.': '搭配 {cli} 运行。', + 'Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.': + '上下文 {context} 个 Token。加载后占用 {memory} GB 内存,全部位于 GPU 上。', + 'Context {context} tokens. Takes {memory} GB of memory when loaded.': + '上下文 {context} 个 Token。加载后占用 {memory} GB 内存。', + 'Context {context} tokens.': '上下文 {context} 个 Token。', + 'Start a session': '开始会话', + 'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?', + 'Also delete the downloaded model': '同时删除已下载的模型', + 'The coding agent stays installed.': '编程智能体会保留,不会被卸载。', // The sentences and labels the SERVER picks (LOCAL_MODEL_LABELS in src/types/local-model.ts), keyed // by their exact English text: changing a text there means changing its key here. 'Best fit for this Mac': '最适合此 Mac 的选择', @@ -1471,6 +1528,49 @@ '此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。', 'No coding agent that Codeman can connect to a local model is available on this Mac.': '此 Mac 上没有 {name} 可以连接到本地模型的编程智能体。', + // The tag of a model row (LOCAL_MODEL_TAG_LABELS in src/types/local-model.ts). Tight and Slow are + // the entries above. + 'Tested on a Mac like this': '已在同类 Mac 上测试', + 'Best fit': '最适合', + 'Small model': '小型模型', + // The sentences of the install (LOCAL_MODEL_INSTALL_LABELS in src/types/local-model-install.ts): + // why a job stopped, why a request was refused, and the one sentence about the developer tools. + 'The download of the runtime stopped and could not be continued. Check the network connection, then try again.': + '运行时的下载已中断,且无法继续。请检查网络连接,然后重试。', + 'The runtime download was damaged and has been deleted. Try again.': '下载的运行时已损坏,已被删除。请重试。', + 'The runtime could not be unpacked on this Mac.': '无法在此 Mac 上解压运行时。', + 'The runtime did not start on this Mac.': '运行时未能在此 Mac 上启动。', + 'The runtime keeps stopping, so Codeman no longer restarts it.': '运行时反复停止,因此 {name} 不再重新启动它。', + 'The model on this Mac is not running. Start it first.': '此 Mac 上的模型没有在运行。请先启动它。', + 'Port {port} is in use by another program.': '端口 {port} 已被其他程序占用。', + 'The disk is full. Free up space, then try again.': '磁盘已满。请腾出空间,然后重试。', + 'The download keeps stopping. The network may be damaging data. Try again or use another network.': + '下载反复中断。网络可能正在损坏数据。请重试,或换一个网络。', + '{model} is no longer offered under this name. Update Codeman or pick another model.': + '{model} 已不再以此名称提供。请更新 {name},或选择其他模型。', + '{model} does not fit in the memory of this Mac after all.': '{model} 终究装不进此 Mac 的内存。', + '{model} loaded with a context of {actual} tokens instead of {wanted}.': + '{model} 加载时的上下文为 {actual} 个 Token,而不是 {wanted} 个。', + '{model} did not load on this Mac.': '{model} 未能在此 Mac 上加载。', + '{model} loaded but did not call a tool, so a coding agent cannot work with it.': + '{model} 已加载,但没有调用工具,因此编程智能体无法使用它。', + 'The check of {model} did not finish.': '对 {model} 的检查没有完成。', + '{cli} could not be installed.': '无法安装 {cli}。', + 'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.': + '已存在名为 {id} 的已保存模型连接,而它不是在这里创建的。请在“应用设置”中重命名或删除它,然后重试。', + 'Codeman could not save the connection to the model.': '{name} 无法保存与模型的连接。', + 'The model on this Mac changed since it was installed. Remove it and install it again.': + '此 Mac 上的模型在安装后发生了变化。请移除后重新安装。', + 'The install stopped on an unexpected error.': '安装因意外错误而中止。', + 'An install is already running.': '已有一个安装正在进行。', + 'A model is already installed on this Mac. Remove it first.': '此 Mac 上已安装了一个模型。请先移除它。', + 'The download is {size} GB now. Check the sizes and confirm again.': + '现在的下载大小为 {size} GB。请核对大小后再次确认。', + 'Sessions are still using this model: {sessions}. Close them first.': + '仍有会话在使用此模型:{sessions}。请先关闭它们。', + 'That is not possible in the current state.': '当前状态下无法执行此操作。', + 'Coding agents use git and Python. This Mac has no developer tools yet, so macOS will offer to install them the first time an agent needs one.': + '编程智能体会用到 git 和 Python。此 Mac 尚未安装开发者工具,因此在智能体首次需要时,macOS 会提示安装。', }); const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value])); diff --git a/src/web/public/index.html b/src/web/public/index.html index 8026b7ce..abd35e38 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -511,6 +511,17 @@ contents on every state change and owns its inline display. -->
+ +
- - @@ -2876,20 +2879,24 @@ - + for a non-admin in multi-user mode (the routes are admin-only). Not gated on + the endpoints toggle above: an install switches that on itself. -->

Model on this Mac

-

What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.

+

Run a coding model on the Mac that Codeman runs on. No account and no API key.

-
+
+
+ +
@@ -3671,6 +3678,24 @@
+ + +