Merge #804: a library of models on this Mac, with sizes, delete, add and switch

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 20:15:34 +01:00
commit f07cbccb0b
26 files changed
+4244 -227

No files matched your search

File diff suppressed because it is too large. Load diff
+39
View File
@@ -808,6 +808,45 @@ export async function removePartialModelBlobs(paths?: LocalModelPaths): Promise<
return freed;
}
/** How many files of the blob folder are counted at most: a store holds a few dozen, and a walk must end. */
const STORE_MEASURE_MAX_FILES = 20_000;
/**
* What the model store takes on disk: the sizes of the regular files directly in
* `<models>/blobs`, summed. That folder is flat (one file per layer, plus the partial files of
* a download), so this is the store's real disk use, shared layers counted once, an
* interrupted download included. The manifests beside it are a few kilobytes and are left out.
*
* One `readdir` and one `lstat` per file, at most `STORE_MEASURE_MAX_FILES` of them. Nothing is
* followed: a `root`, a model store or a blob folder that is a symbolic link counts as empty,
* and so does a link in the place of a file. Never outside `LocalModelPaths.root`. Resolves 0
* when there is no store. Never throws.
*/
export async function measureModelStore(paths?: LocalModelPaths): Promise<number> {
let total = 0;
try {
const resolved = paths ?? localModelPaths(OLLAMA_RUNTIME_PIN.version);
const blobs = join(resolved.modelsDir, 'blobs');
if (!isInsideLocalModelRoot(blobs, resolved.root)) return 0;
for (const folder of [resolved.root, resolved.modelsDir, blobs]) {
const info = await lstat(folder);
if (info.isSymbolicLink() || !info.isDirectory()) return 0;
}
const names = await readdir(blobs);
for (const name of names.slice(0, STORE_MEASURE_MAX_FILES)) {
try {
const info = await lstat(join(blobs, name));
if (info.isFile()) total += info.size;
} catch {
// Gone between the listing and the look: a download or a delete is under way.
}
}
} catch {
// No model store yet.
}
return total;
}
// ---------- Port ----------
/** Whether nothing holds `port` on 127.0.0.1, decided by binding it and letting go again. */
+39
View File
@@ -26,6 +26,7 @@ import {
type LocalModelErrorCode,
type LocalModelInstallState,
type LocalModelInstallStep,
type LocalModelLibraryEntry,
type LocalModelStateFile,
} from '../types.js';
import { OLLAMA_RUNTIME_PIN } from './runtime.js';
@@ -153,6 +154,41 @@ function parseConnection(value: unknown): LocalModelStateFile['connection'] {
: undefined;
}
/** How many checked models a state file may list: far more than one Mac holds, and a bound on what a damaged file can cost. */
const MAX_LIBRARY_ENTRIES = 64;
/**
* The checked models of a file. An entry without its id, label, context or digest is dropped
* (it could never be switched to), and so is a second entry for an id. `undefined` for a file
* that has no list, which is what an earlier build wrote.
*/
function parseLibrary(value: unknown): LocalModelLibraryEntry[] | undefined {
if (!Array.isArray(value)) return undefined;
const entries: LocalModelLibraryEntry[] = [];
for (const item of value) {
if (entries.length >= MAX_LIBRARY_ENTRIES) break;
if (!isRecord(item)) continue;
const id = text(item.id, 200);
const label = text(item.label, 200);
const context = count(item.context);
const digest = text(item.digest, 200);
if (!id || !label || !context || !digest || entries.some((entry) => entry.id === id)) continue;
const bytes = count(item.bytes);
const cli = parseCli(item.cli);
const verifiedAt = text(item.verifiedAt, 64);
entries.push({
id,
label,
context,
digest,
...(bytes ? { bytes } : {}),
...(cli ? { cli } : {}),
...(verifiedAt ? { verifiedAt } : {}),
});
}
return entries;
}
/**
* The state a file's text stands for. Pure and total: text that is not a JSON object of
* `schema: 1` with a known `state` is `IDLE_LOCAL_MODEL_STATE`. Members it does not know are
@@ -180,6 +216,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
const connection = parseConnection(parsed.connection);
const updatedAt = text(parsed.updatedAt, 64);
const replacedModelId = text(parsed.replacedModelId, 200);
const library = parseLibrary(parsed.library);
if (!model || !cli) return IDLE_LOCAL_MODEL_STATE;
if (state === 'installed') {
@@ -192,6 +229,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
cli,
runtime,
...(loaded ? { loaded } : {}),
...(library ? { library } : {}),
...(connection ? { connection } : {}),
...(updatedAt ? { updatedAt } : {}),
};
@@ -215,6 +253,7 @@ export function parseLocalModelState(raw: string): LocalModelStateFile {
...(runtime ? { runtime } : {}),
...(progress ? { progress } : {}),
...(loaded ? { loaded } : {}),
...(library ? { library } : {}),
...(connection ? { connection } : {}),
...(replacedModelId ? { replacedModelId } : {}),
...(updatedAt ? { updatedAt } : {}),
+144 -13
View File
@@ -24,7 +24,11 @@
* | `GET /api/local-model` | none | none (a failed probe is an answer) |
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | 400 not a row the server offers, or a saved endpoint |
* | | | already has the reserved id; 409 `job-running`, |
* | | | `already-installed`, `size-changed` |
* | | | `already-installed` (this model at this context is |
* | | | the one in use), `size-changed`, `sessions-open` |
* | `POST /api/local-model/use` | `LocalModelUseRequest` | 400 not a checked model of the library, or no coding |
* | | | agent for it is installed; 409 `job-running`, |
* | | | `wrong-state`, `already-installed`, `sessions-open` |
* | `POST /api/local-model/cancel` | none | 409 `wrong-state` (no job is running) |
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | 409 `job-running`, `wrong-state` (not paused or |
* | | | failed), `sessions-open` (another port while live |
@@ -33,6 +37,9 @@
* | | | `sessions-open` (stop, or another port, while live |
* | | | sessions use the model) |
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | 409 `job-running`, `sessions-open` |
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | 400 not a model of this Mac; 409 `job-running`, |
* | | | `wrong-state`, `model-active`, `runtime-not-running`; |
* | | | 500 the runtime did not delete it |
*
* A 409 is the error envelope plus `data: LocalModelConflict`:
* `{ success: false, error, errorCode: 'CONFLICT', data: { reason, message, ... } }`. A 409
@@ -141,7 +148,10 @@ export const LOCAL_MODEL_INSTALL_STEPS: readonly LocalModelInstallStep[] = [
* | `harness-failed` | the coding agent's install command failed, or its binary is still missing |
* | `endpoint-id-taken` | a saved endpoint with our id exists that this feature did not write |
* | `connect-failed` | the endpoint record or the settings flag could not be written |
* | `model-changed` | installed: the model's digest is not the one stored at install time |
* | `model-changed` | installed: the model's digest is not the one stored at install time. A |
* | | switch: the model's digest is not the one it was checked with |
* | `job-unfinished` | a job that changes the model in use is running or stopped. Only a |
* | | refused session start says it: nothing is started until the job ended |
* | `internal` | anything unexpected (the exception text is in `detail`) |
*/
export type LocalModelErrorCode =
@@ -164,6 +174,7 @@ export type LocalModelErrorCode =
| 'endpoint-id-taken'
| 'connect-failed'
| 'model-changed'
| 'job-unfinished'
| 'internal';
/**
@@ -192,12 +203,25 @@ export type LocalModelNextStep = 'retry' | 'other-port' | 'next-model' | 'start'
* - `cancel`: `POST /api/local-model/cancel`.
* - `resume`: `POST /api/local-model/resume`.
* - `start` and `stop`: `POST /api/local-model/runtime`.
* - `remove`: `DELETE /api/local-model`.
* - `remove`: `DELETE /api/local-model`. In `idle` it is listed only while the model store
* still holds something (models an earlier Remove kept): it is then what frees that space.
* - `start-session`: not a route of this feature: the window starts a session on the endpoint
* `endpointId` with the coding agent `cli`. Offered only while the runtime runs and nothing
* blocks a session.
* - `use`: `POST /api/local-model/use`. Listed only while a row of `library` offers it.
* - `delete-model`: `DELETE /api/local-model/models`. Listed only while a row of `library`
* offers it.
*/
export type LocalModelAction = 'install' | 'cancel' | 'resume' | 'start' | 'stop' | 'remove' | 'start-session';
export type LocalModelAction =
| 'install'
| 'cancel'
| 'resume'
| 'start'
| 'stop'
| 'remove'
| 'start-session'
| 'use'
| 'delete-model';
/** Whether the Mac has the developer tools (git, Python) a coding agent calls. */
export type LocalModelDeveloperTools =
@@ -225,7 +249,9 @@ export type LocalModelDeveloperTools =
* | `errHarnessFailed` | `cli`: the coding agent's label |
* | `errEndpointIdTaken` | `id`: the endpoint id this feature uses |
* | `refusedAgentNotInstallable` | `cli`: the coding agent's label |
* | `refusedOtherModel` | `model`: the label of the model that is installed |
* | `refusedOtherModel` | `model`: the label of the model that is in use |
* | `refusedAgentMissing` | `cli`: the coding agent's label |
* | `conflictModelActive`, `deleteFailed` | `model`: the model's label |
* | `conflictSizeChanged` | `size`: the server's own total as decimal GB |
* | `conflictSessionsOpen` | `sessions`: the session names, joined with ', ' |
*
@@ -259,16 +285,23 @@ export const LOCAL_MODEL_INSTALL_LABELS = {
'A saved model connection named {id} already exists and was not created here. Rename or delete it in App Settings, then try again.',
errConnectFailed: 'Codeman could not save the connection to the model.',
errModelChanged: 'The model on this Mac changed since it was installed. Remove it and install it again.',
errJobUnfinished:
'The model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.',
errInternal: 'The install stopped on an unexpected error.',
conflictJobRunning: 'An install is already running.',
conflictAlreadyInstalled: 'A model is already installed on this Mac. Remove it first.',
conflictAlreadyInstalled: 'This model is already in use on this Mac.',
conflictModelActive: '{model} is the model in use. Switch to another model first, or remove the local model.',
conflictSizeChanged: 'The download is {size} GB now. Check the sizes and confirm again.',
conflictSessionsOpen: 'Sessions are still using this model: {sessions}. Close them first.',
conflictWrongState: 'That is not possible in the current state.',
refusedModelNotOffered: 'This model at this context is not offered for this Mac right now.',
refusedAgentNotOffered: 'This coding agent is not offered for this model.',
refusedAgentNotInstallable: '{cli} is not installed and cannot be installed from here.',
refusedOtherModel: 'Only {model} is installed on this Mac. Remove it and install another model first.',
refusedOtherModel: 'Only {model} is in use on this Mac. Switch models in the local model window first.',
refusedNotInLibrary: 'This model is not one of the checked models on this Mac.',
refusedNotOnThisMac: 'This model is not on this Mac.',
deleteFailed: '{model} could not be deleted from this Mac.',
refusedAgentMissing: '{cli} is not installed on this Mac. Install this model again to choose a coding agent.',
endpointManaged:
'This endpoint belongs to the model Codeman runs on this Mac. Change or remove it in the local model window.',
endpointIdReserved: 'This endpoint id is reserved for the model Codeman runs on this Mac. Pick another id.',
@@ -307,6 +340,7 @@ export const LOCAL_MODEL_ERROR_LABELS: Record<LocalModelErrorCode, LocalModelIns
'endpoint-id-taken': LOCAL_MODEL_INSTALL_LABELS.errEndpointIdTaken,
'connect-failed': LOCAL_MODEL_INSTALL_LABELS.errConnectFailed,
'model-changed': LOCAL_MODEL_INSTALL_LABELS.errModelChanged,
'job-unfinished': LOCAL_MODEL_INSTALL_LABELS.errJobUnfinished,
internal: LOCAL_MODEL_INSTALL_LABELS.errInternal,
};
@@ -331,6 +365,7 @@ export const LOCAL_MODEL_ERROR_NEXT: Record<LocalModelErrorCode, LocalModelNextS
'endpoint-id-taken': 'retry',
'connect-failed': 'retry',
'model-changed': 'remove',
'job-unfinished': 'retry',
internal: 'retry',
};
@@ -426,7 +461,8 @@ export interface LocalModelLoadedFacts {
* | `port` | | once chosen | once chosen | once chosen | yes |
* | `loaded`, `endpointId` | | | | | yes |
*
* `running`, `developerTools`, `runtime`, `folder` and `actions` are always present.
* `running`, `developerTools`, `runtime`, `folder`, `library` and `actions` are always
* present. `store` is present once the model store was measured.
*/
export interface LocalModelInstallView {
state: LocalModelInstallState;
@@ -454,10 +490,63 @@ export interface LocalModelInstallView {
loaded?: LocalModelLoadedFacts;
/** The id of the saved endpoint a session is started on. */
endpointId?: string;
/**
* The models on this Mac: every model Codeman checked (`LocalModelStateFile.library`), plus
* whatever else the running runtime lists. Empty in `idle` (no runtime is there to ask) and
* before anything was downloaded. The model in use comes first.
*/
library: LocalModelLibraryRow[];
/** What the model store takes on disk right now. Present in every state once it was measured. */
store?: LocalModelStoreView;
/** What the server accepts right now, see `LocalModelAction`. */
actions: LocalModelAction[];
}
/**
* How a model of the library stands.
* - `ready`: checked by Codeman, and the runtime (when it was asked) still lists it with the
* digest that was stored.
* - `changed`: checked, but the runtime now lists another digest under the name. It is never
* started: `use` would fail with `model-changed`.
* - `missing`: checked, but the runtime no longer lists it (something deleted it through the
* runtime's port).
* - `unverified`: the runtime lists it and Codeman never checked it: a model an earlier
* Remove kept, the model of a job that has not passed its check yet, or one another program
* pulled through the runtime's port. Never startable.
*/
export type LocalModelLibraryStatus = 'ready' | 'changed' | 'missing' | 'unverified';
/** What can be done with one row of `library`: `POST /api/local-model/use` and `DELETE /api/local-model/models`. */
export type LocalModelLibraryAction = 'use' | 'delete';
/** One model on this Mac. */
export interface LocalModelLibraryRow {
/** The model's name exactly as the runtime lists it (`name:tag`). */
id: string;
/** Its catalogue label, or `id` for a model the catalogue does not know. */
label: string;
/** The context it was checked with. Absent for an `unverified` row. */
context?: number;
/** Its size on disk: what the runtime reports, else what was recorded when it was checked. */
sizeBytes?: number;
/** `sizeBytes` as decimal GB text, no unit. Present exactly when `sizeBytes` is. */
sizeGb?: string;
/** It is the model in use: the one of an `installed` state. */
active: boolean;
/** Codeman checked it (`LocalModelStateFile.library` holds it). */
verified: boolean;
status: LocalModelLibraryStatus;
/** What the server accepts for this row right now. `delete` needs the running runtime. */
actions: LocalModelLibraryAction[];
}
/** The disk use of the model store: the files directly in its blob folder, summed. */
export interface LocalModelStoreView {
bytes: number;
/** `bytes` as decimal GB text, no unit. */
gb: string;
}
/** The `data` of `GET /api/local-model`. Admin only in multi-user mode. */
export interface LocalModelStatus {
hardware: HardwareFacts;
@@ -511,6 +600,18 @@ export interface LocalModelRemoveRequest {
removeModel?: boolean;
}
/** Body of `POST /api/local-model/use`: a checked model of `library` to switch to. Nothing is downloaded. */
export interface LocalModelUseRequest {
/** `LocalModelLibraryRow.id` of a row whose `actions` list `use`. */
modelId: string;
}
/** Body of `DELETE /api/local-model/models`: one model to delete from the store. */
export interface LocalModelDeleteRequest {
/** `LocalModelLibraryRow.id` of a row whose `actions` list `delete`. Never a path. */
modelId: string;
}
/** The `data` of a 400 the service names: its sentence, for the window to print in its own language. */
export interface LocalModelRefusal {
message: LocalModelInstallMessage;
@@ -522,20 +623,26 @@ export type LocalModelVerbAnswer = LocalModelInstallView;
/**
* Why a verb was refused with 409.
* - `job-running`: a job is running (a second install, a resume, a remove or a runtime action).
* - `already-installed`: install while a model is installed. Remove it first.
* - `already-installed`: install or use of the model that is already the one in use (at the
* same context).
* - `size-changed`: `acknowledgedBytes` is not the server's total; `expectedBytes` and
* `expectedGb` carry that total, so the window can show it and ask again.
* - `sessions-open`: remove, stop or a change of port while live sessions use the endpoint;
* `sessions` names them. Their coding agents were given the runtime's address when they
* started, so they would go on calling a port nothing of ours holds.
* - `sessions-open`: remove, stop, a change of port, an install of another model or a switch
* while live sessions use the endpoint; `sessions` names them. Their coding agents were
* given the runtime's address and the model's name when they started, so they would go on
* calling a port nothing of ours holds, or make the runtime swap models on every request.
* - `wrong-state`: the verb makes no sense in the current state (see the route table above).
* - `model-active`: a delete of the model the installation or the current job is about.
* - `runtime-not-running`: a delete while the runtime is down: only the runtime deletes a model.
*/
export type LocalModelConflictReason =
| 'job-running'
| 'already-installed'
| 'size-changed'
| 'sessions-open'
| 'wrong-state';
| 'wrong-state'
| 'model-active'
| 'runtime-not-running';
/** The `data` of a 409 of an install verb. */
export interface LocalModelConflict {
@@ -622,6 +729,14 @@ export interface LocalModelStateFile {
progress?: { completedBytes: number; totalBytes: number };
/** What the runtime reported when the job checked the loaded model. */
loaded?: { context: number; sizeBytes: number; sizeVramBytes: number };
/**
* Every model that PASSED the check through Codeman and is believed to be in the store, the
* model in use among them. An entry is written when the `verify` step passes and dropped
* when the model is deleted; `remove` drops the whole list. An `installed` file an earlier
* build wrote has none: its one model then counts as the library (`libraryOf()` in
* src/local-model/install-job.ts).
*/
library?: LocalModelLibraryEntry[];
/**
* Present once the `connect` step CLAIMED the connection, which it does before its two
* writes: what uninstall must undo. A claim with nothing behind it (the step was interrupted
@@ -636,3 +751,19 @@ export interface LocalModelStateFile {
/** ISO time of the last write. */
updatedAt?: string;
}
/** One checked model of `LocalModelStateFile.library`. It holds no path. */
export interface LocalModelLibraryEntry {
id: string;
label: string;
/** The context length in tokens it was checked with. */
context: number;
/** The digest `/api/tags` reported when it passed the check. A switch to it must find the same one. */
digest: string;
/** Its size on disk as the runtime reported it then, for a listing while the runtime is stopped. */
bytes?: number;
/** The coding agent of the job that checked it. A switch back takes it when it is installed. */
cli?: { id: string; label: string };
/** ISO time of the check. */
verifiedAt?: string;
}
+6
View File
@@ -554,6 +554,12 @@ export interface Candidate {
installBytes: number;
/** `installBytes` as decimal GB text, no unit. */
installGb: string;
/**
* Set by the route, never by `recommend()`: this model at this context is a checked model of
* the library on this Mac (`LocalModelInstallView.library`). `active`: it is the model in
* use. `ready`: `POST /api/local-model/use` switches to it, and nothing is downloaded.
*/
library?: 'active' | 'ready';
}
/**
+1 -1
View File
@@ -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).
**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 window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and 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 figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ 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`, already adapted by the server to the state: `start` on an installed model, `retry` on a job) plus a quiet Remove; an error's text is split after its first sentence (`localModelSentences()`, which knows the ideographic full stop) and only that sentence is the headline. A refusal the server names (a 400, a full disk) carries `data.message` like a 409 and is printed from it, never from the English `error` text. ⚠️ 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 `<html lang>` 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 "RecoLine truncated
**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. ⚠️ The models on this Mac (`install.library`) are ONE list builder, `_localModelLibraryEl()`: on the installed screen always (the model in use first, in the chooser's selected look, each row with the context it was checked with, a state tag and its size on disk), on a paused or failed screen only when a row offers something (the way back to an earlier model), never on the screen that ends an install. A row has "Use" and "Delete" only for what the server lists for THAT row (`row.actions`); Use is a switch with no confirm screen (nothing is downloaded), Delete asks once in place under its row (`.lm-lib-ask`, the one red button, the focus on Cancel), and the asked row keeps its buttons unseen (`.lm-lib-actions-away`) so the sizes do not jump sideways. Under the list: what all downloads take (`install.store`) and "Add a model", which opens the chooser from an installed model; there a row with `candidate.library` says "In use" or "On this Mac" and the main button becomes a disabled "In use" or "Use this model". With nothing installed and a store an earlier Remove kept, the chooser and the nothing-fits screen say what it takes and offer "Delete them" (`_localModelStoreEl()`). ⚠️ A screen that carries the list sets `data-long` on the body, which switches it from the centred short-screen layout (bottom-aligned on a phone) to top-aligned: a centred flex column that overflows cuts off its first lines. ⚠️ The row buttons live in `.lm-lib-actions`, not `.lm-buttons` (the screen's own buttons in the foot), and are a size smaller (`.lm-btn-row`). ⚠️ A job whose steps hold no `model` step is a switch (`localModelIsSwitch()`) and is named one ("Switching to", no download bar). 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 window is ONE frame for the whole flow: a fixed height on desktop (`.modal-content.local-model-window`), App Settings' boxed close button, the body scrolling between the header and a foot of its own (`#localModelWindowFoot`): every screen's last part (`.lm-foot`, or the `.lm-remove` question) is moved there by `_renderLocalModelWindow()`, so a button never moves away from under the pointer and is never clipped; a test that looks for a button looks in the frame, not the body. The short screens are centred in that frame on desktop and sit above the buttons on a phone (`data-screen` on the body). ⚠️ The memory bar (`.lm-bar`, `role="meter"`) is the one strong element: filled to the selected row's `budgetShare`, the model at its PEAK (the legend says so, and the installed line uses the same words beside what the model takes now, so the two figures are never read as a contradiction), the memory map (`Candidate.memory`: the model, its context and the brief peaks side by side, each named in the legend, on a track of `--text-muted` at about a third) in the feature's own colours (`--lm-*`: white for the feature, turned over to dark ink on the light skins, blue the context, green good, amber tight, orange slow, rose the brief peaks, deeper on the light skins), never `--accent`, which the owner rejected as too much blue (about 3:1 against the window, so the free part can be seen; a border token is 1.5:1 and 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 figure beside it is an estimate and says "about" in whole GB (`localModelWholeGb()` on the server's text); only the download figure keeps tabular figures. ⚠️ The rows are the server's pick first, then the other models by download size, largest first (`localModelCandidates()`): the server's alternatives are its ranking, which no column shows. ⚠️ The download bar means ONE thing, the model download: at the start while the runtime is fetched, gone once the model is in (the step list carries verify, agent and connect); the first paint after an install finished shows "Start a session" alone (`justInstalled`), an error on an installed model is the headline with a grey dot, and Remove is quiet everywhere but red in its own question, never the accent. ⚠️ 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 reLine truncated
**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)
+42 -3
View File
@@ -1434,6 +1434,36 @@
'Installing {model}': '正在安装 {model}',
'The install of {model} is paused': '{model} 的安装已暂停',
'The install of {model} stopped': '{model} 的安装已中止',
// A switch to a model that is on this Mac already: nothing is downloaded.
'Switching to {model}': '正在切换到 {model}',
'The switch to {model} is paused': '切换到 {model} 的操作已暂停',
'The switch to {model} stopped': '切换到 {model} 的操作已中止',
'The switch waits until you continue.': '切换会等你点击继续后再进行。',
'You can close this window. The switch continues while Codeman is running.':
'可以关闭此窗口。只要 {name} 在运行,切换就会继续。',
// The models on this Mac: the list of the installed screen, its states and its two actions.
'Models on this Mac': '此 Mac 上的模型',
'On disk': '占用磁盘',
'{context} tokens': '{context} 个令牌',
'In use': '使用中',
Ready: '就绪',
Changed: '已变化',
Missing: '已缺失',
'Not checked': '未检查',
'On this Mac': '已在此 Mac 上',
Use: '使用',
'Use {model}': '使用 {model}',
'Use this model': '使用此模型',
'Delete {model}': '删除 {model}',
'Delete {model} from this Mac?': '要从此 Mac 删除 {model} 吗?',
'This frees {size} GB.': '这会释放 {size} GB。',
'Downloaded models use {size} GB on this Mac.': '已下载的模型在此 Mac 上占用 {size} GB。',
'Press Start to delete a single model.': '点击“启动”后才能删除单个模型。',
'Add a model': '添加模型',
'Delete the downloaded models from this Mac?': '要从此 Mac 删除已下载的模型吗?',
'Models from an earlier install still use {size} GB on this Mac.':
'之前安装留下的模型仍在此 Mac 上占用 {size} GB。',
'Delete them': '删除它们',
'{model} is running': '{model} 正在运行',
'{model} is installed and stopped': '{model} 已安装,当前已停止',
'Checking this Mac': '正在检查此 Mac',
@@ -1578,9 +1608,13 @@
'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 model on this Mac is being changed and is not ready. Finish or undo that in the local model window first.':
'此 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 上已安装了一个模型。请先移除它。',
'This model is already in use on this Mac.': '此 Mac 已在使用这个模型。',
'{model} is the model in use. Switch to another model first, or remove the local model.':
'{model} 是正在使用的模型。请先切换到其他模型,或移除本地模型。',
'The download is {size} GB now. Check the sizes and confirm again.':
'现在的下载大小为 {size} GB。请核对大小后再次确认。',
'Sessions are still using this model: {sessions}. Close them first.':
@@ -1590,8 +1624,13 @@
'目前不为此 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}。请先移除它,再安装其他模型。',
'Only {model} is in use on this Mac. Switch models in the local model window first.':
'此 Mac 目前只使用 {model}。请先在本地模型窗口中切换模型。',
'This model is not one of the checked models on this Mac.': '该模型不在此 Mac 上已检查的模型之列。',
'This model is not on this Mac.': '此 Mac 上没有这个模型。',
'{cli} is not installed on this Mac. Install this model again to choose a coding agent.':
'此 Mac 上没有安装 {cli}。请重新安装此模型,以便选择编程智能体。',
'{model} could not be deleted from this Mac.': '无法从此 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 上运行的模型。请在本地模型窗口中更改或移除它。',
+357 -18
View File
@@ -7,10 +7,12 @@
* drives the install verbs:
*
* POST /api/local-model/install { modelId, cli, context, acknowledgedBytes }
* POST /api/local-model/use { modelId }
* POST /api/local-model/cancel
* POST /api/local-model/resume { newPort? }
* POST /api/local-model/runtime { action: 'start' | 'stop', newPort? }
* DELETE /api/local-model { removeModel }
* DELETE /api/local-model/models { modelId }
*
* ## Three surfaces, one window
*
@@ -25,14 +27,17 @@
* | Screen | When |
* |-------------|-------------------------------------------------------------------|
* | `choose` | idle: the memory bar, the models that fit as a radio group, the |
* | | coding agent, "Install on this Mac" |
* | | coding agent, "Install on this Mac". Also from an installed |
* | | model ("Add a model"): a row that is on this Mac already says so |
* | | and is switched to without a download |
* | `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) |
* | `installed` | installed: running or stopped, the models on this Mac with their |
* | | size on disk (Use, Delete), Add a model, 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
@@ -49,6 +54,13 @@
* 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.
*
* The models on this Mac (`install.library`) are a list of their own, on the installed screen
* and on a paused or failed one that has a model to go back to: the model in use first, each
* with its size on disk and the two things a row can offer, "Use" (a switch, nothing is
* downloaded) and "Delete" (asked once, in the row). Under it the disk use of all downloaded
* models (`install.store`) and "Add a model". A row has a button only for an action the server
* lists for THAT row (`row.actions`).
*
* ## Polling
*
* The window asks the GET once a second WHILE IT IS OPEN, and never otherwise (there is no SSE
@@ -100,6 +112,9 @@ const LOCAL_MODEL_MAX_AGE_MS = 60000;
/** How often the open window asks where the install stands. */
const LOCAL_MODEL_POLL_MS = 1000;
/** What `confirmDelete` holds while the question is about everything an earlier Remove kept. No model id has a space. */
const LOCAL_MODEL_STORE_KEY = 'the store';
/** 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'];
@@ -202,6 +217,29 @@ function localModelCandidates(status) {
);
}
/**
* Whether the job of `install` is a switch to a model that is on this Mac already: its steps
* hold no download. The steps are the server's; nothing is compared with a model or an agent.
*/
function localModelIsSwitch(install) {
const steps = Array.isArray(install.steps) ? install.steps : [];
return steps.length > 0 && !steps.includes('model');
}
/** The rows of `install.library`: the models on this Mac. A payload without the list has none. */
function localModelLibrary(install) {
return (Array.isArray(install.library) ? install.library : []).filter((row) => row && typeof row.id === 'string');
}
/** What a library row's state is called, and the tag class that colours it. */
function localModelLibraryTag(row) {
if (row.active === true) return { text: localModelT('In use'), kind: 'inuse' };
if (row.status === 'ready') return { text: localModelT('Ready'), kind: 'ready' };
if (row.status === 'changed') return { text: localModelT('Changed'), kind: 'changed' };
if (row.status === 'missing') return { text: localModelT('Missing'), kind: 'missing' };
return { text: localModelT('Not checked'), kind: 'unverified' };
}
/**
* The coding agents a row can run with. A payload without the list means the one agent of the
* row's flat fields.
@@ -516,6 +554,11 @@ Object.assign(CodemanApp.prototype, {
/** The Remove question is open, and whether the downloaded model goes too. */
confirmRemove: false,
removeModel: true,
/**
* The Delete question of one row is open: that row's model id, or `LOCAL_MODEL_STORE_KEY`
* for the downloaded models an earlier Remove kept.
*/
confirmDelete: null,
/**
* 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.
@@ -543,9 +586,10 @@ Object.assign(CodemanApp.prototype, {
const install = localModelInstall(this._localModelStatus);
const selection = this._localModelSelection();
if (install.state === 'installing') return 'progress';
if (install.state === 'installed') return 'installed';
const canInstall = install.actions.includes('install') && !!selection;
// The chooser is also where a second model is added to an installed one.
if (canInstall && (ui.view === 'choose' || ui.view === 'confirm')) return ui.view;
if (install.state === 'installed') return 'installed';
if (install.state === 'paused') return 'paused';
if (install.state === 'failed') return 'failed';
return selection ? 'choose' : 'none';
@@ -572,6 +616,7 @@ Object.assign(CodemanApp.prototype, {
notice: null,
expected: null,
confirmRemove: false,
confirmDelete: null,
justInstalled: false,
});
this._forgetLocalModelPaint();
@@ -690,10 +735,10 @@ Object.assign(CodemanApp.prototype, {
this.renderLocalModel();
},
/** Back to the chooser (from the confirm screen, or to pick another model after a stop). */
/** Back to the chooser (from the confirm screen, to pick another model after a stop, or to add one). */
chooseLocalModel(modelId) {
const ui = this._localModelWindowState();
Object.assign(ui, { view: 'choose', notice: null, expected: null, confirmRemove: false });
Object.assign(ui, { view: 'choose', notice: null, expected: null, confirmRemove: false, confirmDelete: null });
if (typeof modelId === 'string') {
ui.modelId = modelId;
ui.cli = null;
@@ -749,6 +794,57 @@ Object.assign(CodemanApp.prototype, {
this.renderLocalModel();
},
/**
* "Use": switch to a model that is on this Mac already. Nothing is downloaded, so there is
* no confirm screen: the server loads and checks the model again and connects it.
*/
async useLocalModel(modelId) {
const ui = this._localModelWindowState();
ui.confirmDelete = null;
const result = await this._localModelVerb('POST', '/api/local-model/use', { modelId });
// Started, or refused with the server's sentence: either way the state's own screen says it.
if (result.ok || result.conflict) ui.view = 'auto';
this.renderLocalModel();
},
/** "Delete" of one row: first the question in the row, then (with `confirmed`) the delete. */
async deleteLocalModelEntry(modelId, { confirmed = false } = {}) {
const ui = this._localModelWindowState();
if (!confirmed) {
Object.assign(ui, { confirmDelete: modelId, confirmRemove: false, notice: null });
this.renderLocalModel();
// On the safe answer, whatever had the focus (a click does not move it in every browser).
this._focusLocalModelWindow('delete-no');
return;
}
await this._localModelVerb('DELETE', '/api/local-model/models', { modelId });
ui.confirmDelete = null;
this.renderLocalModel();
},
/** "Delete them": the downloaded models an earlier Remove kept, while nothing is installed. */
async deleteLocalModelStore({ confirmed = false } = {}) {
const ui = this._localModelWindowState();
if (!confirmed) {
Object.assign(ui, { confirmDelete: LOCAL_MODEL_STORE_KEY, notice: null });
this.renderLocalModel();
this._focusLocalModelWindow('delete-no');
return;
}
await this._localModelVerb('DELETE', '/api/local-model', { removeModel: true });
ui.confirmDelete = null;
this.renderLocalModel();
},
/** Leave a Delete question without deleting. The focus goes back to the button that asked. */
keepLocalModelEntry() {
const ui = this._localModelWindowState();
const asked = ui.confirmDelete;
Object.assign(ui, { confirmDelete: null, notice: null });
this.renderLocalModel();
if (asked) this._focusLocalModelWindow(asked === LOCAL_MODEL_STORE_KEY ? 'store-delete' : `delete:${asked}`);
},
cancelLocalModelInstall() {
return this._localModelVerb('POST', '/api/local-model/cancel');
},
@@ -767,7 +863,7 @@ Object.assign(CodemanApp.prototype, {
async removeLocalModel({ confirmed = false } = {}) {
const ui = this._localModelWindowState();
if (!confirmed) {
Object.assign(ui, { confirmRemove: true, notice: null });
Object.assign(ui, { confirmRemove: true, confirmDelete: null, notice: null });
this.renderLocalModel();
return;
}
@@ -829,9 +925,20 @@ Object.assign(CodemanApp.prototype, {
const status = this._localModelStatus;
const install = localModelInstall(status);
const model = install.model ? String(install.model.label || install.model.id || '') : '';
if (install.state === 'installing') return localModelT('Installing {model}', { model });
if (install.state === 'paused') return localModelT('The install of {model} is paused', { model });
if (install.state === 'failed') return localModelT('The install of {model} stopped', { model });
const switching = localModelIsSwitch(install);
if (install.state === 'installing') {
return switching ? localModelT('Switching to {model}', { model }) : localModelT('Installing {model}', { model });
}
if (install.state === 'paused') {
return switching
? localModelT('The switch to {model} is paused', { model })
: localModelT('The install of {model} is paused', { model });
}
if (install.state === 'failed') {
return switching
? localModelT('The switch to {model} stopped', { model })
: localModelT('The install of {model} stopped', { model });
}
if (install.state === 'installed') {
if (install.running) return localModelT('{model} is running', { model });
return localModelT('{model} is installed and stopped', { model });
@@ -917,6 +1024,8 @@ Object.assign(CodemanApp.prototype, {
foot && tail && tail.classList && (tail.classList.contains('lm-foot') || tail.classList.contains('lm-remove'));
body.dataset.screen = screen;
body.replaceChildren(...(actions ? parts.slice(0, -1) : parts));
// A screen that carries the list of models starts at the top and scrolls (see styles.css).
body.dataset.long = body.querySelector('.lm-library') ? 'true' : 'false';
if (foot) foot.replaceChildren(...(actions ? [tail] : []));
body.scrollTop = scrolled;
this._settleLocalModelBars(body);
@@ -1031,6 +1140,12 @@ Object.assign(CodemanApp.prototype, {
tag.classList.add(`lm-tag-${candidate.tag.kind}`);
tagSlot.appendChild(tag);
}
// Already on this Mac: choosing it switches to it, and nothing is downloaded.
if (candidate.library === 'active') {
tagSlot.appendChild(localModelEl('span', 'lm-tag lm-tag-inuse', localModelT('In use')));
} else if (candidate.library === 'ready') {
tagSlot.appendChild(localModelEl('span', 'lm-tag lm-tag-ready', localModelT('On this Mac')));
}
row.appendChild(tagSlot);
const speed = localModelEl('span', 'lm-model-speed', localModelSpeedText(candidate));
const gauge = localModelEl(
@@ -1153,6 +1268,10 @@ Object.assign(CodemanApp.prototype, {
if (hints.length) runs.appendChild(localModelEl('span', 'lm-runs-hint', hints.join(' ')));
parts.push(runs);
// Nothing is installed, and an earlier Remove kept its downloads: what they take, and the way to free it.
const leftovers = this._localModelStoreEl(install);
if (leftovers) parts.push(leftovers);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
@@ -1160,13 +1279,25 @@ Object.assign(CodemanApp.prototype, {
const promise = localModelT('No account and no API key. The model runs only on this Mac.');
foot.appendChild(localModelEl('p', 'lm-foot-note', promise));
const buttons = localModelEl('div', 'lm-buttons');
// Reached from a paused or stopped install: the way back to it.
// Reached from a paused or stopped install, or from an installed model: the way back to it.
if (install.state !== 'idle') {
buttons.appendChild(
localModelButton(localModelT('Back'), { key: 'back', onClick: () => this.leaveLocalModelChooser() })
);
}
if (install.actions.includes('install')) {
if (candidate.library === 'active') {
// The model that runs now: nothing to do with it here.
buttons.appendChild(localModelButton(localModelT('In use'), { key: 'primary', primary: true, disabled: true }));
} else if (candidate.library === 'ready' && install.actions.includes('use')) {
buttons.appendChild(
localModelButton(localModelT('Use this model'), {
key: 'primary',
primary: true,
disabled: ui.busy,
onClick: () => void this.useLocalModel(candidate.modelId),
})
);
} else if (install.actions.includes('install')) {
buttons.appendChild(
localModelButton(localModelT('Install on this Mac'), {
key: 'primary',
@@ -1181,6 +1312,189 @@ Object.assign(CodemanApp.prototype, {
return parts;
},
/**
* The models on this Mac: one row per model of `install.library` with its name, the context
* it was checked with, its state, its size on disk and what the server offers for it. A row
* whose Delete was pressed is followed by the question, once, in place. `null` when there is
* no row, or (with `onlyWithActions`, for a paused or failed job) no row offers anything.
*/
_localModelLibraryEl(install, { onlyWithActions = false } = {}) {
const ui = this._localModelWindowState();
const rows = localModelLibrary(install);
const offers = (row, action) => Array.isArray(row.actions) && row.actions.includes(action);
if (!rows.length) return null;
if (onlyWithActions && !rows.some((row) => offers(row, 'use') || offers(row, 'delete'))) return null;
const section = localModelEl('div', 'lm-library');
const list = localModelEl('div', 'lm-lib-list');
list.setAttribute('role', 'list');
list.setAttribute('aria-labelledby', 'localModelLibraryLabel');
const caption = localModelEl('div', 'lm-lib-caption');
const label = localModelEl('span', 'lm-lib-caption-label', localModelT('Models on this Mac'));
label.id = 'localModelLibraryLabel';
caption.appendChild(label);
caption.appendChild(localModelEl('span', 'lm-lib-caption-size', localModelT('On disk')));
list.appendChild(caption);
for (const row of rows) {
const name = String(row.label || row.id);
const item = localModelEl('div', row.active === true ? 'lm-lib-row lm-lib-row-on' : 'lm-lib-row');
item.setAttribute('role', 'listitem');
const what = localModelEl('div', 'lm-lib-name');
what.appendChild(localModelEl('span', 'lm-lib-label', name));
if (Number.isFinite(row.context)) {
what.appendChild(
localModelEl(
'span',
'lm-lib-meta',
localModelT('{context} tokens', { context: localModelCount(row.context) })
)
);
}
item.appendChild(what);
const tag = localModelLibraryTag(row);
const tagSlot = localModelEl('span', 'lm-lib-tag');
tagSlot.appendChild(localModelEl('span', `lm-tag lm-tag-${tag.kind}`, tag.text));
item.appendChild(tagSlot);
item.appendChild(localModelEl('span', 'lm-lib-size', row.sizeGb ? localModelGb(row.sizeGb) : ''));
const asking = ui.confirmDelete === row.id && offers(row, 'delete');
const actions = localModelEl('span', 'lm-lib-actions');
// While the row's question is open its buttons keep their place and are out of reach,
// so the column of sizes does not move when the question opens.
if (asking) {
actions.classList.add('lm-lib-actions-away');
actions.setAttribute('aria-hidden', 'true');
}
if (offers(row, 'use')) {
const use = localModelButton(localModelT('Use'), {
key: `use:${row.id}`,
disabled: ui.busy || asking,
onClick: () => void this.useLocalModel(row.id),
});
use.classList.add('lm-btn-row');
use.setAttribute('aria-label', localModelT('Use {model}', { model: name }));
actions.appendChild(use);
}
if (offers(row, 'delete')) {
const remove = localModelButton(localModelT('Delete'), {
key: `delete:${row.id}`,
disabled: ui.busy || asking,
onClick: () => void this.deleteLocalModelEntry(row.id),
});
remove.classList.add('lm-btn-row');
remove.setAttribute('aria-label', localModelT('Delete {model}', { model: name }));
actions.appendChild(remove);
}
item.appendChild(actions);
list.appendChild(item);
if (asking) {
list.appendChild(
this._localModelAskEl({
question: localModelT('Delete {model} from this Mac?', { model: name }),
detail: row.sizeGb ? localModelT('This frees {size} GB.', { size: row.sizeGb }) : '',
onYes: () => void this.deleteLocalModelEntry(row.id, { confirmed: true }),
})
);
}
}
section.appendChild(list);
const foot = localModelEl('div', 'lm-lib-foot');
const notes = localModelEl('div', 'lm-lib-notes');
const store = install.store;
if (store && Number(store.bytes) > 0 && store.gb) {
notes.appendChild(
localModelEl('p', 'lm-aside', localModelT('Downloaded models use {size} GB on this Mac.', { size: store.gb }))
);
}
// Only the runtime deletes a model: with it stopped, the rows offer none, and this says why.
if (install.state === 'installed' && install.running !== true && rows.length > 1) {
notes.appendChild(localModelEl('p', 'lm-aside', localModelT('Press Start to delete a single model.')));
}
if (notes.childElementCount) foot.appendChild(notes);
if (install.state === 'installed' && install.actions.includes('install') && this._localModelSelection()) {
const add = localModelButton(localModelT('Add a model'), {
key: 'add',
disabled: ui.busy,
onClick: () => this.chooseLocalModel(),
});
add.classList.add('lm-btn-row');
foot.appendChild(add);
}
if (foot.childElementCount) section.appendChild(foot);
return section;
},
/** A Delete question in place: the question, what it frees, Cancel and the red Delete. */
_localModelAskEl({ question, detail, onYes }) {
const ui = this._localModelWindowState();
const ask = localModelEl('div', 'lm-lib-ask');
ask.setAttribute('role', 'group');
const text = localModelEl('div', 'lm-lib-ask-text');
text.appendChild(localModelEl('p', 'lm-lead', question));
if (detail) text.appendChild(localModelEl('p', 'lm-aside', detail));
ask.appendChild(text);
const buttons = localModelEl('div', 'lm-lib-actions');
const no = localModelButton(localModelT('Cancel'), {
key: 'delete-no',
disabled: ui.busy,
onClick: () => this.keepLocalModelEntry(),
});
no.classList.add('lm-btn-row');
const yes = localModelButton(localModelT('Delete'), {
key: 'delete-yes',
danger: true,
disabled: ui.busy,
onClick: onYes,
});
yes.classList.add('lm-btn-row');
buttons.appendChild(no);
buttons.appendChild(yes);
ask.appendChild(buttons);
return ask;
},
/**
* Nothing is installed and the model store still holds downloads (an earlier Remove kept
* them): what they take on disk and "Delete them", asked once in place. `null` unless the
* server offers that removal.
*/
_localModelStoreEl(install) {
const ui = this._localModelWindowState();
const store = install.store;
if (install.state !== 'idle' || !install.actions.includes('remove')) return null;
if (!store || !(Number(store.bytes) > 0) || !store.gb) return null;
if (ui.confirmDelete === LOCAL_MODEL_STORE_KEY) {
const box = localModelEl('div', 'lm-leftovers');
box.appendChild(
this._localModelAskEl({
question: localModelT('Delete the downloaded models from this Mac?'),
detail: localModelT('This frees {size} GB.', { size: store.gb }),
onYes: () => void this.deleteLocalModelStore({ confirmed: true }),
})
);
return box;
}
const box = localModelEl('div', 'lm-leftovers lm-lib-foot');
box.appendChild(
localModelEl(
'p',
'lm-aside',
localModelT('Models from an earlier install still use {size} GB on this Mac.', { size: store.gb })
)
);
const remove = localModelButton(localModelT('Delete them'), {
key: 'store-delete',
disabled: ui.busy,
onClick: () => void this.deleteLocalModelStore(),
});
remove.classList.add('lm-btn-row');
box.appendChild(remove);
return box;
},
/** Arrow keys, Home and End move the selection of the radio group, and the focus with it. */
_onLocalModelListKey(event) {
const selection = this._localModelSelection();
@@ -1397,9 +1711,18 @@ Object.assign(CodemanApp.prototype, {
const paused = screen === 'paused';
const model = install.model ? String(install.model.label || install.model.id || '') : '';
const parts = [];
const title = paused
? localModelT('The install of {model} is paused', { model })
: localModelT('Installing {model}', { model });
// A switch to a model that is on this Mac already downloads nothing, and says so in its name.
const switching = localModelIsSwitch(install);
let title;
if (switching) {
title = paused
? localModelT('The switch to {model} is paused', { model })
: localModelT('Switching to {model}', { model });
} else {
title = paused
? localModelT('The install of {model} is paused', { model })
: localModelT('Installing {model}', { model });
}
parts.push(localModelEl('p', 'lm-title', title));
// The download bar takes the place of the memory bar, for as long as something downloads.
@@ -1414,19 +1737,26 @@ Object.assign(CodemanApp.prototype, {
return parts;
}
// What waits or goes on is the download while there is one, the install after it.
// What waits or goes on is the download while there is one, the install (or the switch) after it.
const downloading = !!meter;
let note;
if (paused) {
if (paused && switching) {
note = localModelT('The switch waits until you continue.');
} else if (paused) {
note = downloading
? localModelT('The download waits until you continue.')
: localModelT('The install waits until you continue.');
} else if (switching) {
note = localModelT('You can close this window. The switch continues while Codeman is running.');
} 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));
// A paused job that has a checked model beside it: the way back to that model.
const library = paused ? this._localModelLibraryEl(install, { onlyWithActions: true }) : null;
if (library) parts.push(library);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
@@ -1548,6 +1878,9 @@ Object.assign(CodemanApp.prototype, {
parts.push(this._localModelRemoveEl());
return parts;
}
// A checked model beside the one that stopped: the way back to it.
const library = this._localModelLibraryEl(install, { onlyWithActions: true });
if (library) parts.push(library);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
@@ -1641,13 +1974,17 @@ Object.assign(CodemanApp.prototype, {
const tools = this._localModelDeveloperToolsEl();
if (tools) parts.push(tools);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
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;
// Everything that is on this Mac, what it takes on disk, and the way to another model.
// Not on the screen that ends an install: that one says one thing.
const library = ending ? null : this._localModelLibraryEl(install);
if (library) parts.push(library);
if (notice) parts.push(notice);
const foot = localModelEl('div', 'lm-foot');
const buttons = localModelEl('div', 'lm-buttons');
if (!ending && can('remove') && error?.next !== 'remove') {
@@ -1703,6 +2040,8 @@ Object.assign(CodemanApp.prototype, {
if (hw) parts.push(hw);
const reason = localModelMessage(status.recommendation.headline);
if (reason) parts.push(localModelEl('p', 'lm-reason', reason));
const leftovers = this._localModelStoreEl(localModelInstall(status));
if (leftovers) parts.push(leftovers);
const notice = this._localModelNoticeEl();
if (notice) parts.push(notice);
const foot = localModelEl('div', 'lm-foot');
+60
View File
@@ -1887,6 +1887,11 @@ html.mobile-init .file-browser-panel {
justify-content: flex-end;
}
/* A screen with the list of models scrolls from its top. */
.local-model-window > .lm-body[data-long='true'] {
justify-content: flex-start;
}
/* Ralph wizard responsive */
.modal-wizard {
display: flex;
@@ -4339,6 +4344,61 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
grid-column: 3;
}
/* The models on this Mac: the name and its size, then the state and what can be done. */
.lm-lib-list {
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 0.7rem;
}
.lm-lib-caption-label {
grid-column: 1;
}
.lm-lib-caption-size {
grid-column: 2;
}
.lm-lib-row {
row-gap: 0.15rem;
padding-top: 0.45rem;
padding-bottom: 0.45rem;
}
.lm-lib-name {
grid-row: 1;
grid-column: 1;
padding: 0;
}
.lm-lib-size {
grid-row: 1;
grid-column: 2;
}
.lm-lib-tag {
grid-row: 2;
grid-column: 1;
}
.lm-lib-actions {
grid-row: 2;
grid-column: 2;
}
.lm-lib-ask {
margin-inline: 0.7rem;
}
.lm-lib-foot {
flex-wrap: wrap;
}
/* A row's buttons keep their own size: they sit in the row, not under the thumb. */
.local-model-window .wt-btn.lm-btn.lm-btn-row {
min-height: 36px;
font-size: 0.8rem;
}
.lm-foot {
flex-direction: column;
align-items: stretch;
+206
View File
@@ -22535,6 +22535,13 @@ html[data-skin='rose-pine-dawn'] {
padding-bottom: 2.2rem;
}
/* A screen that carries the list of models is no short screen: it starts at the
top and scrolls, or its first lines would be cut off above the frame. */
.local-model-window > .lm-body[data-long='true'] {
justify-content: flex-start;
padding-bottom: 1.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 {
@@ -22876,7 +22883,9 @@ html[data-skin='rose-pine-dawn'] {
.lm-model-tag {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 0.25rem;
}
/* A tag says one thing about a row, in the colour of what it says. */
@@ -22912,6 +22921,29 @@ html[data-skin='rose-pine-dawn'] {
color: var(--lm-orange);
}
/* The states of a model that is on this Mac: in use takes the feature's own
colour, ready is good, a changed or missing one is a warning. A model that
was never checked keeps the quiet tag. */
.lm-tag-inuse {
background: color-mix(in srgb, var(--lm-brand) 20%, transparent);
color: var(--lm-brand);
}
.lm-tag-ready {
background: color-mix(in srgb, var(--lm-good) 15%, transparent);
color: var(--lm-good);
}
.lm-tag-changed {
background: color-mix(in srgb, var(--lm-amber) 15%, transparent);
color: var(--lm-amber);
}
.lm-tag-missing {
background: color-mix(in srgb, var(--lm-orange) 15%, transparent);
color: var(--lm-orange);
}
.lm-model-speed,
.lm-model-size {
text-align: end;
@@ -23286,6 +23318,176 @@ html[data-skin='rose-pine-dawn'] {
accent-color: var(--lm-brand);
}
/* ── the models on this Mac ───────────────────────────────────────── */
/* Name with its context, state, size on disk, and what can be done with the
row. One grid like the chooser's, so the sizes line up down the list. The
model in use wears the chooser's selected look: it is the one that runs. */
.lm-library {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.lm-lib-list {
display: grid;
grid-template-columns: minmax(0, max-content) minmax(0, 1fr) auto auto;
column-gap: 0.75rem;
row-gap: 0.1rem;
margin-inline: calc(-0.7rem - 2px) -0.7rem;
}
.lm-lib-caption,
.lm-lib-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
padding: 0 0.7rem;
border-inline-start: 2px solid transparent;
}
.lm-lib-caption {
padding-bottom: 0.3rem;
font-size: 0.7rem;
color: var(--text-dim);
}
.lm-lib-caption-label {
grid-column: 1 / span 2;
}
.lm-lib-caption-size {
grid-column: 3;
text-align: end;
}
.lm-lib-row {
min-height: 2.9rem;
border-radius: 9px;
}
.lm-lib-row-on {
border-inline-start-color: var(--lm-brand);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--lm-brand) 17%, transparent),
color-mix(in srgb, var(--lm-brand) 4%, transparent)
);
}
.lm-lib-name {
display: flex;
flex-direction: column;
min-width: 0;
padding: 0.35rem 0;
}
.lm-lib-label {
font-size: 0.86rem;
font-weight: 600;
line-height: 1.35;
color: var(--text);
overflow-wrap: anywhere;
}
.lm-lib-meta {
font-size: 0.7rem;
line-height: 1.4;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.lm-lib-tag {
display: flex;
justify-content: flex-start;
}
.lm-lib-size {
text-align: end;
white-space: nowrap;
font-size: 0.8rem;
font-weight: 560;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.lm-lib-actions {
display: flex;
flex-shrink: 0;
justify-content: flex-end;
gap: 0.35rem;
}
/* While a row's Delete question is open its buttons keep their room, unseen:
the sizes above and below must not jump sideways. */
.lm-lib-actions-away {
visibility: hidden;
}
/* The buttons of a row are a size smaller than the buttons of the screen. */
.local-model-window .wt-btn.lm-btn.lm-btn-row {
padding: 0.28rem 0.65rem;
font-size: 0.74rem;
}
/* The Delete question of one row, in place under it: the row is what it is about. */
.lm-lib-ask {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
margin: 0.1rem 0.7rem 0.4rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 9px;
}
.lm-lib-ask-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
/* Under the list: what all downloads take on disk, and the way to another model. */
.lm-lib-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
}
.lm-lib-notes {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
/* Nothing installed, and an earlier Remove kept its downloads. */
.lm-leftovers .lm-lib-ask {
margin-inline: 0;
}
/* A browser without subgrid: fixed tracks keep the columns in line. */
@supports not (grid-template-columns: subgrid) {
.lm-lib-list {
display: block;
}
.lm-lib-caption,
.lm-lib-row {
grid-template-columns: minmax(0, 12rem) minmax(0, 1fr) 4.5rem minmax(0, auto);
column-gap: 0.75rem;
}
.lm-lib-row {
margin-top: 0.1rem;
}
}
/* ── the foot: one promise on the left, the action on the right ──── */
.lm-foot {
display: flex;
@@ -23361,6 +23563,10 @@ html[data-skin='rose-pine-dawn'] {
min-height: 2.9rem;
}
.local-model-window .wt-btn.lm-btn.lm-btn-row {
min-height: 36px;
}
.lm-select {
min-height: 36px;
}
+39 -8
View File
@@ -6,15 +6,19 @@
* |----------------------------------|-----------------------------|------------------------------------------------|
* | `GET /api/local-model` | none | `{ hardware, recommendation, catalogUpdated, install }` |
* | `POST /api/local-model/install` | `LocalModelInstallRequest` | the install view; 400, 409 |
* | `POST /api/local-model/use` | `LocalModelUseRequest` | the install view; 400, 409 |
* | `POST /api/local-model/cancel` | none | the install view; 409 |
* | `POST /api/local-model/resume` | `LocalModelResumeRequest` | the install view; 409 |
* | `POST /api/local-model/runtime` | `LocalModelRuntimeRequest` | the install view; 409 |
* | `DELETE /api/local-model` | `LocalModelRemoveRequest` | the install view; 409 |
* | `DELETE /api/local-model/models` | `LocalModelDeleteRequest` | the install view; 400, 409, 500 |
*
* The GET is built from the hardware probe, the catalogue, the harness facts and the pure
* `recommend()`, plus the `install` object of the install service
* (`LocalModelService.view()`). Nothing is reformatted here: every size text and every
* sentence in the payload was chosen by the modules that produced it. There is no SSE event:
* (`LocalModelService.view()`). The one thing joined here: a row of the recommendation that is
* a checked model of the library is marked (`Candidate.library`, by `markLibraryCandidates()`).
* Nothing is reformatted: every size text and every sentence in the payload was chosen by the
* modules that produced it. There is no SSE event:
* the window polls the GET once a second while it is open.
*
* EXPERIMENTAL (docs/versioning-policy.md, "Experimental local model routes"): the routes and
@@ -45,9 +49,12 @@
* or of the endpoint store that was refused (a file that does not parse) is a 409 without
* `data`, with the fix.
* - The verbs do the work in the install service; this file only gates, parses and maps.
* `DELETE`, `runtime` and `resume` look at the live sessions: they hand their names to the
* service, which refuses a removal, a stop and a change of port while one of them still
* uses the model.
* `DELETE /api/local-model`, `runtime`, `resume`, `install` and `use` look at the live
* sessions: they hand their names to the service, which refuses a removal, a stop, a change
* of port, another model and a switch while one of them still uses the model.
* - `DELETE /api/local-model/models` takes a model NAME in its body, never a path: the schema
* admits only `name:tag`, and the service deletes only a name of its own listing, through
* the runtime.
* - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and
* does not need a Mac: it passes its own hardware, harness facts and service.
*/
@@ -83,16 +90,19 @@ import { buildHarnessFacts } from '../../local-model/harness-facts.js';
import {
formatInstallMessage,
getLocalModelService,
markLibraryCandidates,
type LocalModelActor,
type LocalModelService,
type LocalModelVerbResult,
} from '../../local-model/install-job.js';
import { recommend } from '../../local-model/recommend.js';
import {
LocalModelDeleteSchema,
LocalModelInstallSchema,
LocalModelRemoveSchema,
LocalModelResumeSchema,
LocalModelRuntimeSchema,
LocalModelUseSchema,
} from '../schemas.js';
import { SettingsWriteRefusedError, settingsWriteErrorResponse } from '../settings-writer.js';
@@ -156,7 +166,7 @@ export interface LocalModelConflictResponse {
data: LocalModelConflict;
}
/** The 400 (or 507) of a verb the service refused with a sentence: the error envelope plus that sentence as `data`. */
/** The 400 (or 500, or 507) of a verb the service refused with a sentence: the error envelope plus that sentence as `data`. */
export interface LocalModelRefusalResponse {
success: false;
/** The sentence in English, its slots filled. */
@@ -251,8 +261,12 @@ export function registerLocalModelRoutes(
// The measured budget once the runtime reported it, and the runtime download that an
// install would still add: what turns the estimate into the figures of the consent.
const options = await service.recommendOptions();
const recommendation = resolved.recommend(hardware, catalog, harnessFacts, options);
const install = await service.view();
// A row that is already a checked model on this Mac says so: choosing it is a switch.
const recommendation = markLibraryCandidates(
resolved.recommend(hardware, catalog, harnessFacts, options),
install
);
return { success: true, data: { hardware, recommendation, catalogUpdated: catalog.updated, install } };
} catch (err) {
reply.code(500);
@@ -268,7 +282,17 @@ export function registerLocalModelRoutes(
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelInstallSchema, req.body);
return runVerb(reply, () => resolved.service().install(body, actorOf(req)));
// Asked at the moment of the request: another model takes the runtime from a live session.
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
return runVerb(reply, () => resolved.service().install(body, actorOf(req), sessionNames));
});
app.post('/api/local-model/use', async (req, reply): Promise<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelUseSchema, req.body);
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
return runVerb(reply, () => resolved.service().use(body, actorOf(req), sessionNames));
});
app.post('/api/local-model/cancel', async (req, reply): Promise<LocalModelVerbResponse> => {
@@ -303,4 +327,11 @@ export function registerLocalModelRoutes(
const sessionNames = sessionNamesUsingEndpoint(ctx.sessions.values());
return runVerb(reply, () => resolved.service().remove(body, sessionNames, actorOf(req)));
});
app.delete('/api/local-model/models', async (req, reply): Promise<LocalModelVerbResponse> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(LocalModelDeleteSchema, req.body);
return runVerb(reply, () => resolved.service().deleteLibraryModel(body, actorOf(req)));
});
}
+6
View File
@@ -2519,6 +2519,12 @@ export const LocalModelRuntimeSchema = z
/** DELETE /api/local-model. The body may be empty. */
export const LocalModelRemoveSchema = z.object({ removeModel: z.boolean().optional() }).strict();
/** POST /api/local-model/use: a checked model of the library to switch to. */
export const LocalModelUseSchema = z.object({ modelId: localModelId }).strict();
/** DELETE /api/local-model/models: one model of the store, by its name. Never a path. */
export const LocalModelDeleteSchema = z.object({ modelId: localModelId }).strict();
/**
* A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` —
* duplicated rather than imported, since the REAL safety boundary for anything built from