mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge branch 'feat/local-model-recommendation' into test/desktop-macbook
This commit is contained in:
commit
bce9fde8df
32 files changed
+7427
-4
No files matched your search
@@ -48,6 +48,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`) and the tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
|
||||
|
||||
**Model on this Mac** (`local-model-ui.js`; welcome card `#localModelCard` + App Settings > Models group `#localModelGroup`; server rules in the root CLAUDE.md, Local model recommendation): a READ-ONLY view of `GET /api/local-model`. ONE builder (`localModelBody`) paints both containers, so the card and the Settings row can never name a different pick; the card folds the alternatives, Settings lists them. ⚠️ Every dynamic string goes in through `textContent` (model, harness and chip names are someone else's text), both containers carry `data-i18n-skip`, and the module translates its own sentences at render time, so a language or display-name change must repaint from the module (the `<html lang>` observer). ⚠️ Labels and sentences come from the payload (`{ text, vars }` through `localModelT`); the module holds no list of models or harnesses, never compares a CLI id, and never contains the word "Recommended" (that headline is the server's to send). ⚠️ `test/local-model-i18n.test.ts` harvests every `localModelT('...')` literal: a new string is a single-quoted literal with no apostrophe and gets a zh-CN entry, and the only non-literal calls are the two payload ones. ⚠️ Sizes are printed as sent (decimal GB text), bytes are never divided here; the installed memory alone is `memClassGb`, a number or nothing (an unknown size is left out, never `0 GB`). ⚠️ The card ships `hidden` and shows only once the server answered for a Mac; the card AND the Settings group stay hidden for `not-macos` (the server is usually Linux, and "this Mac" always means the machine the server runs on), for a refusal and while the first request is in flight; a failed first request shows one sentence in Settings only. ⚠️ Refused means `403` with `errorCode: FORBIDDEN` (a non-admin in multi-user mode: stop asking); the auth layer's `403 PASSWORD_CHANGE_REQUIRED` is a failed request that is asked again on `codeman:me`. ⚠️ Each fit and speed pill carries what it measures as `title` + `aria-label` ("Good" is the memory fit, not a rating), and the line saying Codeman cannot download or install yet sits right under the pick and its notes, in `--text-dim` like every other line. The dismissal is a per-device localStorage key named for this stage (`codeman:local-model-dismissed:readonly`), never a server setting: the stage that can set a model up takes a new key so its offer shows once more. At most one request a minute (the server caches its probe for 60 s), shared while in flight, and a failed refresh keeps the answer on screen. Tests: `test/local-model-ui.test.ts`, `test/local-model-i18n.test.ts`.
|
||||
|
||||
**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)
|
||||
|
||||
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
|
||||
|
||||
@@ -1415,6 +1415,70 @@
|
||||
'该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。',
|
||||
'Git status is not available for a tmux session opened outside Codeman.':
|
||||
'在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。',
|
||||
// Model on this Mac (local-model-ui.js: the welcome card and its App Settings group). The first
|
||||
// block is the module's own text; sizes and names fill the {placeholder} slots.
|
||||
'Model on this Mac': '此 Mac 上的模型',
|
||||
'What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.':
|
||||
'运行 {name} 的这台 Mac 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。',
|
||||
'The Mac that Codeman runs on, which is not always the device you are reading on.':
|
||||
'指运行 {name} 的那台 Mac,不一定是你正在使用的这台设备。',
|
||||
Hide: '隐藏',
|
||||
'Hide this card on this device. It stays in App Settings, under Models.':
|
||||
'在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。',
|
||||
'{chip}, {memory} GB memory, {free} GB free': '{chip},内存 {memory} GB,可用磁盘空间 {free} GB',
|
||||
'{chip}, {memory} GB memory': '{chip},内存 {memory} GB',
|
||||
'{memory} GB memory, {free} GB free': '内存 {memory} GB,可用磁盘空间 {free} GB',
|
||||
'{memory} GB memory': '内存 {memory} GB',
|
||||
'{model} ({id}) with {harness}': '{model}({id}),搭配 {harness}',
|
||||
'Context {context} tokens': '上下文 {context} 个 Token',
|
||||
'{need} GB of memory': '占用内存 {need} GB',
|
||||
'{download} GB download': '下载 {download} GB',
|
||||
'Needs about {need} GB of the {budget} GB this Mac can give a model.':
|
||||
'大约需要 {need} GB,此 Mac 可分配给模型的内存为 {budget} GB。',
|
||||
'Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).':
|
||||
'大约需要 {need} GB,此 Mac 可分配给模型的内存约为 {budget} GB(估算)。',
|
||||
'About {speed} tokens per second (estimated).': '每秒约 {speed} 个 Token(估算)。',
|
||||
'{harness} is not installed on this Mac yet.': '此 Mac 上尚未安装 {harness}。',
|
||||
'With {harness}, the first answer can take minutes.': '使用 {harness} 时,第一次回答可能需要几分钟。',
|
||||
'about {speed} tokens per second': '每秒约 {speed} 个 Token',
|
||||
// What the fit and speed labels measure (their tooltip and accessible name).
|
||||
'Memory fit: needs {share}% of what this Mac can give a model':
|
||||
'内存占用:需要此 Mac 可分配给模型的内存的 {share}%',
|
||||
'No speed estimate for this model on this chip': '此芯片上没有该模型的速度估算',
|
||||
'Other models that fit': '其他装得下的模型',
|
||||
'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.':
|
||||
'{name} 目前还不能为你下载或安装它。这是下一步的功能,尚未实现。',
|
||||
// The sentences and labels the SERVER picks (LOCAL_MODEL_LABELS in src/types/local-model.ts), keyed
|
||||
// by their exact English text: changing a text there means changing its key here.
|
||||
'Best fit for this Mac': '最适合此 Mac 的选择',
|
||||
'Recommended for this Mac': '为此 Mac 推荐',
|
||||
'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.':
|
||||
'此 Mac 可以运行小型模型。它能处理小幅修改和问答,但不能替代云端模型。',
|
||||
'Not yet tested by us on a Mac like this.': '我们尚未在类似的 Mac 上测试过。',
|
||||
'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.':
|
||||
'最适合的是一个小型模型。它能处理小幅修改和问答,但不能替代云端模型。',
|
||||
'{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.':
|
||||
'{model} 也能装入此 Mac 的内存,但下载它还需要再腾出约 {missing} GB 的可用磁盘空间。',
|
||||
Good: '良好',
|
||||
Tight: '偏紧',
|
||||
Slow: '较慢',
|
||||
'Speed unknown': '速度未知',
|
||||
'Codeman recommends a local model only on a Mac with Apple silicon.':
|
||||
'{name} 仅在搭载 Apple 芯片的 Mac 上推荐本地模型。',
|
||||
'This Mac has an Intel processor. Local coding models need Apple silicon.':
|
||||
'此 Mac 使用 Intel 处理器。本地编程模型需要 Apple 芯片。',
|
||||
'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.':
|
||||
'此 Mac 有 {memory} GB 内存,不足以运行能够驱动编程智能体的模型,因此 {name} 在此不推荐任何模型。',
|
||||
'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.':
|
||||
'据 {name} 估算,此 Mac 可分配给模型的内存约为 {budget} GB。编程智能体能够使用的最小模型约需 {need} GB。这样没有留出安全余量,因此 {name} 在此不推荐任何模型。',
|
||||
'Codeman could not read how much memory this Mac has, so it recommends no model here.':
|
||||
'{name} 无法读取此 Mac 的内存大小,因此在此不推荐任何模型。',
|
||||
'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.':
|
||||
'可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,下载后还必须保留 {reserve} GB 的可用空间。当前可用空间为 {free} GB。请腾出约 {missing} GB。',
|
||||
'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.':
|
||||
'此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。',
|
||||
'No coding agent that Codeman can connect to a local model is available on this Mac.':
|
||||
'此 Mac 上没有 {name} 可以连接到本地模型的编程智能体。',
|
||||
});
|
||||
|
||||
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
|
||||
|
||||
@@ -583,6 +583,14 @@
|
||||
</div>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<!-- What the Mac the server runs on can run as a local coding model
|
||||
(local-model-ui.js). Read-only. Ships `hidden` and only that module
|
||||
reveals it, once the server has answered for a Mac: a server on
|
||||
another system never shows it, and the user can hide it per device.
|
||||
The module writes every string through its own translator call
|
||||
(model and chip names are not ours to translate), hence
|
||||
data-i18n-skip. -->
|
||||
<section class="local-model-card" id="localModelCard" aria-labelledby="localModelCardTitle" data-i18n-skip hidden></section>
|
||||
<!-- Sessions a human started outside Codeman (foreign-sessions.js).
|
||||
Hidden until a scan finds one; never rendered empty on a fresh
|
||||
install, where an empty block reads as a broken feature. -->
|
||||
@@ -2867,6 +2875,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What the Mac the server runs on can run as a local model (local-model-ui.js).
|
||||
Read-only: the module fills the row from GET /api/local-model, the same body
|
||||
the welcome card shows, through its own translator call (hence data-i18n-skip
|
||||
on the row's text). The module shows the group only once the server has
|
||||
answered for a Mac: it hides it for a server on another system (the title
|
||||
and hint are about "this Mac"), while the first request is in flight, and
|
||||
for a non-admin in multi-user mode (the route is admin-only). Not gated on
|
||||
the endpoints toggle above: the answer does not depend on it. -->
|
||||
<div class="set-group" id="localModelGroup">
|
||||
<div class="set-group-head"><h4>Model on this Mac</h4></div>
|
||||
<p class="set-group-hint">What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.</p>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="local model mac apple silicon memory llm recommendation offline">
|
||||
<div class="set-row-text" id="localModelSettings" data-i18n-skip aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Agents & CLIs ═════════════════════════════════════════ -->
|
||||
@@ -4685,6 +4711,7 @@
|
||||
<script defer src="foreign-sessions.js"></script>
|
||||
<script defer src="git-status-ui.js"></script>
|
||||
<script defer src="edit-history-ui.js"></script>
|
||||
<script defer src="local-model-ui.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* @fileoverview The "model on this Mac" card of the welcome screen and its row in App Settings.
|
||||
*
|
||||
* Reads `GET /api/local-model` (hardware of the machine the server runs on, the recommendation,
|
||||
* the catalogue date) and shows, read-only:
|
||||
* - the hardware line (chip, memory, free disk);
|
||||
* - the verdict: the headline the server sent, then the pick with its numbers (model, harness,
|
||||
* context, memory need against the budget, download size, speed) and the notes;
|
||||
* - one line saying that Codeman cannot download or install the model yet: that is the next
|
||||
* step and is not built. It sits right under the pick, where a reader looks for a button;
|
||||
* - the other models that fit, each with its fit and speed label. A label says what it
|
||||
* measures in its tooltip and accessible name ("Good" is the memory fit, not a rating of
|
||||
* the model), and an alternative with a speed estimate prints the figure.
|
||||
*
|
||||
* ## One body, two containers
|
||||
*
|
||||
* The welcome card (`#localModelCard`) and the App Settings row (`#localModelSettings`) are
|
||||
* painted from the same payload by the same builder (`localModelBody`), so the two can never
|
||||
* name a different pick. They differ only in chrome: the card has a title and a Hide button and
|
||||
* folds the alternatives; the row lists them open.
|
||||
*
|
||||
* ## When the card shows
|
||||
*
|
||||
* The card ships `hidden` and appears only once the server has answered for a Mac. It stays
|
||||
* hidden while the first request is in flight and after a failed one (nothing is known about the
|
||||
* machine then, and an error card would be the only thing this feature ever showed on a server
|
||||
* that is not a Mac), when the answer is `not-macos`, when the request is refused (a non-admin in
|
||||
* multi-user mode gets a `403 FORBIDDEN`, which is an answer: stop asking) and after the user hid
|
||||
* it on this device.
|
||||
*
|
||||
* The Settings group ("Model on this Mac") follows the same knowledge: it is hidden for
|
||||
* `not-macos` (most servers are not a Mac, and a group about "this Mac" there is false), for a
|
||||
* refused request and while the first request is in flight (nothing says yet that the server is
|
||||
* a Mac). It shows the answer for a Mac, and one failure sentence when the first request
|
||||
* failed. It is the way back to the numbers after a Hide.
|
||||
*
|
||||
* "Refused" means the envelope's `errorCode` is `FORBIDDEN`. The auth layer has a second 403 for
|
||||
* every /api route (`PASSWORD_CHANGE_REQUIRED`, an admin who must change the password first):
|
||||
* that one is a failed request, asked again on the next show and on `codeman:me`.
|
||||
*
|
||||
* Rules:
|
||||
* - Read-only: no control here downloads, installs or writes anything but the per-device
|
||||
* dismissal (a localStorage key, never a server setting).
|
||||
* - Every dynamic string goes in through `textContent`. Model, harness and chip names are
|
||||
* someone else's text, so both containers carry `data-i18n-skip` and this file translates
|
||||
* its own sentences at render time. A language or display-name change therefore re-renders
|
||||
* (`<html lang>` is written by every i18n `configure()`).
|
||||
* - Names and labels come from the payload: this file holds no list of models or harnesses and
|
||||
* never compares a CLI id with a literal.
|
||||
* - Sizes are printed exactly as sent (`needGb`, `downloadGb`, `budgetGb`, `freeDiskGb`:
|
||||
* decimal GB as text). Bytes are never divided here. The installed memory is the one size
|
||||
* that is not decimal GB: the hardware line prints `memClassGb`, the size the Mac is sold as
|
||||
* (48, not 51.5), which is what About This Mac shows.
|
||||
* - A sentence the server chose arrives as `{ text, vars }` and goes through
|
||||
* `localModelT(text, vars)`; this file's own strings are literals in that same call, in
|
||||
* `{placeholder}` form, so the i18n test can harvest them. Both kinds need a zh-CN entry in
|
||||
* i18n.js. `{name}` is the translator's own slot (the display name) and is never used here.
|
||||
* - The word "Recommended" is not in this file. Which headline a Mac gets is the server's call.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp)
|
||||
* @dependency api-client.js (_api)
|
||||
* @dependency constants.js (CodemanBase, optional)
|
||||
* @dependency i18n.js (codemanT, optional)
|
||||
* @loadorder 12.59 of 16, after edit-history-ui.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-device dismissal of the welcome card, named for THIS stage (the read-only card). The stage
|
||||
* that can set a model up takes a key of its own, so hiding a card that offers nothing yet does
|
||||
* not hide the offer later.
|
||||
*/
|
||||
const LOCAL_MODEL_DISMISS_KEY = 'codeman:local-model-dismissed:readonly';
|
||||
|
||||
/** The server caches its hardware probe for a minute, so asking again sooner cannot change the answer. */
|
||||
const LOCAL_MODEL_MAX_AGE_MS = 60000;
|
||||
|
||||
/** i18n.js's translator with the `{placeholder}` slots filled, or the English source filled the same way. */
|
||||
function localModelT(source, vars) {
|
||||
const values = vars && typeof vars === 'object' ? vars : {};
|
||||
if (typeof codemanT === 'function') return codemanT(source, values);
|
||||
return String(source).replace(/\{([a-zA-Z]\w*)\}/g, (_match, key) => String(values[key] ?? ''));
|
||||
}
|
||||
|
||||
/** The route, under the reverse-proxy base path when there is one. */
|
||||
function localModelUrl(path) {
|
||||
return typeof CodemanBase !== 'undefined' && CodemanBase ? CodemanBase.url(path) : path;
|
||||
}
|
||||
|
||||
function localModelEl(tag, className, text) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
/** A sentence the server chose: `{ text, vars }` from the payload. */
|
||||
function localModelMessage(message) {
|
||||
if (!message || typeof message.text !== 'string') return '';
|
||||
return localModelT(message.text, message.vars);
|
||||
}
|
||||
|
||||
/** A token count as people write it (131,072). */
|
||||
function localModelCount(value) {
|
||||
return Number.isFinite(value) ? Number(value).toLocaleString('en-US') : String(value ?? '');
|
||||
}
|
||||
|
||||
/** Tokens per second: whole from 10, one decimal below. Empty when the server sent none. */
|
||||
function localModelSpeed(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '';
|
||||
return String(value >= 10 ? Math.round(value) : Math.round(value * 10) / 10);
|
||||
}
|
||||
|
||||
/** Whether the page already knows the route will refuse it (a non-admin in multi-user mode). */
|
||||
function localModelNotAdmin() {
|
||||
const me = (typeof window !== 'undefined' && window.__codemanUser) || {};
|
||||
return !!(me.multiUser && me.role !== 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip, memory and free disk in one line. The memory is the size the Mac is sold as
|
||||
* (`memClassGb`: 48 for a Mac whose 48 x 2^30 bytes are 51.5 decimal GB), a number or nothing:
|
||||
* an unknown size (0) is left out, never printed.
|
||||
*/
|
||||
function localModelHardwareLine(hw) {
|
||||
const chip = String(hw.chip || hw.model || '');
|
||||
const memory = Number.isFinite(hw.memClassGb) && hw.memClassGb > 0 ? String(hw.memClassGb) : '';
|
||||
const sizes = { chip, memory, free: hw.freeDiskGb };
|
||||
if (!memory) return chip;
|
||||
if (chip && hw.freeDiskGb) return localModelT('{chip}, {memory} GB memory, {free} GB free', sizes);
|
||||
if (chip) return localModelT('{chip}, {memory} GB memory', sizes);
|
||||
if (hw.freeDiskGb) return localModelT('{memory} GB memory, {free} GB free', sizes);
|
||||
return localModelT('{memory} GB memory', sizes);
|
||||
}
|
||||
|
||||
/** What the fit label measures: the share of the memory budget the model needs. */
|
||||
function localModelFitHint(candidate) {
|
||||
if (!Number.isFinite(candidate.budgetShare)) return '';
|
||||
const share = String(Math.round(candidate.budgetShare * 100));
|
||||
return localModelT('Memory fit: needs {share}% of what this Mac can give a model', { share });
|
||||
}
|
||||
|
||||
/** What the speed label stands for: the estimate behind "Slow", or that there is none. */
|
||||
function localModelSpeedHint(candidate) {
|
||||
const speed = localModelSpeed(candidate.tokensPerSecond);
|
||||
if (speed) return localModelT('About {speed} tokens per second (estimated).', { speed });
|
||||
return localModelT('No speed estimate for this model on this chip');
|
||||
}
|
||||
|
||||
/**
|
||||
* "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose.
|
||||
* A bare "Good" next to a model name reads as a rating of the model, so each label carries what
|
||||
* it measures as its tooltip and its accessible name.
|
||||
*/
|
||||
function localModelCandidateHead(candidate) {
|
||||
const head = localModelEl('div', 'local-model-name-row');
|
||||
const name = localModelT('{model} ({id}) with {harness}', {
|
||||
model: candidate.modelLabel || candidate.modelId,
|
||||
id: candidate.modelId,
|
||||
harness: candidate.cliLabel || candidate.cli,
|
||||
});
|
||||
head.appendChild(localModelEl('span', 'local-model-name', name));
|
||||
const pills = [
|
||||
[candidate.fitLabel, localModelFitHint],
|
||||
[candidate.speedLabel, localModelSpeedHint],
|
||||
];
|
||||
for (const [label, hint] of pills) {
|
||||
if (typeof label !== 'string' || !label) continue;
|
||||
const pill = localModelEl('span', 'local-model-pill', localModelT(label));
|
||||
const meaning = hint(candidate);
|
||||
if (meaning) {
|
||||
pill.title = meaning;
|
||||
pill.setAttribute('role', 'note');
|
||||
pill.setAttribute('aria-label', `${pill.textContent}. ${meaning}`);
|
||||
}
|
||||
head.appendChild(pill);
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dim line under a candidate's name: context, then for an alternative its memory need and,
|
||||
* when there is an estimate, its speed (so "Slow" comes with a number on a screen without
|
||||
* hover), the download, and for the pick the license.
|
||||
*/
|
||||
function localModelMeta(candidate, { alternative = false } = {}) {
|
||||
const bits = [localModelT('Context {context} tokens', { context: localModelCount(candidate.context) })];
|
||||
if (alternative && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb }));
|
||||
if (candidate.downloadGb) bits.push(localModelT('{download} GB download', { download: candidate.downloadGb }));
|
||||
const speed = alternative ? localModelSpeed(candidate.tokensPerSecond) : '';
|
||||
if (speed) bits.push(localModelT('about {speed} tokens per second', { speed }));
|
||||
if (!alternative && candidate.license) bits.push(String(candidate.license));
|
||||
return bits.join(' · ');
|
||||
}
|
||||
|
||||
/** The pick with its numbers. */
|
||||
function localModelPick(pick, recommendation) {
|
||||
const box = localModelEl('div', 'local-model-pick');
|
||||
box.appendChild(localModelCandidateHead(pick));
|
||||
box.appendChild(localModelEl('div', 'local-model-line', localModelMeta(pick)));
|
||||
const sizes = { need: pick.needGb, budget: recommendation.budgetGb };
|
||||
// A wired limit is a setting of this Mac; anything else is the formula's estimate, and says so.
|
||||
const need =
|
||||
recommendation.budgetSource === 'wired-limit'
|
||||
? localModelT('Needs about {need} GB of the {budget} GB this Mac can give a model.', sizes)
|
||||
: localModelT('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).', sizes);
|
||||
box.appendChild(localModelEl('div', 'local-model-line', need));
|
||||
const speed = localModelSpeed(pick.tokensPerSecond);
|
||||
if (speed) {
|
||||
const line = localModelT('About {speed} tokens per second (estimated).', { speed });
|
||||
box.appendChild(localModelEl('div', 'local-model-line', line));
|
||||
}
|
||||
const harness = pick.cliLabel || pick.cli;
|
||||
if (pick.cliInstalled === false) {
|
||||
const line = localModelT('{harness} is not installed on this Mac yet.', { harness });
|
||||
box.appendChild(localModelEl('div', 'local-model-line', line));
|
||||
}
|
||||
if (pick.slowFirstTurn === true) {
|
||||
const line = localModelT('With {harness}, the first answer can take minutes.', { harness });
|
||||
box.appendChild(localModelEl('div', 'local-model-line', line));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* The other models that fit. Folded behind a native disclosure on the welcome card, open in
|
||||
* Settings. No count beside the title: the server lists four at most, so the number of rows is
|
||||
* not the number of models that fit.
|
||||
*/
|
||||
function localModelAlternatives(alternatives, fold) {
|
||||
const wrap = localModelEl(fold ? 'details' : 'div', 'local-model-alts');
|
||||
const title = localModelEl(fold ? 'summary' : 'div', 'local-model-alts-title', localModelT('Other models that fit'));
|
||||
wrap.appendChild(title);
|
||||
for (const candidate of alternatives) {
|
||||
const row = localModelEl('div', 'local-model-alt');
|
||||
row.appendChild(localModelCandidateHead(candidate));
|
||||
row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { alternative: true })));
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the server said, in reading order. The ONLY builder: the card and the Settings row
|
||||
* both call it, see @fileoverview.
|
||||
*/
|
||||
function localModelBody(status, { fold }) {
|
||||
const hardware = status.hardware || {};
|
||||
const recommendation = status.recommendation || {};
|
||||
const body = localModelEl('div', 'local-model-body');
|
||||
// Not a Mac: one plain sentence, and no line calling another machine's parts "this Mac".
|
||||
const hardwareLine = recommendation.reason === 'not-macos' ? '' : localModelHardwareLine(hardware);
|
||||
if (hardwareLine) body.appendChild(localModelEl('div', 'local-model-hw', hardwareLine));
|
||||
const headline = localModelMessage(recommendation.headline);
|
||||
if (headline) body.appendChild(localModelEl('div', 'local-model-headline', headline));
|
||||
const pick = recommendation.verdict === 'none' ? null : recommendation.pick;
|
||||
if (pick) body.appendChild(localModelPick(pick, recommendation));
|
||||
for (const note of Array.isArray(recommendation.notes) ? recommendation.notes : []) {
|
||||
const text = localModelMessage(note);
|
||||
if (text) body.appendChild(localModelEl('div', 'local-model-note', text));
|
||||
}
|
||||
// Right under the pick and its notes, above the list: the pick names a download and maybe a
|
||||
// harness that is not installed, so this is where a reader looks for a button.
|
||||
if (pick) {
|
||||
const next = localModelT(
|
||||
'Codeman cannot download or install this for you yet. That is the next step, and it is not built yet.'
|
||||
);
|
||||
body.appendChild(localModelEl('div', 'local-model-next', next));
|
||||
}
|
||||
const alternatives = pick && Array.isArray(recommendation.alternatives) ? recommendation.alternatives : [];
|
||||
if (alternatives.length) body.appendChild(localModelAlternatives(alternatives, fold));
|
||||
return body;
|
||||
}
|
||||
|
||||
/** The `data` of the GET, or null for anything that is not one. */
|
||||
function localModelPayload(body) {
|
||||
const data = body && body.success === true ? body.data : null;
|
||||
const recommendation = data && typeof data === 'object' ? data.recommendation : null;
|
||||
if (!recommendation || typeof recommendation !== 'object' || typeof recommendation.verdict !== 'string') return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Data
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Ask the server what this Mac can run, at most once a minute, and paint both containers.
|
||||
* Called when the welcome screen shows and when App Settings opens. Idempotent: a request in
|
||||
* flight is shared, and a fresh answer is only re-rendered.
|
||||
*/
|
||||
loadLocalModel() {
|
||||
this._wireLocalModel();
|
||||
if (this._localModelRefused()) {
|
||||
this.renderLocalModel();
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this._localModelInFlight) return this._localModelInFlight;
|
||||
const fresh = this._localModelStatus && Date.now() - (this._localModelLoadedAt || 0) < LOCAL_MODEL_MAX_AGE_MS;
|
||||
if (fresh) {
|
||||
this.renderLocalModel();
|
||||
return Promise.resolve();
|
||||
}
|
||||
// An answer already on screen stays there while it is refreshed.
|
||||
if (!this._localModelStatus) this._localModelState = 'loading';
|
||||
this.renderLocalModel();
|
||||
this._localModelInFlight = this._fetchLocalModel().finally(() => {
|
||||
this._localModelInFlight = null;
|
||||
});
|
||||
return this._localModelInFlight;
|
||||
},
|
||||
|
||||
async _fetchLocalModel() {
|
||||
// `_api`, not `_apiJson`: a refusal (admin-only in multi-user mode) is an answer and must be
|
||||
// told apart from a request that failed.
|
||||
const res = await this._api(localModelUrl('/api/local-model'));
|
||||
let body = null;
|
||||
if (res?.ok || res?.status === 403) {
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
}
|
||||
// Only the route's own refusal is final. The auth layer answers 403 too, with
|
||||
// PASSWORD_CHANGE_REQUIRED, for an admin who must change the password first: that is a
|
||||
// failed request, asked again once the password is changed.
|
||||
if (res?.status === 403 && body?.errorCode === 'FORBIDDEN') {
|
||||
this._localModelState = 'refused';
|
||||
this._localModelStatus = null;
|
||||
this.renderLocalModel();
|
||||
return;
|
||||
}
|
||||
const data = res?.ok ? localModelPayload(body) : null;
|
||||
if (!data) {
|
||||
// A failed refresh must not blank an answer the user is reading: keep the last good one.
|
||||
if (!this._localModelStatus) this._localModelState = 'failed';
|
||||
this.renderLocalModel();
|
||||
return;
|
||||
}
|
||||
this._localModelStatus = data;
|
||||
this._localModelState = 'ready';
|
||||
this._localModelLoadedAt = Date.now();
|
||||
this.renderLocalModel();
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Dismissal (per device)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
isLocalModelCardDismissed() {
|
||||
if (this._localModelDismissed === true) return true;
|
||||
try {
|
||||
return localStorage.getItem(LOCAL_MODEL_DISMISS_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Hide the welcome card on this device. The Settings row keeps showing the same answer. */
|
||||
dismissLocalModelCard() {
|
||||
// Remembered on the instance too: with storage blocked the card still stays away until a reload.
|
||||
this._localModelDismissed = true;
|
||||
try {
|
||||
localStorage.setItem(LOCAL_MODEL_DISMISS_KEY, '1');
|
||||
} catch {
|
||||
/* storage blocked: hidden for this page load only */
|
||||
}
|
||||
this.renderLocalModel();
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Render
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Paint every mounted container (welcome card + App Settings row). */
|
||||
renderLocalModel() {
|
||||
const card = document.getElementById('localModelCard');
|
||||
if (card) this._renderLocalModelCard(card);
|
||||
const row = document.getElementById('localModelSettings');
|
||||
if (row) this._renderLocalModelSettings(row);
|
||||
},
|
||||
|
||||
_localModelRefused() {
|
||||
return this._localModelState === 'refused' || localModelNotAdmin();
|
||||
},
|
||||
|
||||
_renderLocalModelCard(card) {
|
||||
// The one piece of state the card holds itself: whether the alternatives were unfolded.
|
||||
const wasOpen = card.querySelector('details')?.open === true;
|
||||
card.textContent = '';
|
||||
const status = this._localModelStatus;
|
||||
const hidden =
|
||||
!status ||
|
||||
this._localModelRefused() ||
|
||||
status.recommendation.reason === 'not-macos' ||
|
||||
this.isLocalModelCardDismissed();
|
||||
card.hidden = hidden;
|
||||
if (hidden) return;
|
||||
|
||||
const head = localModelEl('div', 'local-model-head');
|
||||
const title = localModelEl('h3', 'local-model-title', localModelT('Model on this Mac'));
|
||||
title.id = 'localModelCardTitle';
|
||||
// The page can be open on another laptop or a tablet: "this Mac" is the server's.
|
||||
title.title = localModelT('The Mac that Codeman runs on, which is not always the device you are reading on.');
|
||||
head.appendChild(title);
|
||||
const hide = localModelEl('button', 'local-model-hide', localModelT('Hide'));
|
||||
hide.type = 'button';
|
||||
hide.title = localModelT('Hide this card on this device. It stays in App Settings, under Models.');
|
||||
hide.addEventListener('click', () => this.dismissLocalModelCard());
|
||||
head.appendChild(hide);
|
||||
card.appendChild(head);
|
||||
|
||||
const box = localModelEl('div', 'local-model-box');
|
||||
box.appendChild(localModelBody(status, { fold: true }));
|
||||
card.appendChild(box);
|
||||
const details = card.querySelector('details');
|
||||
if (details && wasOpen) details.open = true;
|
||||
},
|
||||
|
||||
_renderLocalModelSettings(row) {
|
||||
row.textContent = '';
|
||||
const status = this._localModelStatus;
|
||||
const failed = !status && this._localModelState === 'failed';
|
||||
// The whole group goes for a non-admin, the way the other admin-only groups of this modal
|
||||
// do, and for a server that is not a Mac: its title and hint are about "this Mac". Until
|
||||
// the first answer nothing says the server is one, so the group waits for it.
|
||||
const shown = !this._localModelRefused() && (failed || (!!status && status.recommendation.reason !== 'not-macos'));
|
||||
const group = document.getElementById('localModelGroup');
|
||||
if (group) group.style.display = shown ? '' : 'none';
|
||||
if (!shown) return;
|
||||
if (status) {
|
||||
row.appendChild(localModelBody(status, { fold: false }));
|
||||
return;
|
||||
}
|
||||
row.appendChild(localModelEl('div', 'local-model-headline', localModelT('Could not reach the server.')));
|
||||
},
|
||||
|
||||
/** Listeners that outlive a render, wired once. */
|
||||
_wireLocalModel() {
|
||||
if (this._localModelWired) return;
|
||||
this._localModelWired = true;
|
||||
// Both containers are data-i18n-skip (see @fileoverview), so the DOM translator never
|
||||
// revisits them: a language or display-name change has to repaint them from here.
|
||||
if (typeof MutationObserver === 'function' && document.documentElement) {
|
||||
const observer = new MutationObserver(() => this.renderLocalModel());
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
|
||||
}
|
||||
// The real role can arrive after the first render (admin-ui.js fetches /api/me): a non-admin
|
||||
// loses the card and the group, and an admin the page had taken for one is asked for at last.
|
||||
document.addEventListener?.('codeman:me', () => void this.loadLocalModel());
|
||||
},
|
||||
});
|
||||
@@ -1857,6 +1857,17 @@ html.mobile-init .file-browser-panel {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Model on this Mac (local-model-ui.js). Only reached with the phone overview
|
||||
switched off. The card has no fixed width and every line in it wraps, so
|
||||
the narrow column only takes some of the spacing back. */
|
||||
.local-model-card {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.local-model-box {
|
||||
padding: 0.5rem 0.55rem;
|
||||
}
|
||||
|
||||
/* Ralph wizard responsive */
|
||||
.modal-wizard {
|
||||
display: flex;
|
||||
@@ -3969,6 +3980,16 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Model on this Mac (local-model-ui.js): its row follows the label size above. */
|
||||
#localModelSettings .local-model-headline,
|
||||
#localModelSettings .local-model-name {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
#localModelSettings .local-model-alt .local-model-name {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Fields go full width under their label instead of fighting for the row */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row.has-field {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -550,6 +550,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Assigning .checked above does not fire onchange, so the body's visibility
|
||||
// (and its lazy load) needs an explicit sync on every open, not just a save.
|
||||
this.applyCustomModelEndpointsVisibility();
|
||||
// Model on this Mac (local-model-ui.js): a read-only row, not a setting. Server state,
|
||||
// asked for at most once a minute; nothing here is loaded or saved by id.
|
||||
this.loadLocalModel?.();
|
||||
// CLI management (docs/cli-enable-disable-plan.md): synced, default OFF.
|
||||
document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true;
|
||||
// Same reasoning as applyCustomModelEndpointsVisibility above: assigning
|
||||
|
||||
@@ -22303,6 +22303,196 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Model on this Mac (local-model-ui.js): what the server's Mac can run as
|
||||
a local coding model. One body (`.local-model-body`), drawn in two places:
|
||||
the welcome card and a row of App Settings > Models.
|
||||
|
||||
Same vocabulary as the two blocks above it on the welcome screen: a dim
|
||||
heading row, then one bordered surface with a name over dim meta lines.
|
||||
Theme tokens only (`--control-bg`, never a white alpha: that fill is
|
||||
invisible on the light skins), and the fit and speed labels are neutral
|
||||
pills: the words carry the meaning, so no skin needs a colour of its own.
|
||||
Nothing here is keyed on a CLI or a model id, and nothing animates.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.local-model-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* A flex container, so `[hidden]` needs re-asserting or the module's only
|
||||
visibility lever does nothing (same trap as .foreign-sessions). */
|
||||
.local-model-card[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.local-model-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.local-model-title {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.local-model-hide {
|
||||
margin-left: auto;
|
||||
font: inherit;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.local-model-hide:hover {
|
||||
color: var(--text);
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.local-model-box {
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--control-bg);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.local-model-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The line that says nothing can be set up from here yet (.local-model-next) is
|
||||
the one a reader must not miss, so it reads like every other line: same size,
|
||||
same --text-dim. --text-muted on this surface was 3.9:1. */
|
||||
.local-model-hw,
|
||||
.local-model-line,
|
||||
.local-model-note,
|
||||
.local-model-next {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-dim);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.local-model-headline {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The pick is the one thing in the card set apart: a rule in the accent, on the
|
||||
reading side (logical, so it follows a right-to-left page). */
|
||||
.local-model-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
padding-inline-start: 0.55rem;
|
||||
border-inline-start: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.local-model-name-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.2rem 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.local-model-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Each pill carries what it measures as its tooltip (a title), hence the cursor. */
|
||||
.local-model-pill {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.42rem;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.local-model-alts-title {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* The welcome card folds the list behind a native disclosure (keyboard and
|
||||
screen-reader behaviour for free); the summary keeps `display: list-item`
|
||||
so its marker stays. */
|
||||
.local-model-alts > summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.local-model-alts > summary:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.local-model-alt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
margin-top: 0.3rem;
|
||||
padding-top: 0.3rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.local-model-alt .local-model-name {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* In App Settings the body sits inside an ordinary .set-row, so it takes that
|
||||
surface's type scale (label 0.76rem over a muted description) and the row
|
||||
supplies the border. */
|
||||
#localModelSettings {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
#localModelSettings .local-model-headline,
|
||||
#localModelSettings .local-model-name {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
#localModelSettings .local-model-alt .local-model-name {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Touch: the two things in the card that take a press get a finger's height. */
|
||||
@media (pointer: coarse) {
|
||||
.local-model-hide {
|
||||
min-height: 32px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
}
|
||||
|
||||
.local-model-alts > summary {
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are
|
||||
absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */
|
||||
.spreadsheet-preview-shell {
|
||||
|
||||
@@ -2964,6 +2964,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// up (stopped in hideWelcome), see foreign-sessions.js.
|
||||
this.wireForeignSessions?.();
|
||||
this.startForeignPolling?.();
|
||||
// What this Mac can run as a local model (local-model-ui.js). One request,
|
||||
// at most once a minute, no timer: nothing to stop in hideWelcome.
|
||||
this.loadLocalModel?.();
|
||||
}
|
||||
// Home screen has no input target — hide the CJK textarea (activeSessionId
|
||||
// is null by the time we get here). Guarded: defined on the app object.
|
||||
|
||||
@@ -49,5 +49,6 @@ export {
|
||||
type CustomModelSwapDisplacement,
|
||||
} from './custom-model-routes.js';
|
||||
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
|
||||
export { registerLocalModelRoutes } from './local-model-routes.js';
|
||||
|
||||
export { registerAgentSessionRoutes } from './agent-session-routes.js';
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/local-model`: what this Mac is and which local model fits it.
|
||||
*
|
||||
* Answers `{ success: true, data: LocalModelStatus }`, built from the hardware probe, the
|
||||
* catalogue, the harness facts and the pure `recommend()`:
|
||||
* `{ hardware, recommendation, catalogUpdated }`. Nothing is reformatted here: every size text
|
||||
* and every sentence in the payload was chosen by the modules that produced it.
|
||||
*
|
||||
* EXPERIMENTAL (docs/versioning-policy.md, "Experimental local model recommendation"): the
|
||||
* route and its payload are outside the stable `/api/v1` contract until the stage that sets a
|
||||
* model up ships, because that stage will change what the fields mean. docs/api-reference.md
|
||||
* says so in its section; keep the two in step, and drop both labels together.
|
||||
*
|
||||
* Rules:
|
||||
* - Read-only. It downloads nothing, installs nothing, writes no file and reads no setting.
|
||||
* - A failed probe is an answer, never a 500. `readHardware()` reports a spawn that failed
|
||||
* in the hardware's `source` (`os-fallback`), and a server that is not on macOS as
|
||||
* `not-probed`; both go out as a 200 whose recommendation is verdict `none` or computed
|
||||
* from what is known. Should the probe itself reject (it promises not to), the route logs
|
||||
* it and answers with what `node:os` knows (`hardwareFromOs()`), under the same two
|
||||
* `source` values.
|
||||
* - A 500 is left for a fault that is not a probe: the catalogue, the harness facts or
|
||||
* `recommend()` throwing. That is a bug, and it must not be dressed up as "no model fits".
|
||||
* - The payload names hardware of the host, so in multi-user mode it is admin only (403), the
|
||||
* same bar as `GET /api/doctor`. The gate runs before anything is probed.
|
||||
* - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and
|
||||
* does not need a Mac: it passes its own hardware and harness facts.
|
||||
*/
|
||||
|
||||
import { arch, totalmem } from 'node:os';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
getErrorMessage,
|
||||
type ApiResponse,
|
||||
type HardwareFacts,
|
||||
type HarnessFact,
|
||||
type LocalModelCatalog,
|
||||
type LocalModelStatus,
|
||||
type Recommendation,
|
||||
} from '../../types.js';
|
||||
import { isAdmin } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { getLocalModelCatalog } from '../../config/local-model-catalog.js';
|
||||
import { formatGb, memClassGb } from '../../local-model/format.js';
|
||||
import { readHardware } from '../../local-model/hardware.js';
|
||||
import { buildHarnessFacts } from '../../local-model/harness-facts.js';
|
||||
import { recommend } from '../../local-model/recommend.js';
|
||||
|
||||
/** What the route calls. Each member defaults to the real module; a test overrides what it needs. */
|
||||
export interface LocalModelRouteDeps {
|
||||
readHardware: () => Promise<HardwareFacts>;
|
||||
getCatalog: () => LocalModelCatalog;
|
||||
buildHarnessFacts: (catalog: LocalModelCatalog) => Promise<HarnessFact[]>;
|
||||
recommend: (hw: HardwareFacts, catalog: LocalModelCatalog, harnessFacts: readonly HarnessFact[]) => Recommendation;
|
||||
}
|
||||
|
||||
export const defaultLocalModelRouteDeps: LocalModelRouteDeps = {
|
||||
readHardware: () => readHardware(),
|
||||
getCatalog: getLocalModelCatalog,
|
||||
buildHarnessFacts: (catalog) => buildHarnessFacts(catalog),
|
||||
recommend,
|
||||
};
|
||||
|
||||
/**
|
||||
* The hardware answer when the probe rejected: memory and architecture from `node:os`, and the
|
||||
* `source` the probe itself uses for "nothing was read from sysctl" (`os-fallback` on macOS,
|
||||
* `not-probed` anywhere else). Sizes go through the one formatter, like every other size.
|
||||
*/
|
||||
export function hardwareFromOs(): HardwareFacts {
|
||||
const memBytes = totalmem();
|
||||
return {
|
||||
platform: process.platform,
|
||||
arch: arch(),
|
||||
memBytes,
|
||||
memGb: formatGb(memBytes),
|
||||
memClassGb: memClassGb(memBytes),
|
||||
source: process.platform === 'darwin' ? 'os-fallback' : 'not-probed',
|
||||
};
|
||||
}
|
||||
|
||||
export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial<LocalModelRouteDeps> = {}): void {
|
||||
const resolved: LocalModelRouteDeps = { ...defaultLocalModelRouteDeps, ...deps };
|
||||
app.get(
|
||||
'/api/local-model',
|
||||
async (req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<LocalModelStatus>> => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
|
||||
let hardware: HardwareFacts;
|
||||
try {
|
||||
hardware = await resolved.readHardware();
|
||||
} catch (err) {
|
||||
console.warn(`[local-model] The hardware probe failed, answering from node:os: ${getErrorMessage(err)}`);
|
||||
hardware = hardwareFromOs();
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = resolved.getCatalog();
|
||||
const harnessFacts = await resolved.buildHarnessFacts(catalog);
|
||||
const recommendation = resolved.recommend(hardware, catalog, harnessFacts);
|
||||
return { success: true, data: { hardware, recommendation, catalogUpdated: catalog.updated } };
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INTERNAL_ERROR,
|
||||
`The local model recommendation failed: ${getErrorMessage(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -264,6 +264,7 @@ import {
|
||||
pruneIdleLlamaSwapLogTails,
|
||||
tryWebviewRefererFallback,
|
||||
registerCliRegistryRoutes,
|
||||
registerLocalModelRoutes,
|
||||
} from './routes/index.js';
|
||||
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
@@ -1417,6 +1418,7 @@ export class WebServer extends EventEmitter {
|
||||
registerDoctorRoutes(this.app);
|
||||
registerCustomModelRoutes(this.app);
|
||||
registerCliRegistryRoutes(this.app);
|
||||
registerLocalModelRoutes(this.app);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
// due times for any persisted jobs, then expose it to its routes.
|
||||
|
||||
Reference in new issue
Block a user