diff --git a/src/local-model/recommend.ts b/src/local-model/recommend.ts index 752a32df..54a747f4 100644 --- a/src/local-model/recommend.ts +++ b/src/local-model/recommend.ts @@ -313,14 +313,16 @@ function buildCandidate( } /** - * The one tag of a row: the first kind that applies, in the order tested, best (the pick), - * tight, slow, small. `undefined` when none does. + * The one tag of a row: the first kind that applies, in the order tested, the pick (which + * says so when it only just fits), tight, slow, small. `undefined` when none does. */ function tagFor(candidate: Candidate, isPick: boolean): CandidateTag | undefined { const kind: CandidateTagKind | undefined = candidate.tested ? 'tested' : isPick - ? 'best' + ? candidate.fit === 'tight' + ? 'best-tight' + : 'best' : candidate.fit === 'tight' ? 'tight' : candidate.speed === 'slow' @@ -442,12 +444,8 @@ export function recommend( const hasRoom = (model: LocalModelEntry): boolean => free === undefined || free - model.bytes >= DISK_HEADROOM_BYTES; if (free !== undefined && !accepted.some((entry) => hasRoom(entry.model))) { const smallest = Math.min(...accepted.map((entry) => entry.model.bytes)); - return none('disk', budget, { - download: formatGb(smallest), - reserve: formatGb(DISK_HEADROOM_BYTES), - free: hw.freeDiskGb ?? formatGb(free), - missing: formatGb(smallest + DISK_HEADROOM_BYTES - free), - }); + // One number, the one that says what to do: what must be freed for the smallest model. + return none('disk', budget, { missing: formatGb(smallest + DISK_HEADROOM_BYTES - free) }); } // Everything that fits is ranked, the models without room on the disk included: the best of diff --git a/src/types/local-model.ts b/src/types/local-model.ts index 4e146941..5baa6903 100644 --- a/src/types/local-model.ts +++ b/src/types/local-model.ts @@ -351,7 +351,7 @@ export const LOCAL_MODEL_LABELS = { headlineLimited: 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.', /** A note under an `ok` or `limited` pick whose `tested` is false. */ - noteUntested: 'Not yet tested by us on a Mac like this.', + noteUntested: 'Codeman has not run this on a Mac like this yet.', /** A note under an `ok` pick that is itself a small model: larger ones fit this Mac, but rank lower or have no room on the disk. */ noteSmallPick: 'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.', @@ -363,18 +363,17 @@ export const LOCAL_MODEL_LABELS = { speedSlow: 'Slow', speedUnknown: 'Speed unknown', noneNotMacos: 'Codeman recommends a local model only on a Mac with Apple silicon.', - noneIntel: 'This Mac has an Intel processor. Local coding models need Apple silicon.', + noneIntel: 'Local coding models need Apple silicon, and this Mac has an Intel processor.', /** Reason `memory`: nothing fits at any context. */ - noneMemory: - '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.', - /** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and that they are an estimate. */ + noneMemory: 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.', + /** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and ("about") that they are estimates. */ noneMemoryContext: - '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.', + 'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.', /** Reason `memory`: the memory size could not be read, so no size is stated. */ noneMemoryUnknown: 'Codeman could not read how much memory this Mac has, so it recommends no model here.', - noneDisk: - '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.', - noneMacos: 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.', + /** Reason `disk`. One number: what to do. The other sizes travel in `vars` for a client that wants them. */ + noneDisk: 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.', + noneMacos: 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.', noneNoHarness: 'No coding agent that Codeman can connect to a local model is available on this Mac.', } as const; @@ -401,14 +400,17 @@ export interface LocalModelMessage { /** * The one tag a model row of the chooser carries, by what it says. * - `tested`: `Candidate.tested` is true. Replaces every other tag. - * - `best`: the pick. The one tag drawn in the accent colour. + * - `best-tight`: the pick, when its `fit` is `tight`. The words must not say less than the + * memory bar beside them, which is nearly full for such a row. + * - `best`: the pick. * - `tight`: `Candidate.fit` is `tight`. * - `slow`: `Candidate.speed` is `slow`. * - `small`: the model has fewer than 12 billion parameters. - * A row gets the FIRST kind of this list that applies and no tag when none does, so a pick - * that is tight says "Best fit" (the memory bar already shows how full it is). + * A row gets the FIRST kind of this list that applies and no tag when none does. `tested`, + * `best` and `best-tight` are the tags of the row the window opens on, drawn in the accent + * colour. */ -export type CandidateTagKind = 'tested' | 'best' | 'tight' | 'slow' | 'small'; +export type CandidateTagKind = 'tested' | 'best-tight' | 'best' | 'tight' | 'slow' | 'small'; /** * The text of each tag, in English (the i18n source language), like `LOCAL_MODEL_LABELS`. A @@ -416,6 +418,7 @@ export type CandidateTagKind = 'tested' | 'best' | 'tight' | 'slow' | 'small'; */ export const LOCAL_MODEL_TAG_LABELS = { tested: 'Tested on a Mac like this', + 'best-tight': 'Best fit, tight', best: 'Best fit', tight: 'Tight', slow: 'Slow', diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 47f33f7d..ac8622f7 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1435,7 +1435,8 @@ 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 上下文下的占用', + 'The model at its peak, with all {context} tokens of context in use': + '模型的峰值占用({context} 个 Token 的上下文全部用满时)', 'What stays free': '剩余可用', 'Models that fit this Mac': '此 Mac 装得下的模型', Speed: '速度', @@ -1444,20 +1445,20 @@ '{speed} tokens/s': '{speed} Token/秒', 'about {speed} tokens/s': '约 {speed} Token/秒', 'Runs with': '搭配', - 'Not on this Mac yet. It is installed for you.': '此 Mac 上尚未安装,将为你一并安装。', + 'Not on this Mac yet. Codeman installs it for you.': '此 Mac 上尚未安装,{name} 会为你一并安装。', '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},用于运行模型的程序', + // The confirm screen: the download as a table, where it goes. + 'What will be downloaded': '将下载的内容', + 'At {context} tokens of context, with {cli}': '上下文 {context} 个 Token,搭配 {cli}', + 'Ollama {version}': 'Ollama {version}', + 'The program that runs the model': '用于运行模型的程序', 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:': '模型和运行它的程序将存放在:', + 'Not on this Mac yet. Its own installer fetches it with this command:': + '此 Mac 上尚未安装。它自带的安装程序会用以下命令获取它:', + 'Not on this Mac yet. Its own installer fetches it.': '此 Mac 上尚未安装。它自带的安装程序会获取它。', + 'Goes into': '存放位置:', 'No administrator password.': '无需管理员密码。', 'Download and install': '下载并安装', // Progress, and the same picture paused. @@ -1474,9 +1475,14 @@ 'Loading and checking': '加载并检查', 'Installing {cli}': '安装 {cli}', Connecting: '正在连接', + 'Download of the model': '模型下载进度', + '0 of {total} GB': '已下载 0 GB,共 {total} GB', 'You can close this window. The download continues while Codeman is running.': '你可以关闭此窗口。只要 {name} 在运行,下载就会继续。', - 'Nothing is downloaded until you continue.': '在你继续之前不会下载任何内容。', + 'You can close this window. The install continues while Codeman is running.': + '你可以关闭此窗口。只要 {name} 在运行,安装就会继续。', + 'The download waits until you continue.': '下载会等你点击继续后再进行。', + 'The install waits until you continue.': '安装会等你点击继续后再进行。', Continue: '继续', 'Choose another model': '选择其他模型', // After an error: the one action that helps. @@ -1488,11 +1494,11 @@ '{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。', + 'Takes {memory} GB of memory now, and up to about {peak} GB at its peak, with all of the context in use.': + '目前占用 {memory} GB 内存;上下文全部用满时,峰值约为 {peak} GB。', + 'Takes {memory} GB of memory now.': '目前占用 {memory} GB 内存。', + 'All of it is on the GPU.': '全部位于 GPU 上。', 'Start a session': '开始会话', 'Remove the local model from this Mac?': '要从此 Mac 移除本地模型吗?', 'Also delete the downloaded model': '同时删除已下载的模型', @@ -1503,7 +1509,7 @@ 'Recommended for this Mac': '为此 Mac 推荐', '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 上测试过。', + 'Codeman has not run this on a Mac like this yet.': '{name} 尚未在同类 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.': @@ -1514,24 +1520,25 @@ 'Speed unknown': '速度未知', 'Codeman recommends a local model only on a Mac with Apple silicon.': '{name} 仅在搭载 Apple 芯片的 Mac 上推荐本地模型。', - 'This Mac has an Intel processor. Local coding models need Apple silicon.': - '此 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} 在此不推荐任何模型。', - '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} 在此不推荐任何模型。', + 'Local coding models need Apple silicon, and this Mac has an Intel processor.': + '本地编程模型需要 Apple 芯片,而此 Mac 使用的是 Intel 处理器。', + 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.': + '此 Mac 只有 {memory} GB 内存,无法运行能够驱动编程智能体的模型。', + 'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.': + '此 Mac 可分配给模型的内存约为 {budget} GB,而编程智能体能够使用的最小模型约需 {need} GB。', '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} 或更高版本。', + 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.': + '请腾出约 {missing} GB 磁盘空间,以便安装适合此 Mac 的最小模型。', + 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.': + '本地模型需要 macOS {minimum} 或更高版本,而此 Mac 运行的是 macOS {version}。', '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': '最适合', + 'Best fit, tight': '最适合,内存偏紧', '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. @@ -1569,6 +1576,17 @@ 'Sessions are still using this model: {sessions}. Close them first.': '仍有会话在使用此模型:{sessions}。请先关闭它们。', 'That is not possible in the current state.': '当前状态下无法执行此操作。', + 'This model at this context is not offered for this Mac right now.': + '目前不为此 Mac 提供这一上下文大小下的该模型。', + 'This coding agent is not offered for this model.': '该模型不提供这个编程智能体。', + '{cli} is not installed and cannot be installed from here.': '{cli} 尚未安装,也无法从这里安装。', + 'Only {model} is installed on this Mac. Remove it and install another model first.': + '此 Mac 上只安装了 {model}。请先移除它,再安装其他模型。', + // What the endpoint routes answer for the record of the model on this Mac (shown as a toast). + 'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.': + '此端点属于 {name} 在此 Mac 上运行的模型。请在本地模型窗口中更改或移除它。', + 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.': + '此端点标识已保留给 {name} 在此 Mac 上运行的模型。请换一个标识。', '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 会提示安装。', }); diff --git a/src/web/public/index.html b/src/web/public/index.html index abd35e38..c3c502c0 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3693,6 +3693,9 @@
+ + diff --git a/src/web/public/local-model-ui.js b/src/web/public/local-model-ui.js index 2609ddf2..19964c5f 100644 --- a/src/web/public/local-model-ui.js +++ b/src/web/public/local-model-ui.js @@ -26,15 +26,28 @@ * |-------------|-------------------------------------------------------------------| * | `choose` | idle: the memory bar, the models that fit as a radio group, the | * | | coding agent, "Install on this Mac" | - * | `confirm` | after that button: what is downloaded with sizes, where it goes | + * | `confirm` | after that button: the download as a table with its total, where | + * | | it goes | * | `progress` | installing: the download bar, the steps, Cancel | * | `paused` | paused (Cancel or a server restart): Continue or Remove | * | `failed` | failed: the server's sentence and the ONE action that helps | * | `installed` | installed: running or stopped, Start a session, Stop/Start, Remove | + * | | (right after an install: Start a session alone) | * | `none` | idle and nothing fits: the hardware line, the reason, Close | * * The memory bar is the one strong element: the memory this Mac can give a model, filled to - * what the selected model needs (`budgetShare`). Selecting another row moves the fill. + * what the selected model needs at its PEAK (`budgetShare`). Selecting another row moves the + * fill. The legend under it and the line of an installed model use the same words for that + * peak, so the two figures a person meets (what the model takes now, what it can take) are + * never read as a contradiction. + * + * The window is ONE frame for the whole flow: the header and the foot (`#localModelWindowFoot`, + * the buttons) stay where they are and only the body between them changes and scrolls, so a + * button never moves away from under the pointer and is never cut off. + * + * The rows of the chooser are the server's: its pick first, then the other models by download + * size, largest first, which is a column the reader can see. The server's own order of the + * alternatives is its ranking (what it would offer next), not a reading order. * * ## Polling * @@ -53,11 +66,13 @@ * - Names and labels come from the payload: this file holds no list of models or coding * agents and never compares a CLI id with a literal. The id is passed on as data * (`cli` of the install request, the session start). - * - Sizes are printed exactly as sent (`needGb`, `budgetGb`, `downloadGb`, `installGb`, - * `completedGb`, `totalGb`: decimal GB as text). Bytes are never turned into a size here. - * The installed memory alone is `memClassGb`, the size the Mac is sold as. The two byte + * - Sizes are printed exactly as sent (`downloadGb`, `installGb`, `completedGb`, `totalGb`, + * `memoryGb`: decimal GB as text). Bytes are never turned into a size here. The one figure + * that is rounded is the memory estimate beside the bar and in the installed line: it says + * "about", so it is printed in whole GB from the server's own text (`needGb`, `budgetGb`). + * The installed memory alone is `memClassGb`, the size the Mac is sold as. The byte * figures this file does read are the consent total (sent back unchanged as - * `acknowledgedBytes`) and the two ends of a bar. + * `acknowledgedBytes`), the two ends of a bar and the download size the rows are ordered by. * - The consent is bound to the size shown: the install request carries the server's own total * for the row (`Candidate.installBytes`), and a 409 `size-changed` shows the server's new * total and asks again. @@ -85,6 +100,9 @@ const LOCAL_MODEL_MAX_AGE_MS = 60000; /** How often the open window asks where the install stands. */ const LOCAL_MODEL_POLL_MS = 1000; +/** The tag kinds of the server's pick (`CandidateTagKind`): the ones drawn in the accent colour. */ +const LOCAL_MODEL_ACCENT_TAGS = ['tested', 'best', 'best-tight']; + /** i18n.js's translator with the `{placeholder}` slots filled, or the English source filled the same way. */ function localModelT(source, vars) { const values = vars && typeof vars === 'object' ? vars : {}; @@ -163,12 +181,25 @@ function localModelInstall(status) { return { state: 'idle', running: false, developerTools: 'unknown', actions: [] }; } -/** The rows of the chooser: the pick first, then the other models that fit. */ +/** + * The rows of the chooser: the server's pick first, then the other models that fit by download + * size, largest first. The download is a column of the list, so the order can be read off the + * screen; the server sends the alternatives in its own ranking, which no column shows. + */ function localModelCandidates(status) { const recommendation = (status && status.recommendation) || {}; if (recommendation.verdict === 'none' || !recommendation.pick) return []; - const rest = Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []; - return [recommendation.pick, ...rest].filter((row) => row && typeof row.modelId === 'string'); + const rest = (Array.isArray(recommendation.alternatives) ? recommendation.alternatives : []).filter( + (row) => row && typeof row.modelId === 'string' + ); + const bytes = (row) => (Number.isFinite(row.downloadBytes) ? row.downloadBytes : 0); + // A stable sort: rows of one size keep the server's order. + const bySize = rest + .map((row, index) => ({ row, index })) + .sort((a, b) => bytes(b.row) - bytes(a.row) || a.index - b.index); + return [recommendation.pick, ...bySize.map((entry) => entry.row)].filter( + (row) => row && typeof row.modelId === 'string' + ); } /** @@ -202,6 +233,29 @@ function localModelGb(value) { return `${value} GB`; } +/** + * A memory estimate in whole GB, from the server's decimal GB text ('36.0' is '36'). Only for + * a figure that says "about": an estimate printed to the decimal reads as a measurement. + * Text that is no number is returned as it is. + */ +function localModelWholeGb(value) { + const number = Number(value); + return typeof value === 'string' && value !== '' && Number.isFinite(number) + ? String(Math.round(number)) + : String(value ?? ''); +} + +/** + * A sentence group split after its first sentence: `[first, rest]`. The first is what a + * headline carries; the rest is read in the body size. Works on the translated text, so both + * the Latin full stop (followed by a space) and the ideographic one end a sentence. + */ +function localModelSentences(text) { + const value = String(text ?? '').trim(); + const match = /^(.+?(?:[.!?](?=\s)|[。!?]))\s*(.+)$/su.exec(value); + return match ? [match[1], match[2]] : [value, '']; +} + /** "about 68 tokens/s", the measured figure without "about", or that there is none. */ function localModelSpeedText(candidate) { const speed = localModelSpeed(candidate.tokensPerSecond); @@ -270,11 +324,20 @@ function localModelBar({ share, role, label, kind, from }) { return bar; } -/** One line of the confirm screen's download list: what, and how large. */ -function localModelSizeRow(label, gb, { strong = false } = {}) { +/** + * One row of the confirm screen's table: what is fetched, how large it is, and under the name + * a second line that says what it is (`note`) and, for a coding agent, the literal command of + * its installer (`command`). A row without a size (the coding agent: its installer decides) + * has none. + */ +function localModelSizeRow(label, { gb, note, command, strong = false } = {}) { const row = localModelEl('div', strong ? 'lm-size-row lm-size-total' : 'lm-size-row'); - row.appendChild(localModelEl('span', 'lm-size-what', label)); - row.appendChild(localModelEl('span', 'lm-size-gb', localModelGb(gb))); + const what = localModelEl('div', 'lm-size-what'); + what.appendChild(localModelEl('span', 'lm-size-name', label)); + if (note) what.appendChild(localModelEl('span', 'lm-size-note', note)); + if (command) what.appendChild(localModelEl('code', 'lm-command', command)); + row.appendChild(what); + if (gb) row.appendChild(localModelEl('span', 'lm-size-gb', localModelGb(gb))); return row; } @@ -343,12 +406,26 @@ Object.assign(CodemanApp.prototype, { this.renderLocalModel(); return; } + this._noteLocalModelInstall(data.install); this._localModelStatus = data; this._localModelState = 'ready'; this._localModelLoadedAt = Date.now(); this.renderLocalModel(); }, + /** + * Called with the `install` object that is about to replace the one on screen. An install + * that was running and is installed now finished in front of the person: the window then + * shows the screen that ends it (see `justInstalled`). Any other state ends that moment. + */ + _noteLocalModelInstall(next) { + const ui = this._localModelWindowState(); + const before = localModelInstall(this._localModelStatus).state; + const after = next && typeof next.state === 'string' ? next.state : 'idle'; + if (after !== 'installed') ui.justInstalled = false; + else if (before === 'installing' && ui.open) ui.justInstalled = true; + }, + _localModelRefused() { return this._localModelState === 'refused' || localModelNotAdmin(); }, @@ -373,6 +450,11 @@ Object.assign(CodemanApp.prototype, { /** The Remove question is open, and whether the downloaded model goes too. */ confirmRemove: false, removeModel: true, + /** + * The install finished while this window was open: the screen that ends it shows + * "Start a session" alone. Stop and Remove belong to the window opened later. + */ + justInstalled: false, }; } return this._localModelUi; @@ -417,7 +499,15 @@ Object.assign(CodemanApp.prototype, { if (!modal || this._localModelRefused()) return; const ui = this._localModelWindowState(); if (ui.open) return; - Object.assign(ui, { open: true, view: 'auto', busy: false, notice: null, expected: null, confirmRemove: false }); + Object.assign(ui, { + open: true, + view: 'auto', + busy: false, + notice: null, + expected: null, + confirmRemove: false, + justInstalled: false, + }); this._forgetLocalModelPaint(); modal.classList.add('active'); // A trap of its own: the window can open on top of App Settings, whose trap must stay @@ -503,11 +593,17 @@ Object.assign(CodemanApp.prototype, { const data = json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : null; let result; if (res?.ok && json?.success === true && data && typeof data.state === 'string') { + this._noteLocalModelInstall(data); if (this._localModelStatus) this._localModelStatus = { ...this._localModelStatus, install: data }; result = { ok: true, view: data }; } else if (res?.status === 409 && data) { ui.notice = { message: data.message, sessions: data.sessions }; result = { ok: false, conflict: data }; + } else if (res && data && data.message && typeof data.message.text === 'string') { + // A refusal the server names (a 400, a full disk) carries its sentence with the slots + // apart, like a 409, so it is printed in the window's own language. + ui.notice = { message: data.message }; + result = { ok: false }; } else { ui.notice = res ? { refused: true, detail: typeof json?.error === 'string' ? json.error : '' } @@ -732,9 +828,12 @@ Object.assign(CodemanApp.prototype, { const meter = body.querySelector('.lm-meter'); if (frame === this._localModelFrameKey && (screen === 'progress' || screen === 'paused') && meter) { - meter.replaceWith(this._localModelDownloadMeter(screen, last)); - this._settleLocalModelBars(body); - return; + const moved = this._localModelDownloadMeter(screen, last); + if (moved) { + meter.replaceWith(moved); + this._settleLocalModelBars(body); + return; + } } this._localModelFrameKey = frame; @@ -742,9 +841,17 @@ Object.assign(CodemanApp.prototype, { const active = document.activeElement; const inside = !!(modal && active && modal.contains(active)); const focusKey = inside && active.dataset ? active.dataset.lmKey : undefined; - const scrolled = body.scrollTop; + const scrolled = body.dataset.screen === screen ? body.scrollTop : 0; + const parts = this._buildLocalModelScreen(screen, last); + // The buttons (or the Remove question) are the last part a screen builds. They go into + // the foot of the frame, outside the part that scrolls, so they stay in one place. + const foot = document.getElementById('localModelWindowFoot'); + const tail = parts[parts.length - 1]; + const actions = + foot && tail && tail.classList && (tail.classList.contains('lm-foot') || tail.classList.contains('lm-remove')); body.dataset.screen = screen; - body.replaceChildren(...this._buildLocalModelScreen(screen, last)); + body.replaceChildren(...(actions ? parts.slice(0, -1) : parts)); + if (foot) foot.replaceChildren(...(actions ? [tail] : [])); body.scrollTop = scrolled; this._settleLocalModelBars(body); if (inside || active === document.body) this._focusLocalModelWindow(focusKey); @@ -776,7 +883,8 @@ Object.assign(CodemanApp.prototype, { const modal = document.getElementById('localModelModal'); const body = document.getElementById('localModelWindowBody'); if (!modal || !body) return; - const keyed = [...body.querySelectorAll('[data-lm-key]')].filter((el) => !el.disabled); + // The body and the foot: a screen's controls live in both. + const keyed = [...modal.querySelectorAll('[data-lm-key]')].filter((el) => !el.disabled); const target = (key && keyed.find((el) => el.dataset.lmKey === key && el.getAttribute('tabindex') !== '-1')) || keyed.find((el) => el.dataset.lmKey === 'model' && el.getAttribute('aria-checked') === 'true') || @@ -836,27 +944,23 @@ Object.assign(CodemanApp.prototype, { ); }, - /** One model row: the radio of the chooser, or the same line as plain text on the confirm screen. */ - _localModelRow(candidate, { checked, interactive, tabStop }) { + /** One model row of the chooser: a radio with the name, the tag, the speed and the download. */ + _localModelRow(candidate, { checked, tabStop }) { const row = localModelEl('div', 'lm-model'); - if (interactive) { - row.setAttribute('role', 'radio'); - row.setAttribute('aria-checked', checked ? 'true' : 'false'); - row.setAttribute('tabindex', tabStop ? '0' : '-1'); - row.dataset.lmKey = 'model'; - row.dataset.modelId = candidate.modelId; - row.appendChild(localModelEl('span', 'lm-radio')); - } else { - row.classList.add('lm-model-static'); - } + row.setAttribute('role', 'radio'); + row.setAttribute('aria-checked', checked ? 'true' : 'false'); + row.setAttribute('tabindex', tabStop ? '0' : '-1'); + row.dataset.lmKey = 'model'; + row.dataset.modelId = candidate.modelId; + row.appendChild(localModelEl('span', 'lm-radio')); if (checked) row.classList.add('lm-model-on'); row.appendChild(localModelEl('span', 'lm-model-name', String(candidate.modelLabel || candidate.modelId))); const tagSlot = localModelEl('span', 'lm-model-tag'); const label = candidate.tag && typeof candidate.tag.label === 'string' ? candidate.tag.label : ''; if (label) { const tag = localModelEl('span', 'lm-tag', localModelT(label)); - // The pick, and a row run on a Mac like this one, take the accent. Every other tag is quiet. - if (candidate.tag.kind === 'best' || candidate.tag.kind === 'tested') tag.classList.add('lm-tag-accent'); + // The tags of the row the window opens on take the accent. Every other tag is quiet. + if (LOCAL_MODEL_ACCENT_TAGS.includes(candidate.tag.kind)) tag.classList.add('lm-tag-accent'); tagSlot.appendChild(tag); } row.appendChild(tagSlot); @@ -878,7 +982,8 @@ Object.assign(CodemanApp.prototype, { // The memory bar: what this Mac can give a model, filled to what the selected one needs. const meter = localModelEl('div', 'lm-meter'); const head = localModelEl('div', 'lm-meter-head'); - const sizes = { need: candidate.needGb, budget: recommendation.budgetGb }; + // An estimate, so whole GB: "about 36 of 38", never an estimate to the decimal. + const sizes = { need: localModelWholeGb(candidate.needGb), budget: localModelWholeGb(recommendation.budgetGb) }; head.appendChild(localModelEl('span', 'lm-figure', localModelT('About {need} of {budget} GB of memory', sizes))); // A formula's figure says so, once. A wired limit and the runtime's own figure are facts. if (recommendation.budgetSource === 'estimated') { @@ -897,7 +1002,14 @@ Object.assign(CodemanApp.prototype, { ); const legend = localModelEl('div', 'lm-legend'); const context = localModelCount(candidate.context); - legend.appendChild(localModelEl('span', '', localModelT('The model at {context} tokens of context', { context }))); + // The fill is the model at its PEAK. The installed line uses the same words for it. + legend.appendChild( + localModelEl( + 'span', + '', + localModelT('The model at its peak, with all {context} tokens of context in use', { context }) + ) + ); legend.appendChild(localModelEl('span', '', localModelT('What stays free'))); meter.appendChild(legend); parts.push(meter); @@ -925,7 +1037,7 @@ Object.assign(CodemanApp.prototype, { group.setAttribute('aria-labelledby', 'localModelListLabel'); for (const row of candidates) { const checked = row === candidate; - const el = this._localModelRow(row, { checked, interactive: true, tabStop: checked }); + const el = this._localModelRow(row, { checked, tabStop: checked }); el.addEventListener('click', () => this.selectLocalModel(row.modelId)); group.appendChild(el); } @@ -962,7 +1074,7 @@ Object.assign(CodemanApp.prototype, { }); runs.appendChild(select); const hints = []; - if (harness.installed === false) hints.push(localModelT('Not on this Mac yet. It is installed for you.')); + if (harness.installed === false) hints.push(localModelT('Not on this Mac yet. Codeman installs it for you.')); if (harness.slowFirstTurn === true) hints.push(localModelT('The first answer can take minutes.')); if (hints.length) runs.appendChild(localModelEl('span', 'lm-runs-hint', hints.join(' '))); parts.push(runs); @@ -1014,6 +1126,13 @@ Object.assign(CodemanApp.prototype, { this.selectLocalModel(candidates[next].modelId); }, + /** + * The confirm screen: the download as a table. One row per thing that is fetched (the chosen + * model, the program that runs it while it is missing, the coding agent while it is + * missing), each with what it is in a second line, and the total under them in the strong + * weight. The table IS the screen; around it only where it goes and, on a Mac without the + * developer tools, the one sentence about them. + */ _buildLocalModelConfirm() { const ui = this._localModelWindowState(); const status = this._localModelStatus; @@ -1023,51 +1142,48 @@ Object.assign(CodemanApp.prototype, { const hw = this._localModelHardwareEl(); if (hw) parts.push(hw); - // The list collapses to the chosen row. - const chosen = localModelEl('div', 'lm-list'); - const one = localModelEl('div', 'lm-models'); - one.appendChild(this._localModelRow(candidate, { checked: true, interactive: false })); - chosen.appendChild(one); - parts.push(chosen); const cli = String(harness.cliLabel || harness.cli); - const runs = localModelT('Runs with {cli}, at {context} tokens of context.', { + const sizes = localModelEl('div', 'lm-sizes'); + sizes.appendChild(localModelEl('h4', 'lm-heading', localModelT('What will be downloaded'))); + + // The list collapses to the chosen row: it is the first row of the table. + const about = localModelT('At {context} tokens of context, with {cli}', { cli, context: localModelCount(candidate.context), }); - parts.push(localModelEl('p', 'lm-aside', runs)); - - // What is downloaded, with sizes: the model, the runtime while it is missing, the total. - const sizes = localModelEl('div', 'lm-sizes'); - sizes.appendChild(localModelEl('h4', 'lm-heading', localModelT('What is downloaded'))); - sizes.appendChild(localModelSizeRow(String(candidate.modelLabel || candidate.modelId), candidate.downloadGb)); + sizes.appendChild( + localModelSizeRow(String(candidate.modelLabel || candidate.modelId), { gb: candidate.downloadGb, note: about }) + ); const runtime = install.runtime || {}; if (runtime.installed !== true && Number(runtime.downloadBytes) > 0 && runtime.downloadGb) { - const what = localModelT('Ollama {version}, the program that runs the model', { version: runtime.version }); - sizes.appendChild(localModelSizeRow(what, runtime.downloadGb)); + sizes.appendChild( + localModelSizeRow(localModelT('Ollama {version}', { version: runtime.version }), { + gb: runtime.downloadGb, + note: localModelT('The program that runs the model'), + }) + ); + } + // The coding agent, when it is missing: its own installer fetches it, with this literal command. + if (harness.installed === false) { + const note = harness.installCommand + ? localModelT('Not on this Mac yet. Its own installer fetches it with this command:') + : localModelT('Not on this Mac yet. Its own installer fetches it.'); + sizes.appendChild( + localModelSizeRow(cli, { note, command: harness.installCommand ? String(harness.installCommand) : '' }) + ); } const expected = ui.expected && ui.expected.modelId === candidate.modelId ? ui.expected : null; const total = expected ? expected.gb : candidate.installGb; - if (total) sizes.appendChild(localModelSizeRow(localModelT('Total'), total, { strong: true })); + if (total) sizes.appendChild(localModelSizeRow(localModelT('Total'), { gb: total, strong: true })); parts.push(sizes); - // The coding agent, when it is missing: the literal command its installer runs. - if (harness.installed === false) { - const agent = localModelEl('div', 'lm-agent'); - const lead = harness.installCommand - ? localModelT('{cli} is not on this Mac yet. Its own installer is run with this command:', { cli }) - : localModelT('{cli} is not on this Mac yet. It is installed with its own installer.', { cli }); - agent.appendChild(localModelEl('p', 'lm-aside', lead)); - if (harness.installCommand) agent.appendChild(localModelEl('code', 'lm-command', String(harness.installCommand))); - parts.push(agent); - } - - const where = localModelEl('div', 'lm-where'); if (install.folder) { - where.appendChild(localModelEl('p', 'lm-aside', localModelT('The model and the program that runs it go into:'))); - where.appendChild(localModelEl('code', 'lm-command', String(install.folder))); + const where = localModelEl('p', 'lm-where'); + where.appendChild(localModelEl('span', '', localModelT('Goes into'))); + where.appendChild(document.createTextNode(' ')); + where.appendChild(localModelEl('code', 'lm-path', String(install.folder))); + parts.push(where); } - where.appendChild(localModelEl('p', 'lm-aside', localModelT('No administrator password.'))); - parts.push(where); const tools = this._localModelDeveloperToolsEl(); if (tools) parts.push(tools); @@ -1075,6 +1191,7 @@ Object.assign(CodemanApp.prototype, { if (notice) parts.push(notice); const foot = localModelEl('div', 'lm-foot'); + foot.appendChild(localModelEl('p', 'lm-foot-note', localModelT('No administrator password.'))); const buttons = localModelEl('div', 'lm-buttons'); buttons.appendChild( localModelButton(localModelT('Back'), { key: 'back', disabled: ui.busy, onClick: () => this.chooseLocalModel() }) @@ -1160,22 +1277,24 @@ Object.assign(CodemanApp.prototype, { }, /** - * The download bar and its figure: "6.2 of 23.1 GB", then the speed and the time left. A step - * that downloads nothing names itself instead, with the bar full once the model is in. + * The download bar and its figure: "6.2 of 23.1 GB", then the speed and the time left. The + * bar means ONE thing, the model download: while the runtime is fetched (seconds) it stands + * at the start with the size that is coming, it fills while the model arrives, and once the + * model is in it is gone (`null`): the step list carries the rest. */ _localModelDownloadMeter(screen, last) { const install = localModelInstall(this._localModelStatus); + if (install.step !== 'runtime' && install.step !== 'model') return null; const paused = screen === 'paused'; - const progress = install.progress; + const progress = install.step === 'model' ? install.progress : null; const meter = localModelEl('div', 'lm-meter'); const head = localModelEl('div', 'lm-meter-head'); - const steps = Array.isArray(install.steps) ? install.steps : []; - const now = localModelStepLabel(install.step, String(install.cliLabel || '')); - let share = steps.indexOf(install.step) > steps.indexOf('model') ? 1 : 0; + const label = localModelT('Download of the model'); + let share = 0; if (progress && Number(progress.totalBytes) > 0) { share = Number(progress.completedBytes) / Number(progress.totalBytes); const sizes = { done: progress.completedGb, total: progress.totalGb }; - head.appendChild(localModelEl('span', 'lm-figure', localModelT('{done} of {total} GB', sizes))); + head.appendChild(localModelEl('span', 'lm-figure lm-figure-count', localModelT('{done} of {total} GB', sizes))); const left = localModelTimeLeft(progress.secondsLeft); let rate = ''; if (!paused && progress.mbPerSecond && left) { @@ -1185,10 +1304,15 @@ Object.assign(CodemanApp.prototype, { } if (rate) head.appendChild(localModelEl('span', 'lm-figure-aside', rate)); } else { - head.appendChild(localModelEl('span', 'lm-figure', now)); + // Nothing of the model has arrived yet: the size that is coming, from the row it was chosen on. + const row = localModelCandidates(this._localModelStatus).find( + (candidate) => install.model && candidate.modelId === install.model.id + ); + const text = row && row.downloadGb ? localModelT('0 of {total} GB', { total: row.downloadGb }) : label; + head.appendChild(localModelEl('span', 'lm-figure lm-figure-count', text)); } meter.appendChild(head); - meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label: now })); + meter.appendChild(localModelBar({ share, role: 'progressbar', kind: 'download', from: last.download, label })); return meter; }, @@ -1204,8 +1328,9 @@ Object.assign(CodemanApp.prototype, { : localModelT('Installing {model}', { model }); parts.push(localModelEl('p', 'lm-title', title)); - // The download bar takes the place of the memory bar. - parts.push(this._localModelDownloadMeter(screen, last)); + // The download bar takes the place of the memory bar, for as long as something downloads. + const meter = this._localModelDownloadMeter(screen, last); + if (meter) parts.push(meter); const list = this._localModelStepsEl(install, { stopped: paused }); if (list) parts.push(list); @@ -1215,9 +1340,18 @@ Object.assign(CodemanApp.prototype, { return parts; } - const note = paused - ? localModelT('Nothing is downloaded until you continue.') - : localModelT('You can close this window. The download continues while Codeman is running.'); + // What waits or goes on is the download while there is one, the install after it. + const downloading = !!meter; + let note; + if (paused) { + note = downloading + ? localModelT('The download waits until you continue.') + : localModelT('The install waits until you continue.'); + } else { + note = downloading + ? localModelT('You can close this window. The download continues while Codeman is running.') + : localModelT('You can close this window. The install continues while Codeman is running.'); + } parts.push(localModelEl('p', 'lm-aside', note)); const notice = this._localModelNoticeEl(); if (notice) parts.push(notice); @@ -1269,6 +1403,7 @@ Object.assign(CodemanApp.prototype, { /** * The ONE action that helps after an error, as a button, or null when the server does not * offer it right now. `error.next` names it; `install.actions` says whether it is possible. + * It is the main button of the screen, except a removal, which never takes the accent. */ _localModelNextButton(install, error) { const ui = this._localModelWindowState(); @@ -1289,7 +1424,12 @@ Object.assign(CodemanApp.prototype, { return primary(localModelT('Start'), () => void this.setLocalModelRuntime('start')); } if (error.next === 'remove' && can('remove')) { - return primary(localModelT('Remove'), () => void this.removeLocalModel()); + // The one action that helps, and still a removal: quiet here, red only in its question. + return localModelButton(localModelT('Remove'), { + key: 'primary', + disabled: ui.busy, + onClick: () => void this.removeLocalModel(), + }); } if (error.next === 'next-model' && can('install')) { const candidates = localModelCandidates(this._localModelStatus); @@ -1303,10 +1443,15 @@ Object.assign(CodemanApp.prototype, { return null; }, - /** An error as the window shows it: the server's sentence, and its raw detail folded away. */ + /** + * An error as the window shows it: the first sentence of the server's text as the headline, + * what follows it in the body size, and the raw detail folded away. + */ _localModelErrorEl(error) { const box = localModelEl('div', 'lm-error'); - box.appendChild(localModelEl('p', 'lm-title', localModelMessage(error.message))); + const [headline, rest] = localModelSentences(localModelMessage(error.message)); + box.appendChild(localModelEl('p', 'lm-title', headline)); + if (rest) box.appendChild(localModelEl('p', 'lm-lead', rest)); if (typeof error.detail === 'string' && error.detail) { const details = localModelEl('details', 'lm-details'); details.appendChild(localModelEl('summary', '', localModelT('Details'))); @@ -1351,44 +1496,70 @@ Object.assign(CodemanApp.prototype, { return parts; }, + /** + * The line of facts under an installed model: the context, what the model takes now (what + * the runtime reported when the install checked it) and, in the words of the chooser's + * legend, what it can take at its peak (the row's own estimate, so the two figures a person + * meets are told apart instead of read as a contradiction). + */ + _localModelFactsLine(install) { + const loaded = install.loaded || {}; + const context = localModelCount(loaded.context ?? install.context); + const row = localModelCandidates(this._localModelStatus).find( + (candidate) => + install.model && + candidate.modelId === install.model.id && + candidate.context === (loaded.context ?? install.context) + ); + const facts = { context, memory: loaded.memoryGb, peak: row ? localModelWholeGb(row.needGb) : '' }; + const sentences = []; + if (context) sentences.push(localModelT('Context {context} tokens.', facts)); + if (loaded.memoryGb && facts.peak) { + sentences.push( + localModelT( + 'Takes {memory} GB of memory now, and up to about {peak} GB at its peak, with all of the context in use.', + facts + ) + ); + } else if (loaded.memoryGb) { + sentences.push(localModelT('Takes {memory} GB of memory now.', facts)); + } + if (loaded.memoryGb && loaded.onGpu === true) sentences.push(localModelT('All of it is on the GPU.')); + return sentences.join(' '); + }, + _buildLocalModelInstalled() { const ui = this._localModelWindowState(); const install = localModelInstall(this._localModelStatus); const model = install.model ? String(install.model.label || install.model.id || '') : ''; + const error = install.error; const parts = []; - const title = install.running - ? localModelT('{model} is ready on this Mac.', { model }) - : localModelT('{model} is installed on this Mac.', { model }); - parts.push(localModelEl('p', 'lm-title', title)); - // The measured facts, in one line: what the runtime reported when the install checked it. - const loaded = install.loaded || {}; - const facts = { context: localModelCount(loaded.context ?? install.context), memory: loaded.memoryGb }; - let line = ''; - if (loaded.memoryGb && loaded.onGpu === true) { - line = localModelT('Context {context} tokens. Takes {memory} GB of memory when loaded, all on the GPU.', facts); - } else if (loaded.memoryGb) { - line = localModelT('Context {context} tokens. Takes {memory} GB of memory when loaded.', facts); - } else if (facts.context) { - line = localModelT('Context {context} tokens.', facts); + // Something blocks a session: that sentence is the headline, not "is ready". Its one + // action is the main button below. + if (error) { + parts.push(this._localModelErrorEl(error)); + } else { + const title = install.running + ? localModelT('{model} is ready on this Mac.', { model }) + : localModelT('{model} is installed on this Mac.', { model }); + parts.push(localModelEl('p', 'lm-title', title)); } + const state = localModelEl('div', 'lm-state'); - const dot = localModelEl('span', install.running ? 'lm-dot lm-dot-on' : 'lm-dot'); + // Green says "a session can be started". A model that is blocked is not that. + const dot = localModelEl('span', install.running && !error ? 'lm-dot lm-dot-on' : 'lm-dot'); dot.setAttribute('aria-hidden', 'true'); state.appendChild(dot); - state.appendChild( - localModelEl('span', 'lm-state-text', install.running ? localModelT('Running') : localModelT('Stopped')) - ); + const running = install.running ? localModelT('Running') : localModelT('Stopped'); + state.appendChild(localModelEl('span', 'lm-state-text', error ? `${model}, ${running}` : running)); parts.push(state); const about = []; if (install.cliLabel) about.push(localModelT('Runs with {cli}.', { cli: String(install.cliLabel) })); + const line = this._localModelFactsLine(install); if (line) about.push(line); if (about.length) parts.push(localModelEl('p', 'lm-aside', about.join(' '))); - // Something blocks a session: the server's sentence. Its one action is the main button below. - const error = install.error; - if (error) parts.push(this._localModelErrorEl(error)); - if (ui.confirmRemove) { parts.push(this._localModelRemoveEl()); return parts; @@ -1400,9 +1571,12 @@ Object.assign(CodemanApp.prototype, { const can = (action) => install.actions.includes(action); const next = error ? this._localModelNextButton(install, error) : null; + // The screen that ends an install has one button. Stop and Remove are for the window + // opened later (or for this one once anything else was said in it). + const ending = ui.justInstalled === true && can('start-session') && !error && !notice; const foot = localModelEl('div', 'lm-foot'); const buttons = localModelEl('div', 'lm-buttons'); - if (can('remove') && error?.next !== 'remove') { + if (!ending && can('remove') && error?.next !== 'remove') { buttons.appendChild( localModelButton(localModelT('Remove'), { key: 'remove', @@ -1411,7 +1585,7 @@ Object.assign(CodemanApp.prototype, { }) ); } - if (can('stop')) { + if (!ending && can('stop')) { buttons.appendChild( localModelButton(localModelT('Stop'), { key: 'stop', diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index cfdff19c..fc8fdd63 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1870,14 +1870,26 @@ html.mobile-init .file-browser-panel { } .local-model-window > .lm-body { - flex: 1; - padding: 1rem 1rem calc(1rem + var(--safe-area-bottom)); + padding: 1rem; } /* The window fills the screen, so its actions sit at the bottom, under the thumb. */ - .lm-body > .lm-foot, - .lm-body > .lm-remove { - margin-top: auto; + .local-model-window > .lm-foot-slot { + padding: 0 1rem calc(1rem + var(--safe-area-bottom)); + } + + /* The short screens (progress, paused, a failure, installed, nothing fits) + hold a few lines. On a screen this tall they sit right above the buttons, + where the eye and the thumb already are, not at the top with half a + screen of nothing between the two. */ + .local-model-window > .lm-body:is( + [data-screen='progress'], + [data-screen='paused'], + [data-screen='failed'], + [data-screen='installed'], + [data-screen='none'] + ) { + justify-content: flex-end; } /* Ralph wizard responsive */ @@ -4332,14 +4344,6 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { grid-column: 3; } - .lm-model-static .lm-model-name { - grid-column: 1 / span 2; - } - - .lm-model-static .lm-model-speed { - grid-column: 1 / span 2; - } - .lm-foot { flex-direction: column; align-items: stretch; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 24494294..42f7d2d4 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -22315,11 +22315,16 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til row carries the accent on its reading edge and a faint fill. Theme tokens only, never a black or white alpha (those vanish on the light - skins). The bar's track is `--control-border-hover`: the fill of the app's - inputs (`--bg-input`) is the window's own surface on the dark skins, so the - part of the bar that stays free could not be seen. Nothing here is keyed on - a CLI or a model id. The only motion is the bar's width, and it stops under - prefers-reduced-motion. + skins). The bar's track is `--text-muted`: the part of the bar that stays + FREE is half of what the bar says, so it must be seen against the window + (about 3:1 or better on every skin; the fill of the app's inputs, + `--bg-input`, is the window's own surface on the dark skins, and a border + token is 1.5:1). Nothing here is keyed on a CLI or a model id. The only + motion is the bar's width, and it stops under prefers-reduced-motion. + + The window is ONE frame for the whole flow: a fixed height on desktop, the + header on top, the buttons in a foot of their own (`.lm-foot-slot`) and the + body scrolling between them. A click never moves the next button away. ═══════════════════════════════════════════════════════════════ */ /* The welcome button: one more chip, on its own line under the launchers. */ @@ -22337,13 +22342,16 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til flex-shrink: 0; } -/* The window: App Settings' chrome (`.modal`, `.modal-header`, `.modal-close`) - at a width of its own, the body scrolling under a fixed header. */ +/* The window: App Settings' chrome (`.modal`, `.modal-header`, the boxed + close button) at a width of its own. One frame for every screen: the height + is fixed, so the header, the close button and the main button stay where + they are from the chooser to "Start a session". */ .modal-content.local-model-window { display: flex; flex-direction: column; width: min(640px, 94vw); max-width: min(640px, 94vw); + height: min(600px, 88vh); max-height: 88vh; } @@ -22351,8 +22359,36 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til flex-shrink: 0; } +/* App Settings' close button: a 30px box, not the bare glyph of a plain modal. */ +.local-model-window > .modal-header .modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border-radius: 9px; + font-size: 1.2rem; + color: var(--text-dim); + background: transparent; + transition: + background 0.15s ease, + color 0.15s ease, + transform 0.1s ease; +} + +.local-model-window > .modal-header .modal-close:hover { + background: var(--bg-hover); + color: var(--text); +} + +.local-model-window > .modal-header .modal-close:active { + transform: scale(0.94); +} + .local-model-window > .lm-body { display: flex; + flex: 1; flex-direction: column; gap: 1.05rem; min-height: 0; @@ -22361,6 +22397,37 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til text-align: start; } +/* The short screens (progress, paused, a failure, installed, nothing fits) + hold a few lines in a frame sized for the chooser: they sit in the middle of + it, a status in its own room, not a heading with a void under it. */ +.local-model-window + > .lm-body:is( + [data-screen='progress'], + [data-screen='paused'], + [data-screen='failed'], + [data-screen='installed'], + [data-screen='none'] + ) { + justify-content: center; + padding-bottom: 2.2rem; +} + +/* The foot of the frame: the buttons of the screen (or the Remove question), + outside the part that scrolls. */ +.local-model-window > .lm-foot-slot { + flex-shrink: 0; + padding: 0 1.25rem 1.15rem; + text-align: start; +} + +.local-model-window > .lm-foot-slot:empty { + display: none; +} + +.lm-foot-slot p { + margin: 0; +} + .lm-body p, .lm-body h4 { margin: 0; @@ -22437,6 +22504,11 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til font-weight: 680; letter-spacing: -0.012em; color: var(--text); +} + +/* Only the figure that ticks (the download) holds its digits in fixed cells. + In a sentence fixed cells leave a hole around every 1. */ +.lm-figure-count { font-variant-numeric: tabular-nums; } @@ -22449,7 +22521,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-bar { height: 10px; border-radius: 999px; - background: var(--control-border-hover); + background: var(--text-muted); overflow: hidden; } @@ -22464,9 +22536,9 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til display: flex; justify-content: space-between; gap: 1rem; - font-size: 0.68rem; + font-size: 0.7rem; line-height: 1.4; - color: var(--text-muted); + color: var(--text-dim); } .lm-legend > :last-child { @@ -22507,8 +22579,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-caption { padding-bottom: 0.3rem; - font-size: 0.68rem; - color: var(--text-muted); + font-size: 0.7rem; + color: var(--text-dim); } .lm-caption-label { @@ -22551,15 +22623,6 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til background: rgba(var(--accent-rgb), 0.075); } -/* The chosen row of the confirm screen: the same line, nothing to press. */ -.lm-model-static { - cursor: default; -} - -.lm-model-static .lm-model-name { - grid-column: 1 / span 2; -} - .lm-radio { box-sizing: border-box; width: 14px; @@ -22674,60 +22737,79 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til font-size: 0.74rem; } -/* ── the confirm screen ───────────────────────────────────────────── */ +/* ── the confirm screen: the download as a table ──────────────────── */ +/* One row per thing that is fetched: its name in the text colour with what it + is underneath, its size at the right edge, and the total under a rule in the + strong weight. The table is the screen; nothing else on it competes. */ .lm-sizes { display: flex; flex-direction: column; } .lm-sizes .lm-heading { - margin-bottom: 0.3rem; + margin-bottom: 0.35rem; + font-size: 0.84rem; + font-weight: 650; + letter-spacing: -0.005em; + color: var(--text); } .lm-size-row { display: flex; align-items: baseline; justify-content: space-between; - gap: 1rem; - padding: 0.28rem 0; - font-size: 0.8rem; - color: var(--text-dim); + gap: 1.2rem; + padding: 0.6rem 0; + border-top: 1px solid var(--border); } .lm-size-what { + display: flex; + flex-direction: column; + gap: 0.18rem; min-width: 0; +} + +.lm-size-name { + font-size: 0.86rem; + font-weight: 600; + color: var(--text); + overflow-wrap: anywhere; +} + +.lm-size-note { + font-size: 0.74rem; + line-height: 1.45; + color: var(--text-dim); overflow-wrap: anywhere; } .lm-size-gb { flex-shrink: 0; + font-size: 0.86rem; + font-weight: 560; color: var(--text); font-variant-numeric: tabular-nums; white-space: nowrap; } .lm-size-total { - margin-top: 0.25rem; - padding-top: 0.55rem; - border-top: 1px solid var(--border); - font-weight: 650; - color: var(--text); + padding-top: 0.75rem; + border-top-color: var(--text-muted); } -.lm-agent, -.lm-where { - display: flex; - flex-direction: column; - gap: 0.35rem; +.lm-size-total .lm-size-name, +.lm-size-total .lm-size-gb { + font-size: 1.06rem; + font-weight: 680; + letter-spacing: -0.012em; } -/* A literal command and a folder: text to read and to copy, so it is set in - the mono face and selects whole. */ +/* A literal command: text to read and to copy, so it is set in the mono face + and selects whole. It sits in its row, under the sentence that names it. */ .lm-command { display: block; - padding: 0.42rem 0.6rem; - border-radius: 6px; - background: var(--bg-input); + margin-top: 0.15rem; color: var(--text); font-family: var(--font-mono); font-size: 0.72rem; @@ -22736,6 +22818,21 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til user-select: all; } +/* Where it goes: one quiet line under the table. */ +.lm-where { + font-size: 0.74rem; + line-height: 1.5; + color: var(--text-dim); + overflow-wrap: anywhere; +} + +.lm-path { + color: var(--text); + font-family: var(--font-mono); + font-size: 0.72rem; + user-select: all; +} + /* ── progress: the steps ARE a sequence, so they are a list ───────── */ .lm-steps { display: flex; @@ -22853,7 +22950,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til } .lm-details { - margin-top: 0.55rem; + margin-top: 0.2rem; font-size: 0.74rem; color: var(--text-dim); } @@ -22882,6 +22979,13 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til overflow-wrap: anywhere; } +/* An error: its first sentence as the headline, the rest in the body size. */ +.lm-error { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + .lm-remove { display: flex; flex-direction: column; @@ -22922,9 +23026,9 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til .lm-foot-note { min-width: 0; - font-size: 0.72rem; + font-size: 0.74rem; line-height: 1.45; - color: var(--text-muted); + color: var(--text-dim); } .lm-buttons { diff --git a/test/local-model-recommend.test.ts b/test/local-model-recommend.test.ts index 4a6ee5f8..71726247 100644 --- a/test/local-model-recommend.test.ts +++ b/test/local-model-recommend.test.ts @@ -604,10 +604,11 @@ describe('the MacBook Pro (Mac17,13: Apple M5 Pro, 48 GB)', () => { tested: false, // 13.6 x 307 / 100. tokensPerSecond: 41.8, - tag: { kind: 'best', label: 'Best fit' }, + // It only just fits, and the tag says so. + tag: { kind: 'best-tight', label: 'Best fit, tight' }, }); expect(result.headline).toEqual({ text: 'Best fit for this Mac' }); - expect(result.notes).toEqual([{ text: 'Not yet tested by us on a Mac like this.' }]); + expect(result.notes).toEqual([{ text: 'Codeman has not run this on a Mac like this yet.' }]); expect(stringsIn(result).join(' ')).not.toMatch(/recommended/i); }); @@ -651,7 +652,7 @@ describe('the Mac mini (Mac16,10: Apple M4, 16 GB)', () => { verdict: 'none', reason: 'memory', headline: { - text: '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.', + text: 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.', vars: { memory: '16' }, }, notes: [], @@ -818,7 +819,7 @@ describe('verdict none', () => { it('answers so for an Intel Mac, whose sysctl has no arm64 flag', () => { const hw = without(mac(32, intelChip, { arch: 'x64' }), 'arm64', 'translated', 'wiredLimitMb'); const result = recommend(hw, STOCK, ALL_INSTALLED); - expectNone(result, 'intel', 'This Mac has an Intel processor. Local coding models need Apple silicon.'); + expectNone(result, 'intel', 'Local coding models need Apple silicon, and this Mac has an Intel processor.'); expect(result.budgetBytes).toBe(0); expect(result.budgetGb).toBe('0.0'); }); @@ -868,7 +869,7 @@ describe('verdict none', () => { STOCK, ALL_INSTALLED ); - expectNone(result, 'macos', 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.'); + expectNone(result, 'macos', 'Local models need macOS {minimum} or newer, and this Mac runs macOS {version}.'); expect(result.headline.vars).toEqual({ version: '13.6.7', minimum: '14' }); // The budget is still reported: the Mac itself is able. expect(result.budgetBytes).toBe(35 * GIB); @@ -898,7 +899,7 @@ describe('verdict none', () => { expectNone( result, 'memory', - '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.' + 'With {memory} GB of memory, this Mac cannot run a model that can drive a coding agent.' ); expect(result.headline.text).toBe(LOCAL_MODEL_LABELS.noneMemory); // The size the Mac is sold as (what About This Mac shows), not 8 x 2^30 bytes as 8.6 decimal GB. @@ -931,7 +932,10 @@ describe('verdict none', () => { // The need is the smallest one at a context a usable harness works at (the 9B at 65536), // not the 9B at 32768, which fits and which only the gated harness could use. expect(result.headline.vars).toEqual({ budget: '16.1', need: '17.0' }); - expect(result.headline.text).toMatch(/^Codeman estimates/); + // Both figures are said as estimates ("about"), in one sentence. + expect(result.headline.text).toBe( + 'This Mac can give a model about {budget} GB of memory, and the smallest model a coding agent can work with needs about {need} GB.' + ); expect(result.headline.text).not.toContain('{memory}'); }); @@ -1045,24 +1049,24 @@ describe('verdict none', () => { // 12 GiB free: even the smallest download (6.55 GB) leaves 5.9 GiB. const hw = mac(48, 'Apple M4 Max', { freeDiskBytes: 12 * GIB, freeDiskGb: formatGb(12 * GIB) }); const result = recommend(hw, STOCK, ALL_INSTALLED); + // One sentence with the one number that says what to do. expectNone( result, 'disk', - '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.' + 'Free up about {missing} GB of disk space to install the smallest model that fits this Mac.' ); // download + 10 GiB - free = 6,550,825,373 + 10,737,418,240 - 12,884,901,888 bytes. - expect(result.headline.vars).toEqual({ download: '6.6', reserve: '10.7', free: '12.9', missing: '4.4' }); + expect(result.headline.vars).toEqual({ missing: '4.4' }); expect(result.budgetBytes).toBe(35 * GIB); }); - it('gives the reader every number of the sum: download + reserve - free = missing', () => { - // 12 GB free is more than the 6.6 GB download, so without the reserve "not enough" reads false. + it('says what to free, counting the reserve: download + 10 GiB - free', () => { + // 12 GB free is more than the 6.6 GB download: what is missing is the reserve on top of it. const hw = mac(32, 'Apple M4', { freeDiskBytes: 12 * GB, freeDiskGb: formatGb(12 * GB) }); const vars = recommend(hw, STOCK, ALL_INSTALLED).headline.vars as Record