mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix(local-model): say what is true of the Mac, and why a model is missing
Review fixes for the read-only recommendation and its card. Recommendation (pure): - `limited` is now a statement about the Mac: no model with 12 billion parameters or more fits. It is judged on everything that fits, before the disk filter and whatever the pick is. A 32 or 36 GB Mac whose pick is a 9B model no longer reads "This Mac can run a small model" above a list of 20B, 27B and 35B models, and knowing the chip no longer lowers the verdict. A small pick under `ok` carries a note that says so. - A model that fits the memory but has no room on the disk is no longer dropped in silence: the best-ranked one is named in a note with the GB that are missing. A full disk cannot turn a 48 GB Mac into a small-model Mac. - "Models fit, but not at a context a harness needs" has its own sentence with the budget and the need, worded as an estimate. The 16 GB Mac mini misses by 1.3 points (10.3 of 10.4 GB) and was told flatly that its memory is too little, the sentence of an 8 GB Mac. - The installed memory is named by the size the Mac is sold as (48, not 51.5): the hardware line and the memory sentence. Every computed size stays decimal GB from the one formatter. An unknown memory size is no longer reported as "0.0 GB". - Slow is graded on the unrounded speed: 9.95 to 9.9996 tokens per second printed as 10 and read ok. - The disk sentence names the 10 GiB that must stay free, so its numbers add up for the reader. Card and Settings group: - The Settings group "Model on this Mac" is hidden on a server that is not a Mac, and until the first answer says it is one. - Only `403 FORBIDDEN` is a refusal. `403 PASSWORD_CHANGE_REQUIRED` (an admin who must change the password) is a failed request and is asked again, so that admin no longer loses the card until a reload. - Each fit and speed label carries what it measures as its tooltip and accessible name, an alternative with a speed estimate prints it, and the count beside "Other models that fit" is gone (it was the number of rows, capped at four, not the number of models that fit). - The line saying nothing can be set up yet sits right under the pick, in the colour of every other line (it was 3.9:1), and says whose step it is. - The dismissal key is named for this stage, so hiding the read-only card does not hide the install offer later. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4739542397
commit
8281045cf2
10 files changed
+857
-211
No files matched your search
@@ -8,7 +8,11 @@
|
||||
* size for display, the browser included: it prints the text it was sent.
|
||||
*
|
||||
* GiB and MiB exist only for the budget math (reserves, the wired limit, the prompt cache) and
|
||||
* never reach a person.
|
||||
* never reach a person, with one exception: `memClassGb()`, the memory size a Mac is sold as,
|
||||
* is how the card names the installed memory ("48 GB memory", what About This Mac shows). It
|
||||
* is a name of the machine, not a computed size: `formatGb()` of the same bytes reads 51.5,
|
||||
* which no owner recognises. Every computed size (need, budget, download, free disk) is
|
||||
* `formatGb()`.
|
||||
*
|
||||
* Pure: no IO, no state.
|
||||
*/
|
||||
@@ -33,8 +37,10 @@ export function formatGb(bytes: number): string {
|
||||
|
||||
/**
|
||||
* The memory size a Mac is sold as: whole binary gigabytes (17179869184 bytes is 16). This is
|
||||
* `HardwareFacts.memClassGb`: the key of the tier rules ("below 36 GB") and of `TestedOn.memGb`.
|
||||
* It names a class of Mac. A size for display is `formatGb()`.
|
||||
* `HardwareFacts.memClassGb`: the key of the tier rules ("below 36 GB") and of `TestedOn.memGb`,
|
||||
* and what the card and the `noneMemory` sentence call the installed memory. It names a class
|
||||
* of Mac; 0 means the size is unknown and is never shown. Any other size for display is
|
||||
* `formatGb()`.
|
||||
*/
|
||||
export function memClassGb(memBytes: number): number {
|
||||
if (!Number.isFinite(memBytes) || memBytes <= 0) return 0;
|
||||
|
||||
@@ -10,33 +10,48 @@
|
||||
* catalogue for this Mac's memory.
|
||||
* - Fit: F/B up to 0.85 is good, up to 0.98 is tight, above is out.
|
||||
* - Speed: tokens per second = the model's `speedPer100Gbps` x the chip's bandwidth / 100.
|
||||
* Below 10 is slow. A chip that is in no chips row, or a model without a speed figure, is
|
||||
* UNKNOWN: a bandwidth is never guessed.
|
||||
* Below 10 is slow, decided on the unrounded product (9.96 is slow although it prints as
|
||||
* 10). A chip that is in no chips row, or a model without a speed figure, is UNKNOWN: a
|
||||
* bandwidth is never guessed.
|
||||
* - Candidates: (model, context, harness) with `ctx >= harness.minContext`, the harness not
|
||||
* gated, installed or installable, and accepting the model id.
|
||||
* - Ranking, lexicographic: good before tight; ok before unknown before slow; the higher model
|
||||
* rank; the higher harness rank; the larger context. The pick is the first candidate, the
|
||||
* alternatives are the next four MODELS, each at its own best context and harness.
|
||||
* - Disk: a model whose download would leave less than 10 GiB free is not offered. It is not
|
||||
* dropped in silence: the best-ranked model left out that way is named in `noteDiskDropped`
|
||||
* with what is missing.
|
||||
*
|
||||
* Verdict `none`, in this order (the first that applies is the reason):
|
||||
* 1. `not-macos`: the server does not run on macOS.
|
||||
* 2. `intel`: a Mac without Apple silicon.
|
||||
* 3. `macos`: macOS is older than the catalogue's minimum (an unknown version does not block).
|
||||
* 4. `memory`: no model fits the budget at any of its contexts, whatever the harness.
|
||||
* 4. `memory`: no model fits the budget at any of its contexts, whatever the harness
|
||||
* (`noneMemory`, or `noneMemoryUnknown` when the memory size could not be read).
|
||||
* 5. `no-harness`: no harness row is usable (not gated, known to the caller, installed or
|
||||
* installable).
|
||||
* 6. `memory`: models fit, but none at a context a usable harness needs.
|
||||
* 6. `memory`: models fit, but none at a context a usable harness needs. Its own sentence
|
||||
* (`noneMemoryContext`) with the budget and the smallest need at such a context, because
|
||||
* this Mac can miss by a hair (the 16 GB Mac mini: 10.3 of 10.4 GB) and "too little
|
||||
* memory" would hide that the answer rests on an estimate.
|
||||
* 7. `no-harness`: models fit at such a context, but no usable harness accepts their id.
|
||||
* 8. `disk`: for every remaining model, free disk minus the download is below 10 GiB (an
|
||||
* unknown free disk does not block).
|
||||
*
|
||||
* Rules:
|
||||
* - Verdict `limited` when the pick's model has fewer than 12 billion parameters.
|
||||
* - Verdict `limited` when NO model with 12 billion parameters or more fits this Mac's memory
|
||||
* at a context a usable harness works at. It is a statement about the Mac ("This Mac can run
|
||||
* a small model"), so it is judged on everything that fits, BEFORE the disk filter and
|
||||
* whatever the pick is: a 36 GB Mac whose pick is a 9B model while a 20B, a 27B and a 35B
|
||||
* are listed under it is not a small-model Mac, and a 48 GB Mac does not become one because
|
||||
* its disk is full. A small pick under verdict `ok` carries `noteSmallPick` instead.
|
||||
* - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for verdict `ok` with a
|
||||
* pick whose model has a `tested` entry for exactly this chip, this memory size and this
|
||||
* harness. Otherwise `headlineOk` (or `headlineLimited`) plus `noteUntested`.
|
||||
* - Every label in the result comes from `LOCAL_MODEL_LABELS`, every size text from
|
||||
* `formatGb()`. No number is spliced into a sentence here.
|
||||
* - Every label in the result comes from `LOCAL_MODEL_LABELS`, every computed size text from
|
||||
* `formatGb()`. The one size that is not decimal GB is the installed memory in `noneMemory`:
|
||||
* `HardwareFacts.memClassGb`, the size the Mac is sold as. No number is spliced into a
|
||||
* sentence here.
|
||||
* - No CLI id is ever compared with a literal: a harness is a catalogue row plus a
|
||||
* `HarnessFact`.
|
||||
*/
|
||||
@@ -52,6 +67,8 @@ import {
|
||||
type LocalHarnessEntry,
|
||||
type LocalModelCatalog,
|
||||
type LocalModelEntry,
|
||||
type LocalModelLabel,
|
||||
type LocalModelMessage,
|
||||
type NoneReason,
|
||||
type Recommendation,
|
||||
type SpeedGrade,
|
||||
@@ -65,7 +82,7 @@ export const FIT_GOOD_MAX_SHARE = 0.85;
|
||||
export const FIT_TIGHT_MAX_SHARE = 0.98;
|
||||
/** An estimated speed below this many tokens per second is `slow`. */
|
||||
export const SLOW_BELOW_TOKENS_PER_SECOND = 10;
|
||||
/** A pick whose model has fewer than this many billion parameters makes the verdict `limited`. */
|
||||
/** When no model that fits has at least this many billion parameters, the verdict is `limited`. A pick below it is a "small model". */
|
||||
export const LIMITED_BELOW_PARAMS_B = 12;
|
||||
/** What must stay free on the disk after the model download. */
|
||||
export const DISK_HEADROOM_BYTES = 10 * GIB;
|
||||
@@ -134,9 +151,10 @@ function round(value: number, decimals: number): number {
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
/** A candidate plus the two ranks the ordering needs, which are not part of the payload. */
|
||||
/** A candidate plus its catalogue row and the two ranks the ordering needs, none of which is part of the payload. */
|
||||
interface Ranked {
|
||||
candidate: Candidate;
|
||||
model: LocalModelEntry;
|
||||
modelRank: number;
|
||||
harnessRank: number;
|
||||
}
|
||||
@@ -181,12 +199,12 @@ function buildCandidate(
|
||||
budgetBytes: number
|
||||
): Candidate {
|
||||
const gbps = chipBandwidthGbps(catalog.chips, hw.chip);
|
||||
const tokensPerSecond =
|
||||
gbps !== undefined && model.speedPer100Gbps !== undefined
|
||||
? round((model.speedPer100Gbps * gbps) / 100, 1)
|
||||
: undefined;
|
||||
const rawSpeed =
|
||||
gbps !== undefined && model.speedPer100Gbps !== undefined ? (model.speedPer100Gbps * gbps) / 100 : undefined;
|
||||
// Graded on the unrounded figure: 9.96 rounds to 10.0 for the payload and is still slow.
|
||||
const speed: SpeedGrade =
|
||||
tokensPerSecond === undefined ? 'unknown' : tokensPerSecond < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok';
|
||||
rawSpeed === undefined ? 'unknown' : rawSpeed < SLOW_BELOW_TOKENS_PER_SECOND ? 'slow' : 'ok';
|
||||
const tokensPerSecond = rawSpeed === undefined ? undefined : round(rawSpeed, 1);
|
||||
// "A Mac like this" is exact: the same chip string, the same memory size as sold, the same harness.
|
||||
const tested = model.tested.some(
|
||||
(entry) => entry.chip === hw.chip && entry.memGb === hw.memClassGb && entry.cli === harness.row.cli
|
||||
@@ -216,11 +234,17 @@ function buildCandidate(
|
||||
};
|
||||
}
|
||||
|
||||
function none(reason: NoneReason, budget: DeviceBudget, vars?: Record<string, string>): Recommendation {
|
||||
/** A `none` answer. `text` replaces the reason's default sentence (the `memory` reason has three). */
|
||||
function none(
|
||||
reason: NoneReason,
|
||||
budget: DeviceBudget,
|
||||
vars?: Record<string, string>,
|
||||
text: LocalModelLabel = LOCAL_MODEL_NONE_LABELS[reason]
|
||||
): Recommendation {
|
||||
return {
|
||||
verdict: 'none',
|
||||
reason,
|
||||
headline: { text: LOCAL_MODEL_NONE_LABELS[reason], ...(vars ? { vars } : {}) },
|
||||
headline: { text, ...(vars ? { vars } : {}) },
|
||||
notes: [],
|
||||
alternatives: [],
|
||||
budgetBytes: budget.budgetBytes,
|
||||
@@ -260,7 +284,12 @@ export function recommend(
|
||||
if (fit !== 'out') fitting.push({ model, ctx, need, fit });
|
||||
}
|
||||
}
|
||||
const tooLittleMemory = (): Recommendation => none('memory', budget, { memory: hw.memGb });
|
||||
// Nothing fits at any context. The installed memory is named by the size the Mac is sold
|
||||
// as; a size that could not be read is not stated at all (never "0 GB of memory").
|
||||
const tooLittleMemory = (): Recommendation =>
|
||||
Number.isFinite(hw.memClassGb) && hw.memClassGb > 0
|
||||
? none('memory', budget, { memory: String(hw.memClassGb) })
|
||||
: none('memory', budget, undefined, LOCAL_MODEL_LABELS.noneMemoryUnknown);
|
||||
if (fitting.length === 0) return tooLittleMemory();
|
||||
|
||||
// A gated row is never offered, whatever the caller knows about its CLI.
|
||||
@@ -275,25 +304,46 @@ export function recommend(
|
||||
const atHarnessContext = fitting.flatMap((entry) =>
|
||||
usable.filter((harness) => entry.ctx >= harness.row.minContext).map((harness) => ({ ...entry, harness }))
|
||||
);
|
||||
if (atHarnessContext.length === 0) return tooLittleMemory();
|
||||
if (atHarnessContext.length === 0) {
|
||||
// Models fit, but only below the context a harness needs. Say both numbers: the smallest
|
||||
// need at such a context can sit a hair over the line, and the budget is an estimate.
|
||||
const smallestContext = Math.min(...usable.map((harness) => harness.row.minContext));
|
||||
let smallestNeed: number | undefined;
|
||||
for (const model of catalog.models) {
|
||||
for (const ctx of model.contexts) {
|
||||
if (ctx < smallestContext) continue;
|
||||
const need = needBytes(model, ctx, catalog, hw);
|
||||
if (smallestNeed === undefined || need < smallestNeed) smallestNeed = need;
|
||||
}
|
||||
}
|
||||
if (smallestNeed === undefined) return tooLittleMemory();
|
||||
return none(
|
||||
'memory',
|
||||
budget,
|
||||
{ budget: formatGb(budget.budgetBytes), need: formatGb(smallestNeed) },
|
||||
LOCAL_MODEL_LABELS.noneMemoryContext
|
||||
);
|
||||
}
|
||||
|
||||
const accepted = atHarnessContext.filter((entry) => accepts(entry.harness.fact, entry.model.id));
|
||||
if (accepted.length === 0) return none('no-harness', budget);
|
||||
|
||||
// The download must leave headroom on the disk. An unknown free disk does not block.
|
||||
const free = hw.freeDiskBytes;
|
||||
const downloadable =
|
||||
free === undefined ? accepted : accepted.filter((entry) => free - entry.model.bytes >= DISK_HEADROOM_BYTES);
|
||||
if (free !== undefined && downloadable.length === 0) {
|
||||
const hasRoom = (model: LocalModelEntry): boolean => free === undefined || free - model.bytes >= DISK_HEADROOM_BYTES;
|
||||
if (free !== undefined && !accepted.some((entry) => hasRoom(entry.model))) {
|
||||
const smallest = Math.min(...accepted.map((entry) => entry.model.bytes));
|
||||
return none('disk', budget, {
|
||||
download: formatGb(smallest),
|
||||
reserve: formatGb(DISK_HEADROOM_BYTES),
|
||||
free: hw.freeDiskGb ?? formatGb(free),
|
||||
missing: formatGb(smallest + DISK_HEADROOM_BYTES - free),
|
||||
});
|
||||
}
|
||||
|
||||
const ranked: Ranked[] = downloadable
|
||||
// Everything that fits is ranked, the models without room on the disk included: the best of
|
||||
// those is named in a note, so a full disk never removes a model without a word.
|
||||
const ranked: Ranked[] = accepted
|
||||
.map((entry) => ({
|
||||
candidate: buildCandidate(
|
||||
entry.model,
|
||||
@@ -305,28 +355,47 @@ export function recommend(
|
||||
catalog,
|
||||
budget.budgetBytes
|
||||
),
|
||||
model: entry.model,
|
||||
modelRank: entry.model.rank,
|
||||
harnessRank: entry.harness.row.rank,
|
||||
}))
|
||||
.sort(compareRanked);
|
||||
|
||||
// The first candidate of each model is that model at its best context and harness.
|
||||
const bestPerModel: Candidate[] = [];
|
||||
for (const { candidate } of ranked) {
|
||||
if (!bestPerModel.some((kept) => kept.modelId === candidate.modelId)) bestPerModel.push(candidate);
|
||||
const bestPerModel: Ranked[] = [];
|
||||
for (const entry of ranked) {
|
||||
if (!bestPerModel.some((kept) => kept.model.id === entry.model.id)) bestPerModel.push(entry);
|
||||
}
|
||||
const [pick, ...others] = bestPerModel;
|
||||
const offered = bestPerModel.filter((entry) => hasRoom(entry.model)).map((entry) => entry.candidate);
|
||||
const leftOutForDisk = bestPerModel.find((entry) => !hasRoom(entry.model));
|
||||
const [pick, ...others] = offered;
|
||||
|
||||
const limited = pick.paramsB < LIMITED_BELOW_PARAMS_B;
|
||||
// About the Mac, not the pick, and judged before the disk filter (see the file header).
|
||||
const limited = !accepted.some((entry) => entry.model.paramsB >= LIMITED_BELOW_PARAMS_B);
|
||||
const headline = limited
|
||||
? LOCAL_MODEL_LABELS.headlineLimited
|
||||
: pick.tested
|
||||
? LOCAL_MODEL_LABELS.headlineTested
|
||||
: LOCAL_MODEL_LABELS.headlineOk;
|
||||
|
||||
const notes: LocalModelMessage[] = [];
|
||||
// The limited headline already says it. Under `ok` a small pick must still be called small.
|
||||
if (!limited && pick.paramsB < LIMITED_BELOW_PARAMS_B) notes.push({ text: LOCAL_MODEL_LABELS.noteSmallPick });
|
||||
if (leftOutForDisk && free !== undefined) {
|
||||
notes.push({
|
||||
text: LOCAL_MODEL_LABELS.noteDiskDropped,
|
||||
vars: {
|
||||
model: leftOutForDisk.model.label,
|
||||
missing: formatGb(leftOutForDisk.model.bytes + DISK_HEADROOM_BYTES - free),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!pick.tested) notes.push({ text: LOCAL_MODEL_LABELS.noteUntested });
|
||||
|
||||
return {
|
||||
verdict: limited ? 'limited' : 'ok',
|
||||
headline: { text: headline },
|
||||
notes: pick.tested ? [] : [{ text: LOCAL_MODEL_LABELS.noteUntested }],
|
||||
notes,
|
||||
pick,
|
||||
alternatives: others.slice(0, MAX_ALTERNATIVES),
|
||||
budgetBytes: budget.budgetBytes,
|
||||
|
||||
+61
-25
@@ -15,10 +15,12 @@
|
||||
*
|
||||
* Three rules every producer and consumer follows:
|
||||
*
|
||||
* 1. ONE unit for a size a person reads: decimal GB, computed from bytes by `formatGb()` in
|
||||
* src/local-model/format.ts. Every such size travels twice, as bytes (`needBytes`) and as the
|
||||
* text to show (`needGb`, a number without the unit, such as '25.5'). The browser prints the
|
||||
* text and never divides bytes itself. GiB appears only inside the budget math.
|
||||
* 1. ONE unit for a computed size a person reads: decimal GB, computed from bytes by
|
||||
* `formatGb()` in src/local-model/format.ts. Every such size travels twice, as bytes
|
||||
* (`needBytes`) and as the text to show (`needGb`, a number without the unit, such as
|
||||
* '25.5'). The browser prints the text and never divides bytes itself. GiB appears only
|
||||
* inside the budget math. The one exception is the NAME of the installed memory: the card
|
||||
* says `HardwareFacts.memClassGb` (48 for a Mac sold with 48 GB), see that field.
|
||||
* 2. Every sentence or label the SERVER chooses comes from `LOCAL_MODEL_LABELS` below, so the
|
||||
* browser can translate it (`codemanT(text, vars)`) and its i18n test can list every string.
|
||||
* Numbers are never spliced into a sentence here: they travel in `vars` and fill the
|
||||
@@ -33,7 +35,8 @@
|
||||
* How `HardwareFacts` was obtained.
|
||||
* - `sysctl`: the spawn answered and `hw.memsize` parsed.
|
||||
* - `os-fallback`: on macOS, but the spawn failed or timed out; memory, architecture and the chip
|
||||
* string come from `node:os`, the rest is absent.
|
||||
* string come from `node:os` and the free disk from `statfs`. What only sysctl knows (`arm64`,
|
||||
* `translated`, `model`, `macosVersion`, `macosMajor`, `wiredLimitMb`) is absent.
|
||||
* - `not-probed`: not macOS, so `sysctl` was never spawned.
|
||||
*/
|
||||
export type HardwareProbeSource = 'sysctl' | 'os-fallback' | 'not-probed';
|
||||
@@ -54,9 +57,19 @@ export interface HardwareFacts {
|
||||
translated?: boolean;
|
||||
/** Installed memory in bytes (`hw.memsize`, else `os.totalmem()`). The ONLY memory input of the budget math. */
|
||||
memBytes: number;
|
||||
/** `memBytes` as a person reads it: decimal GB from `formatGb()`, without the unit ('17.2' for a Mac sold as 16 GB). */
|
||||
/**
|
||||
* `memBytes` in the unit of every computed size: decimal GB from `formatGb()`, without the unit
|
||||
* ('17.2' for a Mac sold as 16 GB). NOT what the card calls the installed memory: that is
|
||||
* `memClassGb`, see there.
|
||||
*/
|
||||
memGb: string;
|
||||
/** The memory size the Mac is sold as: whole binary gigabytes (16, 32, 48). Tier rules and `TestedOn.memGb` use it. */
|
||||
/**
|
||||
* The memory size the Mac is sold as: whole binary gigabytes (16, 32, 48), 0 when the size is
|
||||
* unknown. Tier rules and `TestedOn.memGb` use it, and it is the ONE size a person reads that
|
||||
* is not decimal GB: the hardware line and the `memory` slot of `noneMemory` print it, because
|
||||
* it is the number on the box and in About This Mac ("48 GB"), and '51.5 GB memory' reads as a
|
||||
* different machine. Every computed size (need, budget, download, free disk) stays decimal GB.
|
||||
*/
|
||||
memClassGb: number;
|
||||
/** `machdep.cpu.brand_string` ('Apple M4 Pro'), else `os.cpus()[0].model`. */
|
||||
chip?: string;
|
||||
@@ -97,7 +110,7 @@ export interface LocalModelEntry {
|
||||
label: string;
|
||||
/** SPDX id of the license ('Apache-2.0'). */
|
||||
license: string;
|
||||
/** Parameter count in billions. Under 12 the verdict is `limited`, never `ok`. */
|
||||
/** Parameter count in billions. When no model that fits has 12 or more, the verdict is `limited`, never `ok`. */
|
||||
paramsB: number;
|
||||
/** Size of the weights in bytes: both the download and what they take in memory. */
|
||||
bytes: number;
|
||||
@@ -200,7 +213,10 @@ export type SpeedGrade = 'ok' | 'slow' | 'unknown';
|
||||
* Why the verdict is `none`.
|
||||
* - `not-macos`: the server does not run on macOS (the card is hidden).
|
||||
* - `intel`: a Mac without Apple silicon.
|
||||
* - `memory`: no model fits the memory budget with any harness.
|
||||
* - `memory`: no model fits the memory budget at a context a usable harness needs. Three
|
||||
* headlines share this reason: `noneMemory` (nothing fits at any context), `noneMemoryContext`
|
||||
* (a model fits, but only below the context a harness needs: the sentence carries the budget
|
||||
* and the need) and `noneMemoryUnknown` (the memory size could not be read).
|
||||
* - `disk`: models fit the memory, but for every one of them free disk minus the download is below 10 GiB.
|
||||
* - `macos`: macOS is older than `LocalModelCatalog.minMacosMajor`.
|
||||
* - `no-harness`: no catalogue harness is installed or installable and accepts a model.
|
||||
@@ -228,12 +244,17 @@ export interface DeviceBudget {
|
||||
* `{placeholder}` slots are filled by the browser from `LocalModelMessage.vars`. `{name}` is
|
||||
* taken (the translator fills it with the display name), so no slot here is called `name`.
|
||||
*
|
||||
* | Key | Slots (all text; sizes are decimal GB without the unit) |
|
||||
* |---------------|---------------------------------------------------------------------------------|
|
||||
* | `noneMemory` | `memory`: `HardwareFacts.memGb` |
|
||||
* | `noneDisk` | `download`: smallest download among the models that fit the memory; `free`: |
|
||||
* | | `HardwareFacts.freeDiskGb`; `missing`: download + 10 GiB - free |
|
||||
* | `noneMacos` | `version`: `HardwareFacts.macosVersion`; `minimum`: the catalogue's minimum |
|
||||
* | Key | Slots (all text; sizes are decimal GB without the unit) |
|
||||
* |---------------------|---------------------------------------------------------------------------|
|
||||
* | `noteDiskDropped` | `model`: label of the best model left out for disk space; `missing`: its |
|
||||
* | | download + the disk reserve - free |
|
||||
* | `noneMemory` | `memory`: `HardwareFacts.memClassGb`, the size the Mac is sold as |
|
||||
* | `noneMemoryContext` | `budget`: `Recommendation.budgetGb`; `need`: the smallest need of any |
|
||||
* | | model at a context a usable harness works at |
|
||||
* | `noneDisk` | `download`: smallest download among the models that fit the memory; |
|
||||
* | | `reserve`: what must stay free after it (10 GiB, as decimal GB); `free`: |
|
||||
* | | `HardwareFacts.freeDiskGb`; `missing`: download + reserve - free |
|
||||
* | `noneMacos` | `version`: `HardwareFacts.macosVersion`; `minimum`: the catalogue minimum |
|
||||
*
|
||||
* The word "Recommended" exists only in `headlineTested`, which may be used only when the
|
||||
* pick's `tested` is true. No catalogue row has a tested entry today, so it is never sent.
|
||||
@@ -243,21 +264,33 @@ export const LOCAL_MODEL_LABELS = {
|
||||
headlineOk: 'Best fit for this Mac',
|
||||
/** Verdict `ok`, pick tested by us on a Mac like this one. */
|
||||
headlineTested: 'Recommended for this Mac',
|
||||
/** Verdict `limited`. */
|
||||
/** Verdict `limited`: no model with 12 billion parameters or more fits this Mac. */
|
||||
headlineLimited:
|
||||
'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.',
|
||||
/** A note under an `ok` or `limited` pick whose `tested` is false. */
|
||||
noteUntested: 'Not yet tested by us on a Mac like this.',
|
||||
/** A note under an `ok` pick that is itself a small model: larger ones fit this Mac, but rank lower or have no room on the disk. */
|
||||
noteSmallPick:
|
||||
'The best fit is a small model. It handles small edits and questions. It does not replace a cloud model.',
|
||||
/** A note when a model that fits the memory was left out because its download would not leave the disk reserve. */
|
||||
noteDiskDropped:
|
||||
'{model} fits the memory of this Mac too, but its download needs about {missing} GB more free disk space.',
|
||||
fitGood: 'Good',
|
||||
fitTight: 'Tight',
|
||||
speedSlow: 'Slow',
|
||||
speedUnknown: 'Speed unknown',
|
||||
noneNotMacos: 'Codeman recommends a local model only on a Mac with Apple silicon.',
|
||||
noneIntel: 'This Mac has an Intel processor. Local coding models need Apple silicon.',
|
||||
/** Reason `memory`: nothing fits at any context. */
|
||||
noneMemory:
|
||||
'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.',
|
||||
/** Reason `memory`: a model fits, but only below the context a harness needs. Says both numbers, and that they are an estimate. */
|
||||
noneMemoryContext:
|
||||
'Codeman estimates that this Mac can give a model about {budget} GB of memory. The smallest model a coding agent can work with needs about {need} GB. That leaves no safe margin, so Codeman recommends none here.',
|
||||
/** Reason `memory`: the memory size could not be read, so no size is stated. */
|
||||
noneMemoryUnknown: 'Codeman could not read how much memory this Mac has, so it recommends no model here.',
|
||||
noneDisk:
|
||||
'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.',
|
||||
'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download, and {reserve} GB must stay free after it. {free} GB is free now. Free up about {missing} GB.',
|
||||
noneMacos: 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.',
|
||||
noneNoHarness: 'No coding agent that Codeman can connect to a local model is available on this Mac.',
|
||||
} as const;
|
||||
@@ -265,7 +298,7 @@ export const LOCAL_MODEL_LABELS = {
|
||||
/** One of the texts in `LOCAL_MODEL_LABELS`. The payload's label fields are typed with it, so an off-list string does not compile. */
|
||||
export type LocalModelLabel = (typeof LOCAL_MODEL_LABELS)[keyof typeof LOCAL_MODEL_LABELS];
|
||||
|
||||
/** The headline of each `none` reason. */
|
||||
/** The headline of each `none` reason. `memory` has two more (`noneMemoryContext`, `noneMemoryUnknown`), see `NoneReason`. */
|
||||
export const LOCAL_MODEL_NONE_LABELS: Record<NoneReason, LocalModelLabel> = {
|
||||
'not-macos': LOCAL_MODEL_LABELS.noneNotMacos,
|
||||
intel: LOCAL_MODEL_LABELS.noneIntel,
|
||||
@@ -317,7 +350,7 @@ export interface Candidate {
|
||||
speed: SpeedGrade;
|
||||
/** `speedSlow` or `speedUnknown`. Absent when `speed` is `ok`. */
|
||||
speedLabel?: LocalModelLabel;
|
||||
/** Estimated generation speed. Present exactly when `speed` is not `unknown`. */
|
||||
/** Estimated generation speed, rounded to one decimal. Present exactly when `speed` is not `unknown`. `speed` is decided on the unrounded figure. */
|
||||
tokensPerSecond?: number;
|
||||
/** A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this harness. */
|
||||
tested: boolean;
|
||||
@@ -325,22 +358,25 @@ export interface Candidate {
|
||||
|
||||
/**
|
||||
* The answer of `recommend()`.
|
||||
* - `ok`: `pick` is the best candidate and its model has at least 12 billion parameters.
|
||||
* - `limited`: `pick` is the best candidate, but its model has fewer than 12 billion parameters.
|
||||
* The card says so and never says "Recommended".
|
||||
* - `ok`: `pick` is the best candidate, and at least one model with 12 billion parameters or
|
||||
* more fits this Mac's memory at a context a usable harness works at. The pick itself can be
|
||||
* smaller (a larger model may fit only tightly, have no speed figure, or have no room on the
|
||||
* disk): `notes` then carries `noteSmallPick`.
|
||||
* - `limited`: `pick` is the best candidate, and NO model with 12 billion parameters or more
|
||||
* fits. The card says so and never says "Recommended".
|
||||
* - `none`: no pick; `reason` says why.
|
||||
*/
|
||||
export interface Recommendation {
|
||||
verdict: 'ok' | 'limited' | 'none';
|
||||
/** Present exactly when `verdict` is `none`. */
|
||||
reason?: NoneReason;
|
||||
/** What the card says first: `headlineOk`, `headlineTested`, `headlineLimited` or the `none` reason's sentence. */
|
||||
/** What the card says first: `headlineOk`, `headlineTested`, `headlineLimited` or a `none` sentence. */
|
||||
headline: LocalModelMessage;
|
||||
/** Further sentences under the headline (`noteUntested` today). May be empty. */
|
||||
/** Further sentences under the pick, in this order: `noteSmallPick`, `noteDiskDropped`, `noteUntested`. May be empty. */
|
||||
notes: LocalModelMessage[];
|
||||
/** Present exactly when `verdict` is `ok` or `limited`. */
|
||||
pick?: Candidate;
|
||||
/** The next models that fit (at most four, each at its best context and harness), best first. Empty when `verdict` is `none`. */
|
||||
/** The next models that fit and have room on the disk (at most four, each at its best context and harness), best first. Empty when `verdict` is `none`. */
|
||||
alternatives: Candidate[];
|
||||
/** `DeviceBudget.budgetBytes`. 0 when no budget is computed: not macOS, or a Mac without Apple silicon. */
|
||||
budgetBytes: number;
|
||||
|
||||
+21
-7
@@ -1412,10 +1412,11 @@
|
||||
'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.':
|
||||
'在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。',
|
||||
'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',
|
||||
@@ -1431,9 +1432,14 @@
|
||||
'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} 中完成安装和设置,该功能尚未实现。',
|
||||
'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 的选择',
|
||||
@@ -1441,8 +1447,12 @@
|
||||
'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: '紧张',
|
||||
Tight: '偏紧',
|
||||
Slow: '较慢',
|
||||
'Speed unknown': '速度未知',
|
||||
'Codeman recommends a local model only on a Mac with Apple silicon.':
|
||||
@@ -1451,8 +1461,12 @@
|
||||
'此 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。',
|
||||
'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.':
|
||||
|
||||
@@ -2879,9 +2879,11 @@
|
||||
<!-- 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. -->
|
||||
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>
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
* 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);
|
||||
* - the other models that fit, each with its fit and speed label;
|
||||
* - one line saying that setting the model up from Codeman is the next step and is not built
|
||||
* yet.
|
||||
* - 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
|
||||
*
|
||||
@@ -23,9 +25,18 @@
|
||||
* 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.
|
||||
* 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
|
||||
@@ -36,8 +47,10 @@
|
||||
* (`<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.
|
||||
* - 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
|
||||
@@ -52,8 +65,12 @@
|
||||
* @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';
|
||||
/**
|
||||
* 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;
|
||||
@@ -100,18 +117,41 @@ function localModelNotAdmin() {
|
||||
return !!(me.multiUser && me.role !== 'admin');
|
||||
}
|
||||
|
||||
/** Chip, memory and free disk in one line. Every part but the memory may be missing. */
|
||||
/**
|
||||
* 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 sizes = { chip, memory: hw.memGb, free: hw.freeDiskGb };
|
||||
if (!hw.memGb) return chip;
|
||||
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);
|
||||
}
|
||||
|
||||
/** "Qwen3.8 27B (qwen3.8:27b) with OpenCode", then the fit and speed labels the server chose. */
|
||||
/** 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}', {
|
||||
@@ -120,19 +160,36 @@ function localModelCandidateHead(candidate) {
|
||||
harness: candidate.cliLabel || candidate.cli,
|
||||
});
|
||||
head.appendChild(localModelEl('span', 'local-model-name', name));
|
||||
for (const label of [candidate.fitLabel, candidate.speedLabel]) {
|
||||
const pills = [
|
||||
[candidate.fitLabel, localModelFitHint],
|
||||
[candidate.speedLabel, localModelSpeedHint],
|
||||
];
|
||||
for (const [label, hint] of pills) {
|
||||
if (typeof label !== 'string' || !label) continue;
|
||||
head.appendChild(localModelEl('span', 'local-model-pill', localModelT(label)));
|
||||
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, memory need (alternatives), download, license (the pick). */
|
||||
function localModelMeta(candidate, { need = false, license = false } = {}) {
|
||||
/**
|
||||
* 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 (need && candidate.needGb) bits.push(localModelT('{need} GB of memory', { need: candidate.needGb }));
|
||||
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 }));
|
||||
if (license && candidate.license) bits.push(String(candidate.license));
|
||||
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(' · ');
|
||||
}
|
||||
|
||||
@@ -140,7 +197,7 @@ function localModelMeta(candidate, { need = false, license = false } = {}) {
|
||||
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 })));
|
||||
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 =
|
||||
@@ -165,17 +222,19 @@ function localModelPick(pick, recommendation) {
|
||||
return box;
|
||||
}
|
||||
|
||||
/** The other models that fit. Folded behind a native disclosure on the welcome card, open in Settings. */
|
||||
/**
|
||||
* 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');
|
||||
title.appendChild(localModelEl('span', '', localModelT('Other models that fit')));
|
||||
title.appendChild(localModelEl('span', 'local-model-count', String(alternatives.length)));
|
||||
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, { need: true })));
|
||||
row.appendChild(localModelEl('div', 'local-model-line', localModelMeta(candidate, { alternative: true })));
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
return wrap;
|
||||
@@ -200,12 +259,16 @@ function localModelBody(status, { fold }) {
|
||||
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));
|
||||
// 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('Setting this up from Codeman is the next step. It is not built yet.');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -249,24 +312,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
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.
|
||||
// `_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'));
|
||||
if (res?.status === 403) {
|
||||
this._localModelState = 'refused';
|
||||
this._localModelStatus = null;
|
||||
this.renderLocalModel();
|
||||
return;
|
||||
}
|
||||
let body = null;
|
||||
if (res?.ok) {
|
||||
if (res?.ok || res?.status === 403) {
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
}
|
||||
const data = localModelPayload(body);
|
||||
// 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';
|
||||
@@ -336,6 +402,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
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';
|
||||
@@ -353,21 +421,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_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;
|
||||
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;
|
||||
}
|
||||
const text =
|
||||
this._localModelState === 'failed'
|
||||
? localModelT('Could not reach the server.')
|
||||
: localModelT('Checking this Mac…');
|
||||
row.appendChild(localModelEl('div', 'local-model-headline', text));
|
||||
row.appendChild(localModelEl('div', 'local-model-headline', localModelT('Could not reach the server.')));
|
||||
},
|
||||
|
||||
/** Listeners that outlive a render, wired once. */
|
||||
|
||||
@@ -22376,9 +22376,13 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
|
||||
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-note,
|
||||
.local-model-next {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-dim);
|
||||
@@ -22418,8 +22422,8 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.local-model-pill,
|
||||
.local-model-count {
|
||||
/* 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;
|
||||
@@ -22428,12 +22432,7 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
|
||||
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);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.local-model-alts-title {
|
||||
@@ -22466,12 +22465,6 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
|
||||
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. */
|
||||
|
||||
Reference in new issue
Block a user