mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
feat(local-model): types and module skeleton for the hardware-aware recommendation
The contract three builders code against in parallel. Nothing is recommended yet: the route answers 501 and the catalogue is empty. - src/types/local-model.ts: HardwareFacts, the catalogue rows, HarnessFact, Candidate, Recommendation, LocalModelStatus, and LOCAL_MODEL_LABELS, the one list of sentences the server may put into the payload (the zh-CN dictionary is keyed by them). - src/local-model/format.ts: formatGb, the one place a byte count becomes a size a person reads (decimal GB), and memClassGb. - src/config/local-model-catalog.ts: the Zod schema (model ids are name:tag, lowercase, no slash) and an empty stock catalogue. No override file. - Skeletons with their final signatures: hardware.ts, recommend.ts, harness-facts.ts, routes/local-model-routes.ts (injectable dependencies), public/local-model-ui.js with its script tag. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
e9b2ecd552
commit
73ce6a1819
12 files changed
+824
No files matched your search
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @fileoverview The local model catalogue: which models, harnesses and chips the recommendation
|
||||
* knows, as a compiled constant validated by a Zod schema.
|
||||
*
|
||||
* SKELETON: the schema below is the shape of the contract (it mirrors `LocalModelCatalog` in
|
||||
* src/types/local-model.ts) and the stock constant is EMPTY. Still to come here:
|
||||
* - the model rows (ids and byte sizes from the registry manifests, `paramsB`, an empty
|
||||
* `tested` array each; `flint:40b` stays out), the harness rows and the chips table;
|
||||
* - the cross-row rules the schema cannot say field by field: unique model ids, unique harness
|
||||
* `cli`s, `contexts` largest first, `chips` most specific first, `promptCacheTiers` ascending
|
||||
* and starting at 0;
|
||||
* - `getLocalModelCatalog()` parsing the constant once, on first use, never at import.
|
||||
*
|
||||
* Rules that hold from the start:
|
||||
* - There is NO override file and no runtime download data in this stage: nothing here reads
|
||||
* the data dir, and a data file can never decide what gets recommended.
|
||||
* - A model id is `name:tag` in lowercase with no slash (`LOCAL_MODEL_ID_PATTERN`), so it can
|
||||
* only ever name a model of the default registry and namespace.
|
||||
* - A harness is a ROW (`{ cli, minContext, rank, gated?, slowFirstTurn? }`). The `cli` is a CLI
|
||||
* registry id as data: no code may compare it with a literal. Whether that CLI exists and can
|
||||
* take a custom model is checked against the registry at call time, in
|
||||
* src/local-model/harness-facts.ts, never here.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { LocalModelCatalog } from '../types.js';
|
||||
|
||||
/** A model id: lowercase name, one colon, a tag. No slash, so no registry host or namespace can ride along. */
|
||||
export const LOCAL_MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*:[a-z0-9][a-z0-9._-]*$/;
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be an ISO date (YYYY-MM-DD)');
|
||||
const positiveInt = z.number().int().positive();
|
||||
|
||||
const TestedOnSchema = z
|
||||
.object({
|
||||
chip: z.string().min(1).max(80),
|
||||
memGb: positiveInt,
|
||||
cli: z.string().min(1).max(24),
|
||||
date: isoDate,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const LocalModelEntrySchema = z
|
||||
.object({
|
||||
id: z.string().max(100).regex(LOCAL_MODEL_ID_PATTERN, 'must be name:tag in lowercase, without a slash'),
|
||||
label: z.string().min(1).max(80),
|
||||
license: z.string().min(1).max(40),
|
||||
paramsB: z.number().positive(),
|
||||
bytes: positiveInt,
|
||||
kvBytesPerToken: positiveInt,
|
||||
contexts: z.array(positiveInt).min(1),
|
||||
rank: z.number().int(),
|
||||
speedPer100Gbps: z.number().positive().optional(),
|
||||
tested: z.array(TestedOnSchema),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const LocalHarnessEntrySchema = z
|
||||
.object({
|
||||
cli: z.string().min(1).max(24),
|
||||
minContext: positiveInt,
|
||||
rank: z.number().int(),
|
||||
gated: z.boolean().optional(),
|
||||
slowFirstTurn: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ChipEntrySchema = z
|
||||
.object({
|
||||
match: z.string().min(1).max(40),
|
||||
gbps: z.number().positive(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const PromptCacheTierSchema = z
|
||||
.object({
|
||||
minMemGb: z.number().int().nonnegative(),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Validates a whole catalogue. Typed against `LocalModelCatalog`, so the two cannot drift apart. */
|
||||
export const LocalModelCatalogSchema: z.ZodType<LocalModelCatalog> = z
|
||||
.object({
|
||||
schema: z.literal(1),
|
||||
updated: isoDate,
|
||||
minMacosMajor: positiveInt,
|
||||
promptCacheTiers: z.array(PromptCacheTierSchema).min(1),
|
||||
models: z.array(LocalModelEntrySchema),
|
||||
harnesses: z.array(LocalHarnessEntrySchema),
|
||||
chips: z.array(ChipEntrySchema),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* The catalogue that ships with Codeman. EMPTY for now (no model, harness or chip rows), but
|
||||
* valid: with it `recommend()` can only answer verdict `none`.
|
||||
*
|
||||
* The two constants that are already decided are filled in: macOS 14 is the oldest version the
|
||||
* runtime supports, and the prompt cache is 512 MiB on a Mac below 32 GB and 2 GiB from 32 GB.
|
||||
*/
|
||||
export const STOCK_LOCAL_MODEL_CATALOG: LocalModelCatalog = {
|
||||
schema: 1,
|
||||
updated: '2026-11-06',
|
||||
minMacosMajor: 14,
|
||||
promptCacheTiers: [
|
||||
{ minMemGb: 0, bytes: 512 * 1024 ** 2 },
|
||||
{ minMemGb: 32, bytes: 2 * 1024 ** 3 },
|
||||
],
|
||||
models: [],
|
||||
harnesses: [],
|
||||
chips: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* The catalogue in force. There is no override in this stage, so this is the stock constant;
|
||||
* callers go through this function anyway, so a later stage can change where it comes from
|
||||
* without touching them.
|
||||
*/
|
||||
export function getLocalModelCatalog(): LocalModelCatalog {
|
||||
return STOCK_LOCAL_MODEL_CATALOG;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @fileoverview The formatters of the local model recommendation, and its byte units.
|
||||
*
|
||||
* `formatGb()` is the ONE place a byte count becomes a size a person reads: decimal GB (the
|
||||
* unit the Finder and the model registry use). `hardware.ts` and `recommend.ts` call it to fill
|
||||
* the `...Gb` text fields of the payload (`memGb`, `freeDiskGb`, `needGb`, `downloadGb`,
|
||||
* `budgetGb`, and the size slots of a `LocalModelMessage`). Nothing else may turn bytes into a
|
||||
* 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.
|
||||
*
|
||||
* Pure: no IO, no state.
|
||||
*/
|
||||
|
||||
/** One decimal gigabyte, the unit a person reads. */
|
||||
export const GB = 1_000_000_000;
|
||||
/** One binary gigabyte. Budget math only. */
|
||||
export const GIB = 1024 ** 3;
|
||||
/** One binary megabyte. Budget math only. */
|
||||
export const MIB = 1024 ** 2;
|
||||
|
||||
/**
|
||||
* A byte count as decimal GB text, WITHOUT the unit: one decimal below 100 ('6.6', '17.7',
|
||||
* '25.5'), a whole number from 100 ('212'). The caller's sentence supplies ' GB'.
|
||||
* Anything that is not a positive finite number reads '0.0'.
|
||||
*/
|
||||
export function formatGb(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0.0';
|
||||
const tenths = Math.round((bytes / GB) * 10) / 10;
|
||||
return tenths >= 100 ? String(Math.round(bytes / GB)) : tenths.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()`.
|
||||
*/
|
||||
export function memClassGb(memBytes: number): number {
|
||||
if (!Number.isFinite(memBytes) || memBytes <= 0) return 0;
|
||||
return Math.round(memBytes / GIB);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @fileoverview The hardware probe of the local model recommendation: what this Mac is.
|
||||
*
|
||||
* SKELETON: the exported signatures are final, the bodies are not written yet. This module
|
||||
* will hold:
|
||||
* - `parseSysctl()`: a pure parser of `key: value` lines. The command runs WITHOUT `-n`, so a
|
||||
* key that is missing on a machine (`sysctl.proc_translated` on Intel) cannot shift the
|
||||
* others, and a line it does not know is ignored.
|
||||
* - `readHardware()`: ONE bounded async spawn of `SYSCTL_PATH` with `SYSCTL_KEYS`, the free disk
|
||||
* of the data dir through `fs.promises.statfs`, fallbacks from `node:os` when the spawn
|
||||
* fails, and a 60 s cache.
|
||||
*
|
||||
* Rules:
|
||||
* - Never throws and never rejects: a failed probe is an answer (`source: 'os-fallback'`), and
|
||||
* off macOS nothing is spawned (`source: 'not-probed'`).
|
||||
* - Never `system_profiler` (tens of milliseconds and a parse of free text), never a second
|
||||
* spawn, never a synchronous exec on the server's event loop.
|
||||
* - A non-zero exit is tolerated once `hw.memsize` parsed (a missing key makes sysctl exit 1).
|
||||
* - `iogpu.wired_limit_mb` of 0 means "not set": it is reported as 0 and only a value above 0
|
||||
* is ever used as a limit.
|
||||
* - `memGb`, `memClassGb` and `freeDiskGb` are filled here through src/local-model/format.ts,
|
||||
* the one place a size is formatted.
|
||||
*/
|
||||
|
||||
import type { HardwareFacts } from '../types.js';
|
||||
|
||||
/** Absolute path, so the server's PATH (minimal under a service manager) cannot change what runs. */
|
||||
export const SYSCTL_PATH = '/usr/sbin/sysctl';
|
||||
|
||||
/** The keys of the one spawn, in the order they are passed. */
|
||||
export const SYSCTL_KEYS = [
|
||||
'hw.memsize',
|
||||
'machdep.cpu.brand_string',
|
||||
'hw.optional.arm64',
|
||||
'sysctl.proc_translated',
|
||||
'iogpu.wired_limit_mb',
|
||||
'kern.osproductversion',
|
||||
'hw.model',
|
||||
] as const;
|
||||
|
||||
/** Upper bound of the spawn (it answers in about 2 ms on a real Mac). */
|
||||
export const SYSCTL_TIMEOUT_MS = 2000;
|
||||
|
||||
/** How long one probe result is reused. */
|
||||
export const HARDWARE_CACHE_TTL_MS = 60_000;
|
||||
|
||||
/** What `parseSysctl()` read. Every field is absent when its key was missing or its value did not parse. */
|
||||
export interface SysctlFacts {
|
||||
/** `hw.memsize`. */
|
||||
memBytes?: number;
|
||||
/** `machdep.cpu.brand_string`. */
|
||||
chip?: string;
|
||||
/** `hw.optional.arm64` is 1. */
|
||||
arm64?: boolean;
|
||||
/** `sysctl.proc_translated` is 1. */
|
||||
translated?: boolean;
|
||||
/** `iogpu.wired_limit_mb`, as reported (0 stays 0). */
|
||||
wiredLimitMb?: number;
|
||||
/** `kern.osproductversion`. */
|
||||
macosVersion?: string;
|
||||
/** The leading number of `macosVersion`. */
|
||||
macosMajor?: number;
|
||||
/** `hw.model`. */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** The IO of `readHardware()`, injectable so a test spawns nothing and reads no real disk. Each member defaults to the real thing. */
|
||||
export interface HardwareProbeDeps {
|
||||
/** Default `process.platform`. */
|
||||
platform?: string;
|
||||
/** Default `os.arch()`. */
|
||||
arch?: string;
|
||||
/** Runs the one spawn. Resolves its stdout, also after a non-zero exit, or `undefined` when it could not run or timed out. Never rejects. */
|
||||
runSysctl?: () => Promise<string | undefined>;
|
||||
/** Free bytes on the data dir's filesystem, or `undefined` when unknown. Never rejects. */
|
||||
freeDiskBytes?: () => Promise<number | undefined>;
|
||||
/** Default `os.totalmem()`. */
|
||||
totalMemBytes?: () => number;
|
||||
/** Default `os.cpus()[0]?.model`. */
|
||||
cpuModel?: () => string | undefined;
|
||||
/** Default `Date.now`. The clock of the cache. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of `sysctl <keys>` (no `-n`): one `key: value` line per key it knows.
|
||||
* Pure. Unknown lines, blank lines and error lines are skipped.
|
||||
*/
|
||||
export function parseSysctl(text: string): SysctlFacts {
|
||||
void text;
|
||||
throw new Error('not implemented: parseSysctl');
|
||||
}
|
||||
|
||||
/**
|
||||
* What this machine is. Cached for `HARDWARE_CACHE_TTL_MS`; concurrent callers share one probe.
|
||||
* Never rejects.
|
||||
*/
|
||||
export async function readHardware(deps?: HardwareProbeDeps): Promise<HardwareFacts> {
|
||||
void deps;
|
||||
throw new Error('not implemented: readHardware');
|
||||
}
|
||||
|
||||
/** Drop the cached probe result (tests, and nothing else). */
|
||||
export function resetHardwareCache(): void {
|
||||
throw new Error('not implemented: resetHardwareCache');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @fileoverview What the server knows about each catalogue harness on this machine, for the
|
||||
* local model recommendation. Server side: this is the one module of src/local-model/ that
|
||||
* reads the CLI registry, so `recommend()` can stay pure and name no CLI.
|
||||
*
|
||||
* SKELETON: the exported signature is final, the body is not written yet. `buildHarnessFacts()`
|
||||
* will, for every `LocalHarnessEntry` of the catalogue:
|
||||
* - resolve its `cli` in the CLI registry AT CALL TIME (never in a module-level constant), and
|
||||
* leave out a row whose CLI is unknown, disabled, or has no custom model mechanism
|
||||
* (`capabilities.customModelInjection.kind` is `unsupported`);
|
||||
* - report `label` from the registry entry and `installed` from the same probe the other
|
||||
* routes use;
|
||||
* - report `installable` only for a CLI that is not installed and whose install command for
|
||||
* this platform needs no administrator rights (not one that starts with `brew` or `sudo`);
|
||||
* - supply `acceptsModelId`, the same test a custom model launch applies to the model id
|
||||
* before anything is written (the launch spec's model pattern).
|
||||
*
|
||||
* Rules:
|
||||
* - No CLI id is compared with a literal here or anywhere: the id comes from the catalogue row
|
||||
* and everything else from the registry entry it resolves to.
|
||||
* - Read-only: nothing is installed, written or spawned beyond the existing availability probe.
|
||||
*/
|
||||
|
||||
import type { HarnessFact, LocalModelCatalog } from '../types.js';
|
||||
|
||||
/** One fact per catalogue harness that resolves in the CLI registry, in catalogue order. */
|
||||
export async function buildHarnessFacts(catalog: LocalModelCatalog): Promise<HarnessFact[]> {
|
||||
void catalog;
|
||||
throw new Error('not implemented: buildHarnessFacts');
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @fileoverview The recommendation itself: which model, at which context, with which harness
|
||||
* fits this Mac. Pure: no IO, no clock, no CLI registry, no data dir.
|
||||
*
|
||||
* SKELETON: the exported signatures are final, the bodies are not written yet. This module
|
||||
* will hold:
|
||||
* - `deviceBudget()`: L is the wired limit when it is above 0, else 2/3 of memory below 36 GB
|
||||
* and 3/4 from 36 GB. B = min(L - 1 GiB, memory - reserve), with a reserve of 6 GiB up to
|
||||
* 18 GB, 8 GiB up to 48 GB and 12 GiB from 64 GB.
|
||||
* - `needBytes()`: F = weights + kvBytesPerToken * ctx + 1 GiB + the prompt cache tier of the
|
||||
* catalogue for this Mac's memory.
|
||||
* - `recommend()`: the candidates (model, context, harness) with `ctx >= harness.minContext`,
|
||||
* the harness installed or installable, accepting the model id and not gated; the fit (F/B up
|
||||
* to 0.85 good, up to 0.98 tight, above out); the speed (ok, slow or unknown: a chip that is
|
||||
* in no chips row is unknown, a bandwidth is never guessed); the ranking (good before tight,
|
||||
* then ok before unknown before slow, then model rank, harness rank, larger context); and the
|
||||
* verdict.
|
||||
*
|
||||
* Rules:
|
||||
* - Verdict `limited` when the best candidate's model has fewer than 12 billion parameters.
|
||||
* - The word "Recommended" (`LOCAL_MODEL_LABELS.headlineTested`) only for a pick whose model
|
||||
* has a `tested` entry matching this Mac. Otherwise `headlineOk` plus `noteUntested`.
|
||||
* - Reasons for `none`: not-macos, intel, memory, disk (free disk minus the download is below
|
||||
* 10 GiB for every fitting model), macos, no-harness.
|
||||
* - Every label in the result comes from `LOCAL_MODEL_LABELS`, every size text from
|
||||
* `formatGb()`. 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`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DeviceBudget,
|
||||
HardwareFacts,
|
||||
HarnessFact,
|
||||
LocalModelCatalog,
|
||||
LocalModelEntry,
|
||||
Recommendation,
|
||||
} from '../types.js';
|
||||
|
||||
/** The memory a model may use on this Mac: the device limit L, the budget B and where L came from. */
|
||||
export function deviceBudget(hw: HardwareFacts): DeviceBudget {
|
||||
void hw;
|
||||
throw new Error('not implemented: deviceBudget');
|
||||
}
|
||||
|
||||
/** F: the bytes `model` needs in memory at a context of `ctx` tokens on this Mac. */
|
||||
export function needBytes(model: LocalModelEntry, ctx: number, catalog: LocalModelCatalog, hw: HardwareFacts): number {
|
||||
void model;
|
||||
void ctx;
|
||||
void catalog;
|
||||
void hw;
|
||||
throw new Error('not implemented: needBytes');
|
||||
}
|
||||
|
||||
/**
|
||||
* The recommendation for this Mac. `harnessFacts` holds one fact per catalogue harness the
|
||||
* caller could resolve; a catalogue harness without a fact is not offered.
|
||||
*/
|
||||
export function recommend(
|
||||
hw: HardwareFacts,
|
||||
catalog: LocalModelCatalog,
|
||||
harnessFacts: readonly HarnessFact[]
|
||||
): Recommendation {
|
||||
void hw;
|
||||
void catalog;
|
||||
void harnessFacts;
|
||||
throw new Error('not implemented: recommend');
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
* | plan | PlanItem, PlanTaskStatus, TddPhase | In-memory → `GET /api/sessions/:id/plan/tasks` |
|
||||
* | orchestrator | OrchestratorState, OrchestratorPlan, OrchestratorConfig, OrchestratorPersistState | `~/.codeman/state.json` → `GET /api/orchestrator/status` |
|
||||
* | mcp-sync | McpSyncResult, McpSyncTargetResult | Other CLIs' own config files → `GET`/`POST /api/mcp-sync` |
|
||||
* | local-model | HardwareFacts, LocalModelCatalog, Candidate, Recommendation, LocalModelStatus, LOCAL_MODEL_LABELS | In-memory only (nothing persisted) → `GET /api/local-model` |
|
||||
*
|
||||
* ## Cross-domain relationship map
|
||||
*
|
||||
@@ -77,3 +78,4 @@ export * from './webview.js';
|
||||
export * from './intent.js';
|
||||
export * from './foreign-tmux.js';
|
||||
export * from './mcp-sync.js';
|
||||
export * from './local-model.js';
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* @fileoverview Types of the hardware-aware local model recommendation: what the server sees of
|
||||
* this Mac, the catalogue of models and harnesses, and the answer of `GET /api/local-model`.
|
||||
*
|
||||
* This stage is read-only: Codeman looks at the Mac and recommends. It downloads nothing,
|
||||
* installs nothing and writes no file.
|
||||
*
|
||||
* Who produces what:
|
||||
* - `HardwareFacts`: src/local-model/hardware.ts (one bounded `sysctl` spawn, never throws).
|
||||
* - `LocalModelCatalog` and its rows: src/config/local-model-catalog.ts (a compiled constant).
|
||||
* - `HarnessFact`: src/local-model/harness-facts.ts (server only, read from the CLI registry at
|
||||
* call time; it holds a function, so it never goes over HTTP).
|
||||
* - `Recommendation`, `Candidate`, `DeviceBudget`: src/local-model/recommend.ts (pure).
|
||||
* - `LocalModelStatus`: src/web/routes/local-model-routes.ts (the `data` of the GET).
|
||||
*
|
||||
* 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.
|
||||
* 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
|
||||
* `{placeholder}` slots.
|
||||
* 3. A CLI id is DATA (`LocalHarnessEntry.cli`, `Candidate.cli`). No code compares one with a
|
||||
* literal: what differs between harnesses is a catalogue field or a `HarnessFact`.
|
||||
*/
|
||||
|
||||
// ---------- Hardware ----------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* - `not-probed`: not macOS, so `sysctl` was never spawned.
|
||||
*/
|
||||
export type HardwareProbeSource = 'sysctl' | 'os-fallback' | 'not-probed';
|
||||
|
||||
/**
|
||||
* What the server knows about the machine it runs on. Plain JSON (it is part of the GET payload),
|
||||
* and always an answer: a failed probe yields the `os-fallback` or `not-probed` shape, never an
|
||||
* exception.
|
||||
*/
|
||||
export interface HardwareFacts {
|
||||
/** `process.platform` of the server. Anything but `darwin` ends in verdict `none`, reason `not-macos`. */
|
||||
platform: string;
|
||||
/** `os.arch()` of the server process. `x64` on Apple silicon means Node runs under Rosetta. */
|
||||
arch: string;
|
||||
/** `hw.optional.arm64` is 1: the MACHINE is Apple silicon whatever `arch` says. Absent when the key was not read. */
|
||||
arm64?: boolean;
|
||||
/** `sysctl.proc_translated` is 1: this process runs under Rosetta. Absent when the key was not read (Intel Macs lack it). */
|
||||
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). */
|
||||
memGb: string;
|
||||
/** The memory size the Mac is sold as: whole binary gigabytes (16, 32, 48). Tier rules and `TestedOn.memGb` use it. */
|
||||
memClassGb: number;
|
||||
/** `machdep.cpu.brand_string` ('Apple M4 Pro'), else `os.cpus()[0].model`. */
|
||||
chip?: string;
|
||||
/** `hw.model` ('Mac16,10'). */
|
||||
model?: string;
|
||||
/** `kern.osproductversion` ('27.2'). */
|
||||
macosVersion?: string;
|
||||
/** The leading number of `macosVersion` (27). */
|
||||
macosMajor?: number;
|
||||
/** `iogpu.wired_limit_mb` exactly as reported. 0 means "not set" (both test Macs report 0); only a value above 0 is a limit. */
|
||||
wiredLimitMb?: number;
|
||||
/** Free bytes on the filesystem of Codeman's data dir (`statfs`: `bavail * bsize`). */
|
||||
freeDiskBytes?: number;
|
||||
/** `freeDiskBytes` as a person reads it (decimal GB, no unit). Present exactly when `freeDiskBytes` is. */
|
||||
freeDiskGb?: string;
|
||||
source: HardwareProbeSource;
|
||||
}
|
||||
|
||||
// ---------- Catalogue ----------
|
||||
|
||||
/** One Mac a model and harness pair was really run on by us. A model row with a matching entry may be called "Recommended". */
|
||||
export interface TestedOn {
|
||||
/** The chip, as `machdep.cpu.brand_string` reports it ('Apple M4'). */
|
||||
chip: string;
|
||||
/** The Mac's memory as it is sold (`HardwareFacts.memClassGb`: 16, 32, 48). */
|
||||
memGb: number;
|
||||
/** The harness it was tested with: a CLI registry id, as data. */
|
||||
cli: string;
|
||||
/** ISO date of the test ('2026-11-06'). */
|
||||
date: string;
|
||||
}
|
||||
|
||||
/** One model of the catalogue. */
|
||||
export interface LocalModelEntry {
|
||||
/** `name:tag` exactly as the model registry lists it. Lowercase, one colon, no slash (`LOCAL_MODEL_ID_PATTERN`). */
|
||||
id: string;
|
||||
/** The name a person reads ('Qwen3.8 27B'). */
|
||||
label: string;
|
||||
/** SPDX id of the license ('Apache-2.0'). */
|
||||
license: string;
|
||||
/** Parameter count in billions. Under 12 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;
|
||||
/** Bytes of KV cache one token of context costs. */
|
||||
kvBytesPerToken: number;
|
||||
/** Context lengths (tokens) the model may be run at, largest first. */
|
||||
contexts: number[];
|
||||
/** Preference among models that fit: the HIGHER rank wins. A judgement, not a measurement. */
|
||||
rank: number;
|
||||
/** Measured tokens per second per 100 GB/s of memory bandwidth. Absent: the speed of this model is unknown. */
|
||||
speedPer100Gbps?: number;
|
||||
/** Macs this model was really run on. Empty today for every row. */
|
||||
tested: TestedOn[];
|
||||
}
|
||||
|
||||
/** One harness (coding agent CLI) that can be connected to a local model. All of it is data. */
|
||||
export interface LocalHarnessEntry {
|
||||
/** A CLI registry id. Data only: never compared with a literal anywhere. */
|
||||
cli: string;
|
||||
/** The smallest context (tokens) at which this harness works. */
|
||||
minContext: number;
|
||||
/** Preference among harnesses: the HIGHER rank wins. */
|
||||
rank: number;
|
||||
/** True: listed in the catalogue but never offered yet (its use with a local model is unverified). */
|
||||
gated?: boolean;
|
||||
/** True: the first answer takes minutes, because the harness sends a very large fixed prompt. */
|
||||
slowFirstTurn?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory bandwidth of one chip family. `LocalModelCatalog.chips` is ordered most specific first
|
||||
* ('M4 Max' before 'M4') and the first row whose `match` occurs in `HardwareFacts.chip` wins. A
|
||||
* chip that matches no row has an UNKNOWN speed: a bandwidth is never guessed.
|
||||
*/
|
||||
export interface ChipEntry {
|
||||
/** Text that must occur in the chip string ('M4 Max'). */
|
||||
match: string;
|
||||
/** Memory bandwidth in GB/s. */
|
||||
gbps: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host memory the runtime's prompt cache takes, by the size of the Mac. Tiers are ordered by
|
||||
* `minMemGb` ascending and the last tier with `minMemGb <= HardwareFacts.memClassGb` applies.
|
||||
*/
|
||||
export interface PromptCacheTier {
|
||||
/** Smallest memory size (as sold, binary gigabytes) this tier applies to. The first tier has 0. */
|
||||
minMemGb: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** The catalogue: a compiled constant, validated by `LocalModelCatalogSchema`. No override file, no download data. */
|
||||
export interface LocalModelCatalog {
|
||||
schema: 1;
|
||||
/** ISO date the rows were last checked ('2026-11-06'). Reported as `LocalModelStatus.catalogUpdated`. */
|
||||
updated: string;
|
||||
/** Oldest macOS major version a local model runs on. Below it: verdict `none`, reason `macos`. */
|
||||
minMacosMajor: number;
|
||||
promptCacheTiers: PromptCacheTier[];
|
||||
models: LocalModelEntry[];
|
||||
harnesses: LocalHarnessEntry[];
|
||||
chips: ChipEntry[];
|
||||
}
|
||||
|
||||
// ---------- Harness facts (server only) ----------
|
||||
|
||||
/**
|
||||
* What the server knows about one catalogue harness on this machine. Built from the CLI registry
|
||||
* at call time, so `recommend()` names no CLI. NOT serializable (it holds a function): it never
|
||||
* leaves the server. `Candidate` carries the parts a browser needs.
|
||||
*/
|
||||
export interface HarnessFact {
|
||||
/** The CLI registry id. */
|
||||
cli: string;
|
||||
/** The registry's display label ('OpenCode'). */
|
||||
label: string;
|
||||
/** The CLI's binary was found on this machine. */
|
||||
installed: boolean;
|
||||
/** Not installed, but the registry has an install command for this platform that needs no administrator rights. */
|
||||
installable: boolean;
|
||||
/** Whether this CLI can be launched with that model id (its command-line pattern accepts it). */
|
||||
acceptsModelId: (modelId: string) => boolean;
|
||||
}
|
||||
|
||||
// ---------- Recommendation ----------
|
||||
|
||||
/** Need over budget: up to 0.85 is `good`, up to 0.98 is `tight`, above is `out`. */
|
||||
export type FitGrade = 'good' | 'tight' | 'out';
|
||||
|
||||
/**
|
||||
* Expected generation speed. `unknown` when the chip is in no `ChipEntry` or the model has no
|
||||
* `speedPer100Gbps`. In the ranking `unknown` sits between `ok` and `slow`.
|
||||
*/
|
||||
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.
|
||||
* - `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.
|
||||
*/
|
||||
export type NoneReason = 'not-macos' | 'intel' | 'memory' | 'disk' | 'macos' | 'no-harness';
|
||||
|
||||
/** Where the device budget came from: a formula (`estimated`) or `iogpu.wired_limit_mb` above 0 (`wired-limit`). */
|
||||
export type BudgetSource = 'estimated' | 'wired-limit';
|
||||
|
||||
/** The memory a model may use on this Mac. */
|
||||
export interface DeviceBudget {
|
||||
/** L: the wired limit when above 0, else 2/3 of memory below 36 GB and 3/4 from 36 GB. */
|
||||
limitBytes: number;
|
||||
/** B = min(L - 1 GiB, memory - reserve). What `Candidate.budgetShare` is measured against. */
|
||||
budgetBytes: number;
|
||||
source: BudgetSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sentence and label the server may put into the payload, in English (the i18n source
|
||||
* language). Frozen for the parallel build: the zh-CN dictionary in src/web/public/i18n.js has
|
||||
* one entry per value, keyed by this exact text, and the browser's i18n test imports this
|
||||
* constant to list them. Adding a key is fine; changing a text means changing its zh-CN key too.
|
||||
*
|
||||
* `{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 |
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const LOCAL_MODEL_LABELS = {
|
||||
/** Verdict `ok`, pick not tested by us on a Mac like this one. */
|
||||
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`. */
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
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;
|
||||
|
||||
/** 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. */
|
||||
export const LOCAL_MODEL_NONE_LABELS: Record<NoneReason, LocalModelLabel> = {
|
||||
'not-macos': LOCAL_MODEL_LABELS.noneNotMacos,
|
||||
intel: LOCAL_MODEL_LABELS.noneIntel,
|
||||
memory: LOCAL_MODEL_LABELS.noneMemory,
|
||||
disk: LOCAL_MODEL_LABELS.noneDisk,
|
||||
macos: LOCAL_MODEL_LABELS.noneMacos,
|
||||
'no-harness': LOCAL_MODEL_LABELS.noneNoHarness,
|
||||
};
|
||||
|
||||
/** A sentence for the card: a text from `LOCAL_MODEL_LABELS` plus the values of its `{placeholder}` slots. */
|
||||
export interface LocalModelMessage {
|
||||
text: LocalModelLabel;
|
||||
/** Slot values, already formatted as text. Absent when the text has no slot. */
|
||||
vars?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** One model at one context with one harness, measured against this Mac. Plain JSON. */
|
||||
export interface Candidate {
|
||||
/** `LocalModelEntry.id`. */
|
||||
modelId: string;
|
||||
/** `LocalModelEntry.label`. */
|
||||
modelLabel: string;
|
||||
license: string;
|
||||
paramsB: number;
|
||||
/** Context length in tokens. */
|
||||
context: number;
|
||||
/** The harness: a CLI registry id, as data. */
|
||||
cli: string;
|
||||
/** The harness's display label, from the CLI registry. */
|
||||
cliLabel: string;
|
||||
/** False: the harness is not on this machine yet (it is installable, or it would not be a candidate). */
|
||||
cliInstalled: boolean;
|
||||
/** The harness row's `slowFirstTurn`. */
|
||||
slowFirstTurn?: boolean;
|
||||
/** F = weights + kvBytesPerToken * context + 1 GiB + prompt cache. */
|
||||
needBytes: number;
|
||||
/** `needBytes` as decimal GB text, no unit. */
|
||||
needGb: string;
|
||||
/** The model download in bytes (`LocalModelEntry.bytes`). */
|
||||
downloadBytes: number;
|
||||
/** `downloadBytes` as decimal GB text, no unit. */
|
||||
downloadGb: string;
|
||||
/** `needBytes / Recommendation.budgetBytes` (0.83). */
|
||||
budgetShare: number;
|
||||
/** A candidate in a `Recommendation` is never `out`. */
|
||||
fit: Exclude<FitGrade, 'out'>;
|
||||
/** `fitGood` or `fitTight`. */
|
||||
fitLabel: LocalModelLabel;
|
||||
speed: SpeedGrade;
|
||||
/** `speedSlow` or `speedUnknown`. Absent when `speed` is `ok`. */
|
||||
speedLabel?: LocalModelLabel;
|
||||
/** Estimated generation speed. Present exactly when `speed` is not `unknown`. */
|
||||
tokensPerSecond?: number;
|
||||
/** A `TestedOn` entry of the model matches this Mac (same chip, same `memClassGb`) and this harness. */
|
||||
tested: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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".
|
||||
* - `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. */
|
||||
headline: LocalModelMessage;
|
||||
/** Further sentences under the headline (`noteUntested` today). 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`. */
|
||||
alternatives: Candidate[];
|
||||
/** `DeviceBudget.budgetBytes`. 0 when the budget could not be computed (not macOS). */
|
||||
budgetBytes: number;
|
||||
/** `budgetBytes` as decimal GB text, no unit. */
|
||||
budgetGb: string;
|
||||
budgetSource: BudgetSource;
|
||||
}
|
||||
|
||||
// ---------- API ----------
|
||||
|
||||
/** The `data` of `GET /api/local-model`. Read-only; admin only in multi-user mode. */
|
||||
export interface LocalModelStatus {
|
||||
hardware: HardwareFacts;
|
||||
recommendation: Recommendation;
|
||||
/** `LocalModelCatalog.updated`. */
|
||||
catalogUpdated: string;
|
||||
}
|
||||
@@ -4685,6 +4685,7 @@
|
||||
<script defer src="foreign-sessions.js"></script>
|
||||
<script defer src="git-status-ui.js"></script>
|
||||
<script defer src="edit-history-ui.js"></script>
|
||||
<script defer src="local-model-ui.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @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:
|
||||
* - 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.
|
||||
*
|
||||
* 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).
|
||||
* - Every dynamic string goes in through `textContent`. Model, harness and chip names are
|
||||
* someone else's text and carry `data-i18n-skip`.
|
||||
* - 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.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp)
|
||||
* @dependency api-client.js (_api)
|
||||
* @dependency i18n.js (codemanT, optional)
|
||||
* @loadorder 12.59 of 16, after edit-history-ui.js, before entrance-animations.js
|
||||
*/
|
||||
@@ -49,5 +49,6 @@ export {
|
||||
type CustomModelSwapDisplacement,
|
||||
} from './custom-model-routes.js';
|
||||
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
|
||||
export { registerLocalModelRoutes } from './local-model-routes.js';
|
||||
|
||||
export { registerAgentSessionRoutes } from './agent-session-routes.js';
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/local-model`: what this Mac is and which local model fits it.
|
||||
*
|
||||
* SKELETON: the route is registered and answers 501 until its handler is written. It will
|
||||
* answer `{ success: true, data: LocalModelStatus }`, built from the hardware probe, the
|
||||
* catalogue, the harness facts and the pure `recommend()`:
|
||||
* `{ hardware, recommendation, catalogUpdated }`.
|
||||
*
|
||||
* Rules:
|
||||
* - Read-only. It downloads nothing, installs nothing, writes no file and reads no setting.
|
||||
* - Never a 500 for a probe that failed: a failed probe is an answer (the hardware's `source`
|
||||
* says so and the recommendation is verdict `none`).
|
||||
* - The payload names hardware of the host, so in multi-user mode it is admin only (403), the
|
||||
* same bar as `GET /api/doctor`.
|
||||
* - Every dependency is injected (`LocalModelRouteDeps`), so a route test spawns nothing and
|
||||
* does not need a Mac: it passes its own hardware and harness facts.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
type ApiResponse,
|
||||
type HardwareFacts,
|
||||
type HarnessFact,
|
||||
type LocalModelCatalog,
|
||||
type LocalModelStatus,
|
||||
type Recommendation,
|
||||
} from '../../types.js';
|
||||
import { getLocalModelCatalog } from '../../config/local-model-catalog.js';
|
||||
import { readHardware } from '../../local-model/hardware.js';
|
||||
import { buildHarnessFacts } from '../../local-model/harness-facts.js';
|
||||
import { recommend } from '../../local-model/recommend.js';
|
||||
|
||||
/** What the route calls. Each member defaults to the real module; a test overrides what it needs. */
|
||||
export interface LocalModelRouteDeps {
|
||||
readHardware: () => Promise<HardwareFacts>;
|
||||
getCatalog: () => LocalModelCatalog;
|
||||
buildHarnessFacts: (catalog: LocalModelCatalog) => Promise<HarnessFact[]>;
|
||||
recommend: (hw: HardwareFacts, catalog: LocalModelCatalog, harnessFacts: readonly HarnessFact[]) => Recommendation;
|
||||
}
|
||||
|
||||
export const defaultLocalModelRouteDeps: LocalModelRouteDeps = {
|
||||
readHardware: () => readHardware(),
|
||||
getCatalog: getLocalModelCatalog,
|
||||
buildHarnessFacts,
|
||||
recommend,
|
||||
};
|
||||
|
||||
export function registerLocalModelRoutes(app: FastifyInstance, deps: Partial<LocalModelRouteDeps> = {}): void {
|
||||
const resolved: LocalModelRouteDeps = { ...defaultLocalModelRouteDeps, ...deps };
|
||||
void resolved;
|
||||
app.get(
|
||||
'/api/local-model',
|
||||
async (_req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<LocalModelStatus>> => {
|
||||
reply.code(501);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'GET /api/local-model is not implemented yet');
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -264,6 +264,7 @@ import {
|
||||
pruneIdleLlamaSwapLogTails,
|
||||
tryWebviewRefererFallback,
|
||||
registerCliRegistryRoutes,
|
||||
registerLocalModelRoutes,
|
||||
} from './routes/index.js';
|
||||
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
@@ -1417,6 +1418,7 @@ export class WebServer extends EventEmitter {
|
||||
registerDoctorRoutes(this.app);
|
||||
registerCustomModelRoutes(this.app);
|
||||
registerCliRegistryRoutes(this.app);
|
||||
registerLocalModelRoutes(this.app);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
// due times for any persisted jobs, then expose it to its routes.
|
||||
|
||||
Reference in new issue
Block a user