diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index a3730c91..c9ab61a5 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1407,6 +1407,56 @@ '该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。', 'Git status is not available for a tmux session opened outside Codeman.': '在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。', + // Model on this Mac (local-model-ui.js: the welcome card and its App Settings group). The first + // block is the module's own text; sizes and names fill the {placeholder} slots. + 'Model on this Mac': '此 Mac 上的模型', + 'What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.': + '运行 {name} 的这台 Mac 可以运行哪种本地编程模型。只读:此处不会下载或安装任何内容。', + Hide: '隐藏', + 'Hide this card on this device. It stays in App Settings, under Models.': + '在此设备上隐藏此卡片。它仍保留在“应用设置”的“模型”中。', + 'Checking this Mac…': '正在检查此 Mac…', + '{chip}, {memory} GB memory, {free} GB free': '{chip},内存 {memory} GB,可用磁盘空间 {free} GB', + '{chip}, {memory} GB memory': '{chip},内存 {memory} GB', + '{memory} GB memory, {free} GB free': '内存 {memory} GB,可用磁盘空间 {free} GB', + '{memory} GB memory': '内存 {memory} GB', + '{model} ({id}) with {harness}': '{model}({id}),搭配 {harness}', + 'Context {context} tokens': '上下文 {context} 个 Token', + '{need} GB of memory': '占用内存 {need} GB', + '{download} GB download': '下载 {download} GB', + 'Needs about {need} GB of the {budget} GB this Mac can give a model.': + '大约需要 {need} GB,此 Mac 可分配给模型的内存为 {budget} GB。', + 'Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).': + '大约需要 {need} GB,此 Mac 可分配给模型的内存约为 {budget} GB(估算)。', + 'About {speed} tokens per second (estimated).': '每秒约 {speed} 个 Token(估算)。', + '{harness} is not installed on this Mac yet.': '此 Mac 上尚未安装 {harness}。', + 'With {harness}, the first answer can take minutes.': '使用 {harness} 时,第一次回答可能需要几分钟。', + 'Other models that fit': '其他适合的模型', + 'Setting this up from Codeman is the next step. It is not built yet.': + '下一步是直接在 {name} 中完成安装和设置,该功能尚未实现。', + // The sentences and labels the SERVER picks (LOCAL_MODEL_LABELS in src/types/local-model.ts), keyed + // by their exact English text: changing a text there means changing its key here. + 'Best fit for this Mac': '最适合此 Mac 的选择', + 'Recommended for this Mac': '为此 Mac 推荐', + 'This Mac can run a small model. It handles small edits and questions. It does not replace a cloud model.': + '此 Mac 可以运行小型模型。它能处理小幅修改和问答,但不能替代云端模型。', + 'Not yet tested by us on a Mac like this.': '我们尚未在类似的 Mac 上测试过。', + Good: '良好', + Tight: '紧张', + Slow: '较慢', + 'Speed unknown': '速度未知', + 'Codeman recommends a local model only on a Mac with Apple silicon.': + '{name} 仅在搭载 Apple 芯片的 Mac 上推荐本地模型。', + 'This Mac has an Intel processor. Local coding models need Apple silicon.': + '此 Mac 使用 Intel 处理器。本地编程模型需要 Apple 芯片。', + 'This Mac has {memory} GB of memory. That is too little to run a model that can drive a coding agent, so Codeman recommends none here.': + '此 Mac 有 {memory} GB 内存,不足以运行能够驱动编程智能体的模型,因此 {name} 在此不推荐任何模型。', + 'Not enough free disk space. The smallest model that fits this Mac is a {download} GB download and {free} GB is free. Free up about {missing} GB.': + '可用磁盘空间不足。适合此 Mac 的最小模型需要下载 {download} GB,而当前可用空间为 {free} GB。请腾出约 {missing} GB。', + 'This Mac runs macOS {version}. Local models need macOS {minimum} or newer.': + '此 Mac 运行的是 macOS {version}。本地模型需要 macOS {minimum} 或更高版本。', + 'No coding agent that Codeman can connect to a local model is available on this Mac.': + '此 Mac 上没有 {name} 可以连接到本地模型的编程智能体。', }); const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value])); diff --git a/src/web/public/index.html b/src/web/public/index.html index d7b82775..78211715 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -583,6 +583,14 @@
+ + @@ -2867,6 +2875,22 @@ + + +What the Mac that Codeman runs on can run as a local coding model. Read-only: nothing is downloaded or installed from here.
+([^<]+)<\/p>/
+);
+const STATIC = GROUP_MARKUP ? [GROUP_MARKUP[1].trim(), GROUP_MARKUP[2].trim()] : [];
+
+const ALL = [...new Set([...OWN, ...FROM_SERVER, ...STATIC])];
+
+/** The `{slot}` names of a source text. */
+const slots = (text: string) => [...text.matchAll(/\{([a-zA-Z]\w*)\}/g)].map((m) => m[1]);
+
+/** U+2013, U+2014 and U+2015, built from their code points so this file holds none of them. */
+const LONG_DASH = new RegExp(`[${String.fromCharCode(0x2013, 0x2014, 0x2015)}]`);
+
+/** What may stay Latin: the product, the platform and its vendors, the unit, and Token (the dictionary's own word). */
+const leftover = (text: string) =>
+ text.replace(/\b(Codeman|macOS|Mac|GB|Apple|Intel|Token)\b/g, '').match(/[A-Za-z]+/g) ?? [];
+
+describe('the local model strings in zh-CN', () => {
+ it('harvests the module (the check is not vacuous)', () => {
+ expect(FROM_MODULE).toContain('Model on this Mac');
+ expect(FROM_MODULE).toContain('{model} ({id}) with {harness}');
+ expect(FROM_MODULE).toContain('Needs about {need} GB of the {budget} GB this Mac can give a model (estimated).');
+ expect(FROM_MODULE).toContain('Setting this up from Codeman is the next step. It is not built yet.');
+ expect(FROM_MODULE).toContain('Checking this Mac…');
+ expect(FROM_MODULE.length).toBeGreaterThanOrEqual(18);
+ // The only other way the module calls its translator is with payload text (a server
+ // sentence or label), so no string of its own can reach the page unharvested.
+ const code = MODULE.split('\n')
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
+ .join('\n');
+ const calls = code.split('localModelT(').length - 1;
+ const literalCalls = [...code.matchAll(/localModelT\(\s*'/g)].length;
+ const payloadCalls = [...code.matchAll(/localModelT\((message\.text, message\.vars|label)\)/g)].length;
+ // One more: the function's own declaration.
+ expect(calls).toBe(literalCalls + payloadCalls + 1);
+ expect(payloadCalls).toBe(2);
+ });
+
+ it('lists every server label, the none reasons included', () => {
+ // A label added to the contract later fails below by its text, not here by a count.
+ expect(FROM_SERVER.length).toBeGreaterThanOrEqual(14);
+ for (const text of Object.values(LOCAL_MODEL_NONE_LABELS)) expect(FROM_SERVER).toContain(text);
+ expect(FROM_SERVER).toContain('Best fit for this Mac');
+ expect(FROM_SERVER).toContain('Speed unknown');
+ });
+
+ it('never uses the translator slot {name} for a value of its own', () => {
+ for (const text of ALL) expect(slots(text), text).not.toContain('name');
+ });
+
+ it.each(ALL)('translates %s, with no English left', (text) => {
+ const chinese = api.t(text);
+ expect(chinese).not.toBe(text);
+ expect(leftover(chinese)).toEqual([]);
+ // No long dash in any form: commas, colons and parentheses only.
+ expect(chinese).not.toMatch(LONG_DASH);
+ });
+
+ it.each(ALL.filter((text) => slots(text).length > 0))('keeps every slot of %s', (text) => {
+ // Digits only, so a marker is neither translated nor counted as leftover English.
+ const vars = Object.fromEntries(slots(text).map((slot, i) => [slot, `90${i}1`]));
+ const chinese = api.t(text, vars);
+ for (const value of Object.values(vars)) expect(chinese, `${text} lost a slot`).toContain(value);
+ expect(chinese).not.toMatch(/[{}]/);
+ });
+
+ it('keeps the product name a slot, so a custom display name reaches the Chinese text', () => {
+ api.configure({ language: 'zh-CN', displayName: 'Acme' });
+ try {
+ for (const text of ALL.filter((candidate) => candidate.includes('Codeman'))) {
+ const chinese = api.t(text);
+ expect(chinese, text).toContain('Acme');
+ expect(chinese, text).not.toContain('Codeman');
+ }
+ } finally {
+ api.configure({ language: 'zh-CN', displayName: 'Codeman' });
+ }
+ });
+
+ it('adds each key to the dictionary once', () => {
+ // A repeated key in the object literal silently replaces the first. Prettier leaves a key
+ // that is one word unquoted.
+ for (const text of ALL) {
+ const bare = /^\w+$/.test(text) ? I18N.split(`\n ${text}: `).length - 1 : 0;
+ expect(I18N.split(`'${text}':`).length - 1 + bare, text).toBe(1);
+ }
+ });
+
+ it('translates the Settings group heading and hint in the page, and leaves both containers alone', () => {
+ expect(STATIC).toHaveLength(2);
+ expect(STATIC[0]).toBe('Model on this Mac');
+ const translated = page.window.document.getElementById('localModelGroup')!;
+ const heading = translated.querySelector('.set-group-head h4')!.textContent!.trim();
+ const hint = translated.querySelector('.set-group-hint')!.textContent!.trim();
+ expect(heading).toBe('此 Mac 上的模型');
+ expect(hint).not.toBe(STATIC[1]);
+ expect(hint).toBe(api.t(STATIC[1]));
+ expect(leftover(hint)).toEqual([]);
+ // Written only by the module, through its own translator call.
+ expect(INDEX).toMatch(/id="localModelCard"[^>]*data-i18n-skip/);
+ expect(INDEX).toMatch(/id="localModelSettings"[^>]*data-i18n-skip/);
+ });
+});
diff --git a/test/local-model-ui.test.ts b/test/local-model-ui.test.ts
new file mode 100644
index 00000000..1689d6ff
--- /dev/null
+++ b/test/local-model-ui.test.ts
@@ -0,0 +1,797 @@
+/**
+ * @fileoverview The "Model on this Mac" card of the welcome screen and its App Settings row
+ * (src/web/public/local-model-ui.js), driven in JSDOM over the REAL index.html with the real
+ * module and a mocked `_api`.
+ *
+ * What is pinned:
+ * - every state the route can produce: loading, refused (403), failed, not macOS, each `none`
+ * reason, `limited`, `ok`;
+ * - the card stays hidden until the server answered for a Mac, and the Settings row carries
+ * the loading and failure sentences;
+ * - the card and the row show the SAME pick (one builder), the card folds the alternatives;
+ * - every payload string lands as text: an `` in a model, harness or chip name,
+ * or in a slot value of a server sentence, creates no element and runs nothing;
+ * - sizes are printed as sent (decimal GB text), and the word "Recommended" is the server's
+ * to send, never this module's;
+ * - the dismissal is a per-device localStorage key and survives a new app instance;
+ * - one request a minute at most, shared while in flight, and a failed refresh keeps the answer.
+ *
+ * The route answers 501 until the server stage lands, so the payloads here are fixtures typed
+ * with the contract's `LocalModelStatus`, built from `LOCAL_MODEL_LABELS`.
+ *
+ * Port: none.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { JSDOM } from 'jsdom';
+import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
+import { LOCAL_MODEL_LABELS, LOCAL_MODEL_NONE_LABELS } from '../src/types.js';
+import type {
+ Candidate,
+ HardwareFacts,
+ LocalModelMessage,
+ LocalModelStatus,
+ NoneReason,
+ Recommendation,
+} from '../src/types.js';
+
+const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
+const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
+const MODULE = readFileSync(resolve(PUBLIC, 'local-model-ui.js'), 'utf8');
+const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
+
+const DISMISS_KEY = 'codeman:local-model-dismissed';
+const NEXT_STEP = 'Setting this up from Codeman is the next step. It is not built yet.';
+
+type ApiResponse = { ok: boolean; status: number; json(): Promise
';
+
+ it('renders markup in a name, a license or a sentence slot as characters, and runs nothing', async () => {
+ const { dom, newApp, card, row } = mount();
+ const evil = candidate({ modelLabel: EVIL, modelId: `m:${EVIL}`, cliLabel: EVIL, license: EVIL, needGb: EVIL });
+ const answer = status(
+ {
+ // A slot value is server text too.
+ headline: { text: LOCAL_MODEL_LABELS.noneMacos, vars: { version: EVIL, minimum: EVIL } },
+ notes: [{ text: LOCAL_MODEL_LABELS.noneMemory, vars: { memory: EVIL } }],
+ pick: evil,
+ alternatives: [evil],
+ budgetGb: EVIL,
+ },
+ { chip: EVIL, memGb: EVIL, freeDiskGb: EVIL }
+ );
+ await newApp(ok(answer)).loadLocalModel();
+ expect(card.hidden).toBe(false);
+ for (const root of [card, row]) {
+ expect(root.querySelector('img')).toBeNull();
+ expect(root.querySelector('script, iframe, a, [onerror]')).toBeNull();
+ expect(texts(root, '.local-model-hw')[0]).toBe(`${EVIL}, ${EVIL} GB memory, ${EVIL} GB free`);
+ expect(texts(root, '.local-model-headline')[0]).toBe(
+ `This Mac runs macOS ${EVIL}. Local models need macOS ${EVIL} or newer.`
+ );
+ expect(texts(root, '.local-model-name')[0]).toBe(`${EVIL} (m:${EVIL}) with ${EVIL}`);
+ expect(texts(root, '.local-model-pick .local-model-line')[0]).toContain(` · ${EVIL}`);
+ expect(texts(root, '.local-model-note')[0]).toContain(`This Mac has ${EVIL} GB of memory.`);
+ }
+ expect((dom.window as unknown as { __pwned?: number }).__pwned).toBeUndefined();
+ });
+
+ it('never assigns markup: the module has no innerHTML, outerHTML or insertAdjacentHTML', () => {
+ expect(MODULE).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/);
+ });
+
+ it('shows a label the contract does not know as it came, still as text', async () => {
+ const { newApp, card } = mount();
+ const odd = candidate({ fitLabel: EVIL as Candidate['fitLabel'], speedLabel: undefined });
+ await newApp(ok(status({ pick: odd }))).loadLocalModel();
+ expect(texts(card, '.local-model-pill')).toEqual([EVIL]);
+ expect(card.querySelector('img')).toBeNull();
+ });
+});
+
+describe('hiding the card', () => {
+ it('hides it on this device for good, and leaves the Settings row as the way back', async () => {
+ const { dom, newApp, card, row } = mount();
+ const app = newApp(ok(OK_STATUS));
+ await app.loadLocalModel();
+ const hide = card.querySelector('button.local-model-hide') as HTMLButtonElement;
+ expect(hide.textContent).toBe('Hide');
+ expect(hide.type).toBe('button');
+ expect(hide.title).toBe('Hide this card on this device. It stays in App Settings, under Models.');
+ hide.click();
+ expect(card.hidden).toBe(true);
+ expect(card.textContent).toBe('');
+ expect(dom.window.localStorage.getItem(DISMISS_KEY)).toBe('1');
+ expect(texts(row, '.local-model-name')[0]).toBe('Qwen3.8 27B (qwen3.8:27b) with OpenCode');
+
+ // A reload is a new app instance over the same storage.
+ const reloaded = newApp(ok(OK_STATUS));
+ expect(reloaded.isLocalModelCardDismissed()).toBe(true);
+ await reloaded.loadLocalModel();
+ expect(card.hidden).toBe(true);
+ expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']);
+ });
+
+ it('shows the card on a device that never hid it', async () => {
+ const { dom, newApp, card } = mount();
+ expect(dom.window.localStorage.getItem(DISMISS_KEY)).toBeNull();
+ await newApp(ok(OK_STATUS)).loadLocalModel();
+ expect(card.hidden).toBe(false);
+ });
+
+ it('stays hidden for the page load when storage is blocked', async () => {
+ const { dom, newApp, card } = mount();
+ const storage = dom.window.Storage.prototype;
+ vi.spyOn(storage, 'getItem').mockImplementation(() => {
+ throw new Error('blocked');
+ });
+ vi.spyOn(storage, 'setItem').mockImplementation(() => {
+ throw new Error('blocked');
+ });
+ const app = newApp(ok(OK_STATUS));
+ await app.loadLocalModel();
+ expect(card.hidden).toBe(false);
+ app.dismissLocalModelCard();
+ expect(card.hidden).toBe(true);
+ app.renderLocalModel();
+ expect(card.hidden).toBe(true);
+ vi.restoreAllMocks();
+ });
+
+ it('writes nothing to the server: the only request the module ever makes is the GET', async () => {
+ const { newApp, card } = mount();
+ const app = newApp(ok(OK_STATUS));
+ await app.loadLocalModel();
+ (card.querySelector('button.local-model-hide') as HTMLButtonElement).click();
+ expect(app._api.mock.calls).toEqual([['/api/local-model']]);
+ });
+});
+
+describe('the request', () => {
+ it('goes through the base path when Codeman is mounted under one', async () => {
+ const before = "const CodemanBase = { base: '/cm', url: (path) => '/cm' + path };";
+ const { newApp } = mount({ before });
+ const app = newApp(ok(OK_STATUS));
+ await app.loadLocalModel();
+ expect(app._api.mock.calls).toEqual([['/cm/api/local-model']]);
+ });
+
+ it('is sent once a minute at most, and shared while in flight', async () => {
+ const { dom, newApp, card } = mount();
+ let now = 1_000_000;
+ (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now;
+ const app = newApp(ok(OK_STATUS));
+ const first = app.loadLocalModel();
+ const second = app.loadLocalModel();
+ await Promise.all([first, second]);
+ expect(app._api).toHaveBeenCalledTimes(1);
+
+ now += 59_000;
+ await app.loadLocalModel();
+ expect(app._api).toHaveBeenCalledTimes(1);
+
+ now += 2_000;
+ await app.loadLocalModel();
+ expect(app._api).toHaveBeenCalledTimes(2);
+ expect(card.hidden).toBe(false);
+ });
+
+ it('keeps the answer on screen when a later refresh fails', async () => {
+ const { dom, newApp, card, row } = mount();
+ let now = 1_000_000;
+ (dom.window as unknown as { Date: DateConstructor }).Date.now = () => now;
+ const answers: ApiResponse[] = [ok(OK_STATUS), null];
+ const app = newApp(async () => answers.shift() ?? null);
+ await app.loadLocalModel();
+ now += 61_000;
+ await app.loadLocalModel();
+ expect(app._api).toHaveBeenCalledTimes(2);
+ expect(card.hidden).toBe(false);
+ expect(texts(row, '.local-model-headline')).toEqual(['Best fit for this Mac']);
+ expect(row.textContent).not.toContain('Could not reach the server.');
+ });
+});
+
+describe('with the real translator', () => {
+ it('renders in Chinese, and follows a language or display-name change without a reload', async () => {
+ const { newApp, card, row, i18n } = mount({ i18n: true });
+ i18n.start();
+ i18n.configure({ language: 'zh-CN' });
+ const app = newApp(ok(OK_STATUS));
+ await app.loadLocalModel();
+ expect(texts(card, '.local-model-headline')).toEqual(['最适合此 Mac 的选择']);
+ expect(texts(card, '.local-model-pill')).toEqual(['良好', '速度未知', '良好', '紧张', '较慢']);
+ // Names and numbers ride the slots untouched.
+ expect(texts(card, '.local-model-name')[0]).toBe('Qwen3.8 27B(qwen3.8:27b),搭配 OpenCode');
+ expect(texts(row, '.local-model-hw')).toEqual(['Apple M5 Pro,内存 51.5 GB,可用磁盘空间 912 GB']);
+
+ // Both containers are data-i18n-skip, so the module itself must repaint.
+ i18n.configure({ language: 'en' });
+ await settle();
+ expect(texts(card, '.local-model-headline')).toEqual(['Best fit for this Mac']);
+ expect(texts(row, '.local-model-next')).toEqual([NEXT_STEP]);
+
+ i18n.configure({ displayName: 'Acme' });
+ await settle();
+ expect(texts(card, '.local-model-next')).toEqual([
+ 'Setting this up from Acme is the next step. It is not built yet.',
+ ]);
+ });
+
+ it('leaves a slot-shaped piece of a model name alone', async () => {
+ const { newApp, card, i18n } = mount({ i18n: true });
+ i18n.start();
+ const odd = candidate({ modelLabel: 'Model {harness} {name}' });
+ await newApp(ok(status({ pick: odd }))).loadLocalModel();
+ await settle();
+ // The DOM translator would empty `{harness}` and brand `{name}`; the skip attribute keeps it out.
+ expect(texts(card, '.local-model-name')[0]).toBe('Model {harness} {name} (qwen3.8:27b) with OpenCode');
+ });
+});