fix(local-model): say what is true of the Mac, and why a model is missing

Review fixes for the read-only recommendation and its card.

Recommendation (pure):
- `limited` is now a statement about the Mac: no model with 12 billion
  parameters or more fits. It is judged on everything that fits, before
  the disk filter and whatever the pick is. A 32 or 36 GB Mac whose pick
  is a 9B model no longer reads "This Mac can run a small model" above a
  list of 20B, 27B and 35B models, and knowing the chip no longer lowers
  the verdict. A small pick under `ok` carries a note that says so.
- A model that fits the memory but has no room on the disk is no longer
  dropped in silence: the best-ranked one is named in a note with the GB
  that are missing. A full disk cannot turn a 48 GB Mac into a
  small-model Mac.
- "Models fit, but not at a context a harness needs" has its own
  sentence with the budget and the need, worded as an estimate. The
  16 GB Mac mini misses by 1.3 points (10.3 of 10.4 GB) and was told
  flatly that its memory is too little, the sentence of an 8 GB Mac.
- The installed memory is named by the size the Mac is sold as (48, not
  51.5): the hardware line and the memory sentence. Every computed size
  stays decimal GB from the one formatter. An unknown memory size is no
  longer reported as "0.0 GB".
- Slow is graded on the unrounded speed: 9.95 to 9.9996 tokens per
  second printed as 10 and read ok.
- The disk sentence names the 10 GiB that must stay free, so its
  numbers add up for the reader.

Card and Settings group:
- The Settings group "Model on this Mac" is hidden on a server that is
  not a Mac, and until the first answer says it is one.
- Only `403 FORBIDDEN` is a refusal. `403 PASSWORD_CHANGE_REQUIRED`
  (an admin who must change the password) is a failed request and is
  asked again, so that admin no longer loses the card until a reload.
- Each fit and speed label carries what it measures as its tooltip and
  accessible name, an alternative with a speed estimate prints it, and
  the count beside "Other models that fit" is gone (it was the number
  of rows, capped at four, not the number of models that fit).
- The line saying nothing can be set up yet sits right under the pick,
  in the colour of every other line (it was 3.9:1), and says whose step
  it is.
- The dismissal key is named for this stage, so hiding the read-only
  card does not hide the install offer later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 17:37:53 +01:00
1 parent 4739542397
commit 8281045cf2
10 files changed
+857 -211

No files matched your search

+21 -7
View File
@@ -1412,10 +1412,11 @@
'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 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。',
'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.':
'在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。',
'Checking this Mac…': '正在检查此 Mac…',
'{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',
@@ -1431,9 +1432,14 @@
'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} 时,第一次回答可能需要几分钟。',
'Other models that fit': '其他适合的模型',
'Setting this up from Codeman is the next step. It is not built yet.':
'下一步是直接在 {name} 中完成安装和设置,该功能尚未实现。',
'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 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 的选择',
@@ -1441,8 +1447,12 @@
'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.':
'此 Mac 可以运行小型模型。它能处理小幅修改和问答,但不能替代云端模型。',
'Not yet tested by us on a Mac like this.': '我们尚未在类似的 Mac 上测试过。',
'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.':
'最适合的是一个小型模型。它能处理小幅修改和问答,但不能替代云端模型。',
'{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.':
'{model} 也能装入此 Mac 的内存,但下载它还需要再腾出约 {missing} GB 的可用磁盘空间。',
Good: '良好',
Tight: '紧张',
Tight: '偏紧',
Slow: '较慢',
'Speed unknown': '速度未知',
'Codeman recommends a local model only on a Mac with Apple silicon.':
@@ -1451,8 +1461,12 @@
'此 Mac 使用 Intel 处理器。本地编程模型需要 Apple 芯片。',
'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.':
'此 Mac 有 {memory} GB 内存,不足以运行能够驱动编程智能体的模型,因此 {name} 在此不推荐任何模型。',
'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.':
'可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,而当前可用空间为 {free} GB。请腾出约 {missing} GB。',
'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.':
'据 {name} 估算,此 Mac 可分配给模型的内存约为 {budget} GB。编程智能体能够使用的最小模型约需 {need} GB。这样没有留出安全余量,因此 {name} 在此不推荐任何模型。',
'Codeman could not read how much memory this Mac has, so it recommends no model here.':
'{name} 无法读取此 Mac 的内存大小,因此在此不推荐任何模型。',
'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.':
'可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,下载后还必须保留 {reserve} GB 的可用空间。当前可用空间为 {free} GB。请腾出约 {missing} GB。',
'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.':
'此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。',
'No coding agent that Codeman can connect to a local model is available on this Mac.':
+5 -3
View File
@@ -2879,9 +2879,11 @@
<!-- What the Mac the server runs on can run as a local model (local-model-ui.js).
Read-only: the module fills the row from GET /api/local-model, the same body
the welcome card shows, through its own translator call (hence data-i18n-skip
on the row's text). The route is admin-only in multi-user mode, so the module
hides the whole group for a non-admin. Not gated on the endpoints toggle
above: the answer does not depend on it. -->
on the row's text). The module shows the group only once the server has
answered for a Mac: it hides it for a server on another system (the title
and hint are about "this Mac"), while the first request is in flight, and
for a non-admin in multi-user mode (the route is admin-only). Not gated on
the endpoints toggle above: the answer does not depend on it. -->
<div class="set-group" id="localModelGroup">
<div class="set-group-head"><h4>Model on this Mac</h4></div>
<p class="set-group-hint">What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.</p>
+118 -51
View File
@@ -4,11 +4,13 @@
* Reads `GET /api/local-model` (hardware of the machine the server runs on, the recommendation,
* the catalogue date) and shows, read-only:
* - the hardware line (chip, memory, free disk);
* - the verdict: the headline and notes the server sent, then the pick with its numbers
* (model, harness, context, memory need against the budget, download size, speed);
* - the other models that fit, each with its fit and speed label;
* - one line saying that setting the model up from Codeman is the next step and is not built
* yet.
* - the verdict: the headline the server sent, then the pick with its numbers (model, harness,
* context, memory need against the budget, download size, speed) and the notes;
* - one line saying that Codeman cannot download or install the model yet: that is the next
* step and is not built. It sits right under the pick, where a reader looks for a button;
* - the other models that fit, each with its fit and speed label. A label says what it
* measures in its tooltip and accessible name ("Good" is the memory fit, not a rating of
* the model), and an alternative with a speed estimate prints the figure.
*
* ## One body, two containers
*
@@ -23,9 +25,18 @@
* hidden while the first request is in flight and after a failed one (nothing is known about the
* machine then, and an error card would be the only thing this feature ever showed on a server
* that is not a Mac), when the answer is `not-macos`, when the request is refused (a non-admin in
* multi-user mode gets a 403, which is an answer: stop asking) and after the user hid it on this
* device. The Settings row is where the loading and failure sentences show, and it is the way
* back to the numbers after a Hide.
* multi-user mode gets a `403 FORBIDDEN`, which is an answer: stop asking) and after the user hid
* it on this device.
*
* The Settings group ("Model on this Mac") follows the same knowledge: it is hidden for
* `not-macos` (most servers are not a Mac, and a group about "this Mac" there is false), for a
* refused request and while the first request is in flight (nothing says yet that the server is
* a Mac). It shows the answer for a Mac, and one failure sentence when the first request
* failed. It is the way back to the numbers after a Hide.
*
* "Refused" means the envelope's `errorCode` is `FORBIDDEN`. The auth layer has a second 403 for
* every /api route (`PASSWORD_CHANGE_REQUIRED`, an admin who must change the password first):
* that one is a failed request, asked again on the next show and on `codeman:me`.
*
* Rules:
* - Read-only: no control here downloads, installs or writes anything but the per-device
@@ -36,8 +47,10 @@
* (`<html lang>` is written by every i18n `configure()`).
* - Names and labels come from the payload: this file holds no list of models or harnesses and
* never compares a CLI id with a literal.
* - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `memGb`,
* `freeDiskGb`: decimal GB as text). Bytes are never divided here.
* - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `freeDiskGb`:
* decimal GB as text). Bytes are never divided here. The installed memory is the one size
* that is not decimal GB: the hardware line prints `memClassGb`, the size the Mac is sold as
* (48, not 51.5), which is what About This Mac shows.
* - A sentence the server chose arrives as `{ text, vars }` and goes through
* `localModelT(text, vars)`; this file's own strings are literals in that same call, in
* `{placeholder}` form, so the i18n test can harvest them. Both kinds need a zh-CN entry in
@@ -52,8 +65,12 @@
* @loadorder 12.59 of 16, after edit-history-ui.js, before entrance-animations.js
*/
/** Per-device dismissal of the welcome card. */
const LOCAL_MODEL_DISMISS_KEY = 'codeman:local-model-dismissed';
/**
* Per-device dismissal of the welcome card, named for THIS stage (the read-only card). The stage
* that can set a model up takes a key of its own, so hiding a card that offers nothing yet does
* not hide the offer later.
*/
const LOCAL_MODEL_DISMISS_KEY = 'codeman:local-model-dismissed:readonly';
/** The server caches its hardware probe for a minute, so asking again sooner cannot change the answer. */
const LOCAL_MODEL_MAX_AGE_MS = 60000;
@@ -100,18 +117,41 @@ function localModelNotAdmin() {
return !!(me.multiUser && me.role !== 'admin');
}
/** Chip, memory and free disk in one line. Every part but the memory may be missing. */
/**
* Chip, memory and free disk in one line. The memory is the size the Mac is sold as
* (`memClassGb`: 48 for a Mac whose 48 x 2^30 bytes are 51.5 decimal GB), a number or nothing:
* an unknown size (0) is left out, never printed.
*/
function localModelHardwareLine(hw) {
const chip = String(hw.chip || hw.model || '');
const sizes = { chip, memory: hw.memGb, free: hw.freeDiskGb };
if (!hw.memGb) return chip;
const memory = Number.isFinite(hw.memClassGb) && hw.memClassGb > 0 ? String(hw.memClassGb) : '';
const sizes = { chip, memory, free: hw.freeDiskGb };
if (!memory) return chip;
if (chip && hw.freeDiskGb) return localModelT('{chip}, {memory} GB memory, {free} GB free', sizes);
if (chip) return localModelT('{chip}, {memory} GB memory', sizes);
if (hw.freeDiskGb) return localModelT('{memory} GB memory, {free} GB free', sizes);
return localModelT('{memory} GB memory', sizes);
}
/** "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose. */
/** What the fit label measures: the share of the memory budget the model needs. */
function localModelFitHint(candidate) {
if (!Number.isFinite(candidate.budgetShare)) return '';
const share = String(Math.round(candidate.budgetShare * 100));
return localModelT('Memory fit: needs {share}% of what this Mac can give a model', { share });
}
/** What the speed label stands for: the estimate behind "Slow", or that there is none. */
function localModelSpeedHint(candidate) {
const speed = localModelSpeed(candidate.tokensPerSecond);
if (speed) return localModelT('About {speed} tokens per second (estimated).', { speed });
return localModelT('No speed estimate for this model on this chip');
}
/**
* "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose.
* A bare "Good" next to a model name reads as a rating of the model, so each label carries what
* it measures as its tooltip and its accessible name.
*/
function localModelCandidateHead(candidate) {
const head = localModelEl('div', 'local-model-name-row');
const name = localModelT('{model} ({id}) with {harness}', {
@@ -120,19 +160,36 @@ function localModelCandidateHead(candidate) {
harness: candidate.cliLabel || candidate.cli,
});
head.appendChild(localModelEl('span', 'local-model-name', name));
for (const label of [candidate.fitLabel, candidate.speedLabel]) {
const pills = [
[candidate.fitLabel, localModelFitHint],
[candidate.speedLabel, localModelSpeedHint],
];
for (const [label, hint] of pills) {
if (typeof label !== 'string' || !label) continue;
head.appendChild(localModelEl('span', 'local-model-pill', localModelT(label)));
const pill = localModelEl('span', 'local-model-pill', localModelT(label));
const meaning = hint(candidate);
if (meaning) {
pill.title = meaning;
pill.setAttribute('role', 'note');
pill.setAttribute('aria-label', `${pill.textContent}. ${meaning}`);
}
head.appendChild(pill);
}
return head;
}
/** The dim line under a candidate's name: context, memory need (alternatives), download, license (the pick). */
function localModelMeta(candidate, { need = false, license = false } = {}) {
/**
* The dim line under a candidate's name: context, then for an alternative its memory need and,
* when there is an estimate, its speed (so "Slow" comes with a number on a screen without
* hover), the download, and for the pick the license.
*/
function localModelMeta(candidate, { alternative = false } = {}) {
const bits = [localModelT('Context {context} tokens', { context: localModelCount(candidate.context) })];
if (need && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb }));
if (alternative && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb }));
if (candidate.downloadGb) bits.push(localModelT('{download} GB download', { download: candidate.downloadGb }));
if (license && candidate.license) bits.push(String(candidate.license));
const speed = alternative ? localModelSpeed(candidate.tokensPerSecond) : '';
if (speed) bits.push(localModelT('about {speed} tokens per second', { speed }));
if (!alternative && candidate.license) bits.push(String(candidate.license));
return bits.join(' · ');
}
@@ -140,7 +197,7 @@ function localModelMeta(candidate, { need = false, license = false } = {}) {
function localModelPick(pick, recommendation) {
const box = localModelEl('div', 'local-model-pick');
box.appendChild(localModelCandidateHead(pick));
box.appendChild(localModelEl('div', 'local-model-line', localModelMeta(pick, { license: true })));
box.appendChild(localModelEl('div', 'local-model-line', localModelMeta(pick)));
const sizes = { need: pick.needGb, budget: recommendation.budgetGb };
// A wired limit is a setting of this Mac; anything else is the formula's estimate, and says so.
const need =
@@ -165,17 +222,19 @@ function localModelPick(pick, recommendation) {
return box;
}
/** The other models that fit. Folded behind a native disclosure on the welcome card, open in Settings. */
/**
* The other models that fit. Folded behind a native disclosure on the welcome card, open in
* Settings. No count beside the title: the server lists four at most, so the number of rows is
* not the number of models that fit.
*/
function localModelAlternatives(alternatives, fold) {
const wrap = localModelEl(fold ? 'details' : 'div', 'local-model-alts');
const title = localModelEl(fold ? 'summary' : 'div', 'local-model-alts-title');
title.appendChild(localModelEl('span', '', localModelT('Other models that fit')));
title.appendChild(localModelEl('span', 'local-model-count', String(alternatives.length)));
const title = localModelEl(fold ? 'summary' : 'div', 'local-model-alts-title', localModelT('Other models that fit'));
wrap.appendChild(title);
for (const candidate of alternatives) {
const row = localModelEl('div', 'local-model-alt');
row.appendChild(localModelCandidateHead(candidate));
row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { need: true })));
row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { alternative: true })));
wrap.appendChild(row);
}
return wrap;
@@ -200,12 +259,16 @@ function localModelBody(status, { fold }) {
const text = localModelMessage(note);
if (text) body.appendChild(localModelEl('div', 'local-model-note', text));
}
const alternatives = pick && Array.isArray(recommendation.alternatives) ? recommendation.alternatives : [];
if (alternatives.length) body.appendChild(localModelAlternatives(alternatives, fold));
// Right under the pick and its notes, above the list: the pick names a download and maybe a
// harness that is not installed, so this is where a reader looks for a button.
if (pick) {
const next = localModelT('Setting this up from Codeman is the next step. It is not built yet.');
const next = localModelT(
'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'
);
body.appendChild(localModelEl('div', 'local-model-next', next));
}
const alternatives = pick && Array.isArray(recommendation.alternatives) ? recommendation.alternatives : [];
if (alternatives.length) body.appendChild(localModelAlternatives(alternatives, fold));
return body;
}
@@ -249,24 +312,27 @@ Object.assign(CodemanApp.prototype, {
},
async _fetchLocalModel() {
// `_api`, not `_apiJson`: a 403 (admin-only in multi-user mode) is an answer and must be told
// apart from a request that failed.
// `_api`, not `_apiJson`: a refusal (admin-only in multi-user mode) is an answer and must be
// told apart from a request that failed.
const res = await this._api(localModelUrl('/api/local-model'));
if (res?.status === 403) {
this._localModelState = 'refused';
this._localModelStatus = null;
this.renderLocalModel();
return;
}
let body = null;
if (res?.ok) {
if (res?.ok || res?.status === 403) {
try {
body = await res.json();
} catch {
body = null;
}
}
const data = localModelPayload(body);
// Only the route's own refusal is final. The auth layer answers 403 too, with
// PASSWORD_CHANGE_REQUIRED, for an admin who must change the password first: that is a
// failed request, asked again once the password is changed.
if (res?.status === 403 && body?.errorCode === 'FORBIDDEN') {
this._localModelState = 'refused';
this._localModelStatus = null;
this.renderLocalModel();
return;
}
const data = res?.ok ? localModelPayload(body) : null;
if (!data) {
// A failed refresh must not blank an answer the user is reading: keep the last good one.
if (!this._localModelStatus) this._localModelState = 'failed';
@@ -336,6 +402,8 @@ Object.assign(CodemanApp.prototype, {
const head = localModelEl('div', 'local-model-head');
const title = localModelEl('h3', 'local-model-title', localModelT('Model on this Mac'));
title.id = 'localModelCardTitle';
// The page can be open on another laptop or a tablet: "this Mac" is the server's.
title.title = localModelT('The Mac that Codeman runs on, which is not always the device you are reading on.');
head.appendChild(title);
const hide = localModelEl('button', 'local-model-hide', localModelT('Hide'));
hide.type = 'button';
@@ -353,21 +421,20 @@ Object.assign(CodemanApp.prototype, {
_renderLocalModelSettings(row) {
row.textContent = '';
const refused = this._localModelRefused();
// The whole group goes for a non-admin, the way the other admin-only groups of this modal do.
const group = document.getElementById('localModelGroup');
if (group) group.style.display = refused ? 'none' : '';
if (refused) return;
const status = this._localModelStatus;
const failed = !status && this._localModelState === 'failed';
// The whole group goes for a non-admin, the way the other admin-only groups of this modal
// do, and for a server that is not a Mac: its title and hint are about "this Mac". Until
// the first answer nothing says the server is one, so the group waits for it.
const shown = !this._localModelRefused() && (failed || (!!status && status.recommendation.reason !== 'not-macos'));
const group = document.getElementById('localModelGroup');
if (group) group.style.display = shown ? '' : 'none';
if (!shown) return;
if (status) {
row.appendChild(localModelBody(status, { fold: false }));
return;
}
const text =
this._localModelState === 'failed'
? localModelT('Could not reach the server.')
: localModelT('Checking this Mac…');
row.appendChild(localModelEl('div', 'local-model-headline', text));
row.appendChild(localModelEl('div', 'local-model-headline', localModelT('Could not reach the server.')));
},
/** Listeners that outlive a render, wired once. */
+8 -15
View File
@@ -22376,9 +22376,13 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
min-width: 0;
}
/* The line that says nothing can be set up from here yet (.local-model-next) is
the one a reader must not miss, so it reads like every other line: same size,
same --text-dim. --text-muted on this surface was 3.9:1. */
.local-model-hw,
.local-model-line,
.local-model-note {
.local-model-note,
.local-model-next {
font-size: 0.68rem;
line-height: 1.45;
color: var(--text-dim);
@@ -22418,8 +22422,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
min-width: 0;
}
.local-model-pill,
.local-model-count {
/* Each pill carries what it measures as its tooltip (a title), hence the cursor. */
.local-model-pill {
flex: 0 0 auto;
font-size: 0.62rem;
line-height: 1.5;
@@ -22428,12 +22432,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
border-radius: 999px;
padding: 0 0.42rem;
white-space: nowrap;
}
.local-model-count {
margin-inline-start: 0.4rem;
border-color: transparent;
background: var(--control-bg-hover);
cursor: help;
}
.local-model-alts-title {
@@ -22466,12 +22465,6 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
font-weight: 500;
}
.local-model-next {
font-size: 0.66rem;
line-height: 1.45;
color: var(--text-muted);
}
/* In App Settings the body sits inside an ordinary .set-row, so it takes that
surface's type scale (label 0.76rem over a muted description) and the row
supplies the border. */