mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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:
commit
f07cbccb0b
26 files changed
+4244
-227
No files matched your search
+1
-1
@@ -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
@@ -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 上运行的模型。请在本地模型窗口中更改或移除它。',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user