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:
Codeman maintainer committed 2026-11-06 14:56:13 +01:00
1 parent e9b2ecd552
commit 73ce6a1819
12 files changed
+824

No files matched your search

+1
View File
@@ -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>
+33
View File
@@ -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
*/
+1
View File
@@ -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';
+60
View File
@@ -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');
}
);
}
+2
View File
@@ -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.