mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
feat(local-model): welcome card and App Settings row for the model on this Mac
The read-only face of GET /api/local-model: what the Mac the server runs on can run as a local coding model. - local-model-ui.js: one builder paints both containers from the same payload: the hardware line, the server's headline, the pick with its numbers (context, download, memory need against the budget, speed), the server's notes, the other models that fit with their Good, Tight, Slow or Speed unknown labels, and one line saying that setting it up from Codeman is the next step and is not built yet. - The welcome card ships hidden and shows only once the server answered for a Mac. It stays hidden for not-macos, for a refused request (a non-admin in multi-user mode) and after "Hide", a per-device localStorage key. The App Settings group "Model on this Mac" (Models) carries the loading and failure sentences and is the way back after a Hide. - Every dynamic string goes in through textContent, both containers are data-i18n-skip and the module translates its own sentences, repainting on a language change. Sizes are printed as sent. Labels and harness names come from the payload: the module names no CLI and the word "Recommended" is not in it. - One request a minute at most, shared while in flight; a failed refresh keeps the last answer on screen. - i18n.js: zh-CN entries for the module's strings and for every server label. - frontend-cli-no-id-branching.test.ts scans the new module. Tests: test/local-model-ui.test.ts (JSDOM over the real markup), test/local-model-i18n.test.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
34e95ceb62
commit
1976431ce6
10 files changed
+1633
-9
No files matched your search
@@ -1407,6 +1407,56 @@
|
||||
'该 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 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。',
|
||||
Hide: '隐藏',
|
||||
'Hide this card on this device. It stays in App Settings, under Models.':
|
||||
'在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。',
|
||||
'Checking this Mac…': '正在检查此 Mac…',
|
||||
'{chip}, {memory} GB memory, {free} GB free': '{chip},内存 {memory} GB,可用磁盘空间 {free} GB',
|
||||
'{chip}, {memory} GB memory': '{chip},内存 {memory} GB',
|
||||
'{memory} GB memory, {free} GB free': '内存 {memory} GB,可用磁盘空间 {free} GB',
|
||||
'{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} 时,第一次回答可能需要几分钟。',
|
||||
'Other models that fit': '其他适合的模型',
|
||||
'Setting this up from Codeman is the next step. 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 上测试过。',
|
||||
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} 在此不推荐任何模型。',
|
||||
'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.':
|
||||
'可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,而当前可用空间为 {free} GB。请腾出约 {missing} GB。',
|
||||
'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,22 @@
|
||||
</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 route is admin-only in multi-user mode, so the module
|
||||
hides the whole group for a non-admin. Not gated on the endpoints toggle
|
||||
above: the answer does not depend on it. -->
|
||||
<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 ═════════════════════════════════════════ -->
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @fileoverview The "model on this Mac" card of the welcome screen and its row in App Settings.
|
||||
*
|
||||
* SKELETON: this file holds no code yet. It will read `GET /api/local-model` (hardware of the
|
||||
* machine the server runs on, the recommendation, the catalogue date) and show, read-only:
|
||||
* Reads `GET /api/local-model` (hardware of the machine the server runs on, the recommendation,
|
||||
* the catalogue date) and shows, read-only:
|
||||
* - the hardware line (chip, memory, free disk);
|
||||
* - the verdict: the headline and notes the server sent, then the pick with its numbers
|
||||
* (model, harness, context, memory need against the budget, download size, speed);
|
||||
@@ -10,24 +10,378 @@
|
||||
* - one line saying that setting the model up from Codeman is the next step and is not built
|
||||
* yet.
|
||||
*
|
||||
* ## 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, which is an answer: stop asking) and after the user hid it on this
|
||||
* device. The Settings row is where the loading and failure sentences show, and it is the way
|
||||
* back to the numbers after a Hide.
|
||||
*
|
||||
* Rules:
|
||||
* - Read-only: no button here downloads, installs or writes anything.
|
||||
* - Hidden when the server is not on macOS (`recommendation.reason` is `not-macos`), when the
|
||||
* request is refused (a non-admin in multi-user mode gets a 403) and after the user dismissed
|
||||
* the card on this device (a localStorage key, never a server setting).
|
||||
* - 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 and carry `data-i18n-skip`.
|
||||
* 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`, `memGb`,
|
||||
* `freeDiskGb`: decimal GB as text). Bytes are never divided here.
|
||||
* - A sentence the server chose arrives as `{ text, vars }` and goes through
|
||||
* `codemanT(text, vars)`; this file's own strings are literals in its translator call, so the
|
||||
* i18n test can harvest them. Both kinds need a zh-CN entry in i18n.js.
|
||||
* `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. */
|
||||
const LOCAL_MODEL_DISMISS_KEY = 'codeman:local-model-dismissed';
|
||||
|
||||
/** 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. Every part but the memory may be missing. */
|
||||
function localModelHardwareLine(hw) {
|
||||
const chip = String(hw.chip || hw.model || '');
|
||||
const sizes = { chip, memory: hw.memGb, free: hw.freeDiskGb };
|
||||
if (!hw.memGb) return chip;
|
||||
if (chip && hw.freeDiskGb) return localModelT('{chip}, {memory} GB memory, {free} GB free', sizes);
|
||||
if (chip) return localModelT('{chip}, {memory} GB memory', sizes);
|
||||
if (hw.freeDiskGb) return localModelT('{memory} GB memory, {free} GB free', sizes);
|
||||
return localModelT('{memory} GB memory', sizes);
|
||||
}
|
||||
|
||||
/** "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose. */
|
||||
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));
|
||||
for (const label of [candidate.fitLabel, candidate.speedLabel]) {
|
||||
if (typeof label !== 'string' || !label) continue;
|
||||
head.appendChild(localModelEl('span', 'local-model-pill', localModelT(label)));
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
/** The dim line under a candidate's name: context, memory need (alternatives), download, license (the pick). */
|
||||
function localModelMeta(candidate, { need = false, license = false } = {}) {
|
||||
const bits = [localModelT('Context {context} tokens', { context: localModelCount(candidate.context) })];
|
||||
if (need && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb }));
|
||||
if (candidate.downloadGb) bits.push(localModelT('{download} GB download', { download: candidate.downloadGb }));
|
||||
if (license && 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, { license: true })));
|
||||
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. */
|
||||
function localModelAlternatives(alternatives, fold) {
|
||||
const wrap = localModelEl(fold ? 'details' : 'div', 'local-model-alts');
|
||||
const title = localModelEl(fold ? 'summary' : 'div', 'local-model-alts-title');
|
||||
title.appendChild(localModelEl('span', '', localModelT('Other models that fit')));
|
||||
title.appendChild(localModelEl('span', 'local-model-count', String(alternatives.length)));
|
||||
wrap.appendChild(title);
|
||||
for (const candidate of alternatives) {
|
||||
const row = localModelEl('div', 'local-model-alt');
|
||||
row.appendChild(localModelCandidateHead(candidate));
|
||||
row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { need: true })));
|
||||
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));
|
||||
}
|
||||
const alternatives = pick && Array.isArray(recommendation.alternatives) ? recommendation.alternatives : [];
|
||||
if (alternatives.length) body.appendChild(localModelAlternatives(alternatives, fold));
|
||||
if (pick) {
|
||||
const next = localModelT('Setting this up from Codeman is the next step. It is not built yet.');
|
||||
body.appendChild(localModelEl('div', 'local-model-next', next));
|
||||
}
|
||||
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 403 (admin-only in multi-user mode) is an answer and must be told
|
||||
// apart from a request that failed.
|
||||
const res = await this._api(localModelUrl('/api/local-model'));
|
||||
if (res?.status === 403) {
|
||||
this._localModelState = 'refused';
|
||||
this._localModelStatus = null;
|
||||
this.renderLocalModel();
|
||||
return;
|
||||
}
|
||||
let body = null;
|
||||
if (res?.ok) {
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
}
|
||||
const data = localModelPayload(body);
|
||||
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';
|
||||
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 refused = this._localModelRefused();
|
||||
// The whole group goes for a non-admin, the way the other admin-only groups of this modal do.
|
||||
const group = document.getElementById('localModelGroup');
|
||||
if (group) group.style.display = refused ? 'none' : '';
|
||||
if (refused) return;
|
||||
const status = this._localModelStatus;
|
||||
if (status) {
|
||||
row.appendChild(localModelBody(status, { fold: false }));
|
||||
return;
|
||||
}
|
||||
const text =
|
||||
this._localModelState === 'failed'
|
||||
? localModelT('Could not reach the server.')
|
||||
: localModelT('Checking this Mac…');
|
||||
row.appendChild(localModelEl('div', 'local-model-headline', text));
|
||||
},
|
||||
|
||||
/** 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,203 @@ 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;
|
||||
}
|
||||
|
||||
.local-model-hw,
|
||||
.local-model-line,
|
||||
.local-model-note {
|
||||
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;
|
||||
}
|
||||
|
||||
.local-model-pill,
|
||||
.local-model-count {
|
||||
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;
|
||||
}
|
||||
|
||||
.local-model-count {
|
||||
margin-inline-start: 0.4rem;
|
||||
border-color: transparent;
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.local-model-next {
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* In App Settings the body sits inside an ordinary .set-row, so it takes that
|
||||
surface's type scale (label 0.76rem over a muted description) and the row
|
||||
supplies the border. */
|
||||
#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.
|
||||
|
||||
Reference in new issue
Block a user