mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
Approval cards in plain words, Tasks open view, This computer card, version list, and the Tune steps with a live speed
- approval cards: one plain question per tool call (server/agent/ask.mjs maps commands and files to sentences; src/lib/ask.ts holds the translated templates), a compact calm card, the exact command under Details - Tasks: the opened task shows schedule, what it may do, last result, instructions and last answer; back link before the title like the chat's navbar; pause/resume and delete icons, Edit, Run now; pause and play icons added to the sprite - Models: 'This computer' shows graphics memory and RAM bars with what the chosen version takes, and one plain line about the SSD (src/lib/native-fit.ts, from the catalog's memory_gb and the hardware report; the n-gram table is not counted) - Models: one-line version list: Recommended (the fixed four), Swift, GSQ-RCO, NVFP4, Gyro, Unsloth groups, Use your own GGUF; RAM+VRAM in bold, download size muted, 'Uses SSD N GB' only where it applies - Tune: five steps with their own animated icons and states, a live speed readout and sparkline, a result card with the gain and what it was saved as; reduced motion respected; the tuner now reports each measurement's speed - catalog: memory_gb and mem_total per version (tools/strata_mcp.py) - tests: ask, native-fit, tune-phase, autotune speeds, memory figures; headless checks (ui-flows ui2/ui2d, scripts/approval-flow.mjs), light and dark
This commit is contained in:
1 parent
f662759046
commit
baeb4533be
37 files changed
+1663
-33
No files matched your search
@@ -30,7 +30,7 @@ const nodeExe = resolve(args.node ?? join(staged, 'node', process.platform === '
|
||||
const work = mkdtempSync(join(tmpdir(), 'strata-ui-flows-'));
|
||||
const shots = resolve(args.shots ?? join(work, 'shots'));
|
||||
mkdirSync(shots, { recursive: true });
|
||||
const only = new Set(String(args.only ?? 'download,models,profile,tune,fail,delete,theme,restart,perf,polish,stop,latest').split(','));
|
||||
const only = new Set(String(args.only ?? 'download,models,profile,ui2,ui2d,tune,fail,delete,theme,restart,perf,polish,stop,latest').split(','));
|
||||
const isWin = process.platform === 'win32';
|
||||
const { chromium } = await import(pathToFileURL(join(repoHost, 'node_modules/playwright-core/index.mjs')).href).catch(() => import('playwright-core'));
|
||||
const CHROME = process.env.CHROME ?? ['C:/Program Files/Google/Chrome/Application/chrome.exe', '/usr/bin/google-chrome'].find(existsSync);
|
||||
@@ -319,6 +319,88 @@ await scenario('profile', { installed: true }, async ({ base, api, browser, shot
|
||||
check(page.problems.length === 0, 'no page error', page.problems.join('; '));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ version list, This computer, Tune, a task
|
||||
for (const scheme of ['light', 'dark']) await scenario(scheme === 'light' ? 'ui2' : 'ui2d', { installed: true, env: { FAKE_TOKEN_DELAY: '0.02', FAKE_REPLY: 'I am thinking about it. </think> ' + 'Here is the answer, word by word. '.repeat(12), STRATA_TUNE_NO_QUIET_CHECK: '1' } }, async ({ base, api, browser, shot }) => {
|
||||
const page = await newPage(browser, base, { colorScheme: scheme });
|
||||
await clickText(page, /Go straight to the app/i);
|
||||
await page.getByText(/Conversation context/i).first().waitFor({ timeout: 30000 });
|
||||
await delay(1500);
|
||||
// the version list: Recommended (the fixed four), then folded groups, one line per model
|
||||
console.log('info triggers', await page.locator('.version-trigger').count(), await page.getByRole('button', { name: 'Model version' }).count());
|
||||
await page.locator('.version-trigger').first().click();
|
||||
await delay(300);
|
||||
console.log('info expanded after click', await page.locator('.version-trigger').first().getAttribute('aria-expanded'));
|
||||
await delay(800); await shot(page, `0-after-click-${scheme}`);
|
||||
console.log('info menu', await page.evaluate(() => { const m = document.querySelector('.version-menu'); const t = document.querySelector('.version-trigger'); return JSON.stringify({ menu: m ? m.getBoundingClientRect() : null, parent: m ? [m.parentElement.className, getComputedStyle(m.parentElement).display, getComputedStyle(m.parentElement).visibility, m.parentElement.style.cssText.slice(0, 120)] : null, expanded: t?.getAttribute('aria-expanded'), roles: [...document.querySelectorAll('[role=menu]')].length }); }));
|
||||
await page.locator('.version-menu').waitFor({ timeout: 10000 });
|
||||
const heads = await page.locator('.version-head span:first-child, .version-group__head').allTextContents();
|
||||
const names = await page.locator('.version-menu > .version-row .version-row__name strong').allTextContents();
|
||||
console.log('info version list', JSON.stringify(heads.map(t => t.replace(/\s+/g, ' ').trim())), JSON.stringify(names));
|
||||
check(heads[0]?.trim() === 'Recommended', `${scheme}: the list starts with Recommended`);
|
||||
check(names.length >= 1 && names.length <= 4, `${scheme}: Recommended shows only the picks this catalog has (at most the fixed four)`, names.join(' | '));
|
||||
check(heads.some(t => /Swift models|GSQ-RCO models|Gyro models|NVFP4 models|Unsloth models/.test(t)), `${scheme}: the other models are in folded groups`);
|
||||
check(heads.at(-1)?.includes('Use your own GGUF'), `${scheme}: the last row is Use your own GGUF`);
|
||||
const tall = await page.locator('.version-row').evaluateAll(els => els.filter(e => e.getBoundingClientRect().height > 44).length);
|
||||
check(tall === 0, `${scheme}: every model is one line`, String(tall));
|
||||
await shot(page, `1-versions-${scheme}`);
|
||||
await page.keyboard.press('Escape');
|
||||
// This computer
|
||||
const pc = page.locator('.pc-card');
|
||||
await pc.waitFor({ timeout: 10000 });
|
||||
const pcText = ((await pc.textContent()) ?? '').replace(/\s+/g, ' ');
|
||||
check(/This computer/.test(pcText) && /(Fits fully in memory|read from the SSD)/.test(pcText), `${scheme}: This computer says in one line whether it fits`, pcText.slice(0, 200));
|
||||
check(await pc.locator('.pc-meter').count() >= 1, `${scheme}: it shows the memory bars`);
|
||||
check(await pc.locator('.chip, .st-pill, [class*="badge"]').count() === 0, `${scheme}: no chips or badges in it`);
|
||||
await shot(page, `2-computer-${scheme}`);
|
||||
// Tune: five steps, a live speed, a result card
|
||||
await page.getByRole('button', { name: /^Tune model$/ }).first().click({ timeout: 10000 });
|
||||
await delay(1500); await shot(page, `2b-tune-dialog-${scheme}`);
|
||||
// The fake engine has no other setting worth trying, so its tune passes through "trying" too fast to be seen. The page is shown the progress a
|
||||
// real tune reports (Control's status file: stage, speeds) for the running view; the start, the result and every other call are real.
|
||||
let fakePhase = 'run';
|
||||
await page.route('**/api/native*', async route => {
|
||||
const response = await route.fetch(); let json = null;
|
||||
try { json = await response.json(); } catch { return route.fulfill({ response }); }
|
||||
for (const job of json?.jobs ?? []) if (job.kind === 'autotune' && fakePhase === 'done') { job.state = 'done'; job.progress = null; job.result = { outcome: 'improved', gain_pct: 18, base: { decode_tok_s: 41.2 }, chosen: { decode_tok_s: 48.6 }, preset: { id: 'fast', name: 'Fast · 64K' }, answers: { class: 'exact', fields: [] } }; } else if (job.kind === 'autotune' && job.state === 'running') job.progress = { stage: 'rounds', text: null, minutesLeft: 9, runsDone: 3, runsPlanned: 6, elapsedS: 300, step: 'Trying: more experts kept in memory (round 1 of 2)', speeds: [{ label: 'base', tok_s: 41.2 }, { label: 'Fewer helper threads', tok_s: 40.1 }, { label: 'More experts kept in memory', tok_s: 46.8 }] };
|
||||
return route.fulfill({ response, json });
|
||||
});
|
||||
await page.getByRole('button', { name: /Start tuning|Tune again/ }).first().click({ timeout: 15000 });
|
||||
await page.locator('.tune-step').first().waitFor({ timeout: 60000 });
|
||||
check(await page.locator('.tune-step').count() === 5, `${scheme}: the tuner shows its five steps`);
|
||||
let sawRun = false, sawSpeed = false;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const done = await page.locator('.tune-result, .tune-progress').count();
|
||||
if (i % 4 === 0) { const jobs = (await api('/api/native')).body?.jobs ?? []; const j = jobs.find(x => x.kind === 'autotune'); if (j) console.log('info tune job', j.state, JSON.stringify(j.progress ?? null).slice(0, 300)); }
|
||||
if (await page.locator('.tune-step[data-s=run]').count()) sawRun = true;
|
||||
const big = await page.locator('.tune-live__big').first().textContent().catch(() => '');
|
||||
if (/[0-9]/.test(big ?? '')) sawSpeed = true;
|
||||
if (sawRun && sawSpeed) { await delay(700); await shot(page, `3-tune-running-${scheme}`); break; }
|
||||
if (done) break;
|
||||
await delay(600);
|
||||
}
|
||||
check(sawRun, `${scheme}: a step is shown as running`);
|
||||
check(sawSpeed, `${scheme}: the live speed shows a number while it runs`);
|
||||
if (sawSpeed) { fakePhase = 'done'; await page.locator('.tune-hero').waitFor({ timeout: 15000 }).catch(() => {}); check(/\+18%/.test((await page.locator('.tune-hero').textContent().catch(() => '')) ?? ''), `${scheme}: the result card shows the gain large`); check(/Fast/.test((await page.locator('.tune-compare').textContent().catch(() => '')) ?? ''), `${scheme}: and what it was saved as`); }
|
||||
await shot(page, `4-tune-result-${scheme}`);
|
||||
await page.getByRole('button', { name: /^Close$/ }).first().click({ timeout: 10000 }).catch(() => {});
|
||||
// a task: the opened task is skimmable, with pause / delete / Edit / Run now
|
||||
const made = await api('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'Open Downloads folder', prompt: 'Open my Downloads folder and tell me what is new.', frequency: 'weekdays', at: '08:00', weekday: 1, timeZone: 'Europe/Ljubljana', mode: 'manual', enabled: true }) });
|
||||
const id = made.body?.id ?? made.body?.task?.id;
|
||||
check(!!id, `${scheme}: a task is created`, JSON.stringify(made.body).slice(0, 120));
|
||||
await page.evaluate(i => { location.hash = '#/tasks/' + i; }, id);
|
||||
await page.getByRole('button', { name: /^Edit$/ }).first().waitFor({ timeout: 15000 });
|
||||
console.log('info header buttons', await page.getByRole('button', { name: /^Pause / }).count(), await page.getByRole('button', { name: /^Delete / }).count(), await page.getByRole('button', { name: /Run now/ }).count(), JSON.stringify(await page.locator('.task-header-actions button').evaluateAll(b => b.map(x => (x.getAttribute('aria-label') ?? x.textContent ?? '').trim()))));
|
||||
check(await page.getByRole('button', { name: /^Pause / }).count() === 1 && await page.getByRole('button', { name: /^Delete / }).count() >= 1 && await page.getByRole('button', { name: /Run now/ }).count() >= 1, `${scheme}: pause, delete, Edit and Run now are in the header`);
|
||||
check(await page.locator('.main__back').count() === 1, `${scheme}: a back link sits before the title`);
|
||||
const factsText = ((await page.locator('.task-facts').textContent()) ?? '').replace(/\s+/g, ' ');
|
||||
check(/Weekdays/.test(factsText) && /Schedule/i.test(factsText) && /Last result/i.test(factsText), `${scheme}: schedule, what it may do and the last result are at the top`, factsText.slice(0, 160));
|
||||
await shot(page, `5-task-${scheme}`);
|
||||
await page.getByRole('button', { name: /^Pause / }).first().click();
|
||||
await page.getByRole('button', { name: /^Resume / }).first().waitFor({ timeout: 10000 });
|
||||
check(true, `${scheme}: Pause becomes Resume`);
|
||||
check(page.problems.length === 0, `${scheme}: no page error`, page.problems.join('; '));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ tune + reading size
|
||||
await scenario('tune', { installed: true, env: { FAKE_TOKEN_DELAY: '0.02', FAKE_REPLY: ['I am thinking about it.', '</think>', '', 'Here is a list:', '', '- one', '- two', '', '## Heading', '', 'Some `code` and **bold** text.', '', '| a | b |', '|---|---|', '| 1 | 2 |', '', '```', 'code line', '```', ''].join(String.fromCharCode(10)) } }, async ({ base, api, browser, dirs, shot }) => {
|
||||
const page = await newPage(browser, base);
|
||||
|
||||
@@ -192,6 +192,14 @@
|
||||
<path d="M4 17H12.75M17.25 17H20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
|
||||
<path d="M9 4.75C10.24 4.75 11.25 5.76 11.25 7C11.25 8.24 10.24 9.25 9 9.25C7.76 9.25 6.75 8.24 6.75 7C6.75 5.76 7.76 4.75 9 4.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M15 14.75C16.24 14.75 17.25 15.76 17.25 17C17.25 18.24 16.24 19.25 15 19.25C13.76 19.25 12.75 18.24 12.75 17C12.75 15.76 13.76 14.75 15 14.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none">
|
||||
<path opacity="0.12" d="M7 6.5C7 5.672 7.672 5 8.5 5H9.5C10.328 5 11 5.672 11 6.5V17.5C11 18.328 10.328 19 9.5 19H8.5C7.672 19 7 18.328 7 17.5V6.5ZM13 6.5C13 5.672 13.672 5 14.5 5H15.5C16.328 5 17 5.672 17 6.5V17.5C17 18.328 16.328 19 15.5 19H14.5C13.672 19 13 18.328 13 17.5V6.5Z" fill="currentColor"></path>
|
||||
<path d="M7 6.5C7 5.672 7.672 5 8.5 5H9.5C10.328 5 11 5.672 11 6.5V17.5C11 18.328 10.328 19 9.5 19H8.5C7.672 19 7 18.328 7 17.5V6.5ZM13 6.5C13 5.672 13.672 5 14.5 5H15.5C16.328 5 17 5.672 17 6.5V17.5C17 18.328 16.328 19 15.5 19H14.5C13.672 19 13 18.328 13 17.5V6.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none">
|
||||
<path opacity="0.12" d="M8 6.8C8 5.9 9 5.4 9.7 5.9L18 11.1C18.6 11.5 18.6 12.5 18 12.9L9.7 18.1C9 18.6 8 18.1 8 17.2V6.8Z" fill="currentColor"></path>
|
||||
<path d="M8 6.8C8 5.9 9 5.4 9.7 5.9L18 11.1C18.6 11.5 18.6 12.5 18 12.9L9.7 18.1C9 18.6 8 18.1 8 17.2V6.8Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</symbol>
|
||||
<symbol id="i-stop" viewBox="0 0 24 24" fill="none">
|
||||
<path opacity="0.12" d="M5 8C5 6.343 6.343 5 8 5H16C17.657 5 19 6.343 19 8V16C19 17.657 17.657 19 16 19H8C6.343 19 5 17.657 5 16V8Z" fill="currentColor"></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Headless check of the approval card, light and dark, against a real host, the real Harness and a scripted model:
|
||||
*
|
||||
* STRATA_HARNESS_DIR=... node scripts/approval-flow.mjs [shots-dir]
|
||||
*
|
||||
* For each command the scripted model asks to run in Manual mode it checks that the card asks in plain words, says nothing of
|
||||
* tool names or JSON in its headline, and keeps the exact command under Details; then it approves and checks the row that remains.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { startScriptedWorker } from '../tests/helpers/scripted-worker.mjs';
|
||||
|
||||
const HOST_DIR = resolve(import.meta.dirname, '..');
|
||||
const SHOTS = resolve(process.argv[2] ?? 'approval-shots');
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
const { chromium } = await import(pathToFileURL(join(HOST_DIR, 'node_modules/playwright-core/index.mjs')).href);
|
||||
const CHROME = process.env.CHROME ?? ['C:/Program Files/Google/Chrome/Application/chrome.exe', '/usr/bin/google-chrome'].find(existsSync);
|
||||
const freePort = () => new Promise(r => { const s = createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); });
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
let failed = false;
|
||||
const check = (ok, label, extra = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${extra ? ' ' + extra : ''}`); if (!ok) failed = true; };
|
||||
|
||||
const worker = await startScriptedWorker({ model: 'cloud-model' });
|
||||
const home = mkdtempSync(join(tmpdir(), 'strata-appr-'));
|
||||
const ws = join(home, 'workspace'); mkdirSync(ws, { recursive: true });
|
||||
const port = await freePort();
|
||||
const env = { ...process.env, STRATA_HOME: home, STRATA_PORT: String(port), STRATA_WORKSPACE: ws, STRATA_REAL_ENGINE: '0', STRATA_COMPONENTS_DIR: join(home, 'components') };
|
||||
const child = spawn(process.execPath, [join(HOST_DIR, 'server', 'index.mjs')], { cwd: HOST_DIR, env, stdio: 'ignore', windowsHide: true });
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
for (let i = 0; i < 100; i++) { try { if ((await fetch(`${base}/api/agent/status`)).ok) break; } catch { /* starting */ } await sleep(200); }
|
||||
const saved = await (await fetch(`${base}/api/model-providers`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'custom', name: 'Scripted API', baseUrl: worker.url, protocol: 'openai-chat', apiKey: 'sk-approval-test-0123456789', models: [{ id: 'cloud-model', name: 'Cloud model', contextMax: 64000 }] }) })).json();
|
||||
check(!!saved.connections?.[0]?.id, 'a scripted API model is connected');
|
||||
|
||||
const CASES = [
|
||||
{ call: { command: 'Start-Process explorer.exe -ArgumentList "C:\\Users\\Ana\\Documents"', description: 'Open Documents' }, tool: 'pwsh', question: /Open your Documents folder in File Explorer\?/, details: /Start-Process explorer\.exe/ },
|
||||
{ call: { file_path: join(ws, 'notes.txt').replaceAll('\\', '/'), content: 'a\nb\nc\n' }, tool: 'write', question: /Create the file notes\.txt\?/, details: /notes\.txt/ },
|
||||
{ call: { command: 'Start-Process "C:\\Users\\Ana\\Downloads\\setup-1.4.2.exe" -ArgumentList "/S"', description: 'Run the installer' }, tool: 'pwsh', question: /Run a program: setup-1\.4\.2\.exe\?/, details: /setup-1\.4\.2\.exe/ },
|
||||
];
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
||||
for (const scheme of ['light', 'dark']) {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, colorScheme: scheme });
|
||||
const page = await context.newPage();
|
||||
const problems = []; page.on('pageerror', e => problems.push(e.message));
|
||||
await page.goto(`${base}/#/chat`);
|
||||
await page.waitForTimeout(2000);
|
||||
const skip = page.getByText('Go straight to the app');
|
||||
if (await skip.count()) { await skip.click(); await page.waitForTimeout(600); }
|
||||
await page.getByRole('button', { name: 'New chat' }).first().click();
|
||||
await page.waitForTimeout(600);
|
||||
await page.getByRole('button', { name: /Choose model/ }).first().click();
|
||||
await page.getByRole('menuitemradio', { name: /Cloud model/ }).first().click();
|
||||
let n = 0;
|
||||
for (const c of CASES) {
|
||||
n += 1;
|
||||
await page.locator('textarea').first().fill(`SCRIPT:call ${c.tool} ${JSON.stringify(c.call)}`);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
const card = page.locator('.askcard').first();
|
||||
await card.waitFor({ timeout: 120000 });
|
||||
const head = ((await card.locator('.askcard__q').textContent()) ?? '').trim();
|
||||
check(c.question.test(head), `${scheme}: the card asks "${head}"`);
|
||||
check(!/\b(pwsh|bash|write|sandbox|json|powershell)\b/i.test(head) || /Run a program/.test(head), `${scheme}: the headline has no tool name or jargon`);
|
||||
const box = await card.boundingBox();
|
||||
check(!!box && box.height < 110, `${scheme}: the card is compact`, String(Math.round(box?.height ?? 0)) + ' px');
|
||||
check(await card.locator('details').evaluate(d => !d.open), `${scheme}: Details starts closed`);
|
||||
await card.locator('summary').click();
|
||||
const exact = ((await card.locator('pre').textContent()) ?? '');
|
||||
check(c.details.test(exact), `${scheme}: the exact command is under Details`, exact.slice(0, 80));
|
||||
if (n === 1) await page.screenshot({ path: join(SHOTS, `approval-${scheme}.png`) });
|
||||
await page.getByRole('button', { name: 'Deny' }).click();
|
||||
await page.waitForFunction(() => document.documentElement.dataset.streaming !== 'true', null, { timeout: 120000 }).catch(() => {});
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
check(problems.length === 0, `${scheme}: no page error`, problems.join('; '));
|
||||
await context.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }); else child.kill('SIGTERM');
|
||||
await worker.close();
|
||||
try { rmSync(home, { recursive: true, force: true, maxRetries: 3 }); } catch { /* the Harness sandbox leaves entries; best effort */ }
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* An approval question in plain words: what the agent is about to do, as a sentence a person who never opens a terminal can answer.
|
||||
*
|
||||
* `askFor(name, args)` returns `{ id, params, why, detail }`:
|
||||
* - `id` + `params` name one fixed sentence the page translates (src/lib/ask.ts holds the English templates and `{0}` slots),
|
||||
* - `why` is an optional second fixed sentence id, `detail` a short plain line (a path) shown beneath it,
|
||||
* - the exact command or arguments stay on the card under "Details" for experts.
|
||||
* Anything not recognised falls back to a generic question, never to the raw command as the headline.
|
||||
*/
|
||||
|
||||
const KNOWN_FOLDERS = { documents: 'Documents', downloads: 'Downloads', desktop: 'Desktop', pictures: 'Pictures', music: 'Music', videos: 'Videos' };
|
||||
|
||||
const clip = (value, max = 120) => { const t = String(value ?? '').replace(/\s+/g, ' ').trim(); return t.length > max ? `${t.slice(0, max - 1)}…` : t; };
|
||||
const unquote = text => String(text ?? '').trim().replace(/^(['"])(.*)\1$/, '$2');
|
||||
const baseName = path => { const t = unquote(path).replace(/[\\/]+$/, ''); return t.split(/[\\/]/).pop() || t; };
|
||||
const hasExtension = path => /\.[A-Za-z0-9]{1,8}$/.test(baseName(path));
|
||||
const isExecutable = path => /\.(exe|msi|bat|cmd|ps1|com|scr|msix|appx|vbs|jar|sh)$/i.test(unquote(path));
|
||||
|
||||
/** Words of a command line, quotes kept together. */
|
||||
function words(text) {
|
||||
const out = []; const re = /"([^"]*)"|'([^']*)'|(\S+)/g; let m;
|
||||
while ((m = re.exec(text))) out.push(m[1] ?? m[2] ?? m[3]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The first argument that is not a switch (-Name value pairs are skipped by the callers that need them). */
|
||||
const firstPath = list => list.find(w => !/^-/.test(w)) ?? '';
|
||||
|
||||
function folderAsk(target) {
|
||||
const clean = unquote(target);
|
||||
const known = KNOWN_FOLDERS[baseName(clean).toLowerCase()];
|
||||
if (known) return { id: 'open-known-folder', params: [known], why: 'opens-window', detail: clip(clean, 160) };
|
||||
if (hasExtension(clean)) return { id: 'open-file', params: [baseName(clean)], why: 'opens-window', detail: clip(clean, 160) };
|
||||
return { id: 'open-folder', params: [baseName(clean) || clean], why: 'opens-window', detail: clip(clean, 160) };
|
||||
}
|
||||
|
||||
function shellAsk(command) {
|
||||
const text = String(command ?? '').trim();
|
||||
// Several steps in one command: say so rather than guessing at one of them.
|
||||
const steps = text.split(/\s*(?:;|&&|\|\|)\s*|\r?\n/).filter(Boolean);
|
||||
const first = steps[0] ?? '';
|
||||
const w = words(first);
|
||||
const verb = (w[0] ?? '').toLowerCase().replace(/\.exe$/, '');
|
||||
const rest = w.slice(1);
|
||||
if (steps.length > 1) {
|
||||
const kinds = new Set(steps.map(s => (words(s)[0] ?? '').toLowerCase()));
|
||||
if (kinds.size > 1) return { id: 'run-steps', params: [steps.length], why: 'run-steps-why', detail: clip(first, 100) };
|
||||
}
|
||||
if (verb === 'start-process' || verb === 'start' || verb === 'saps') {
|
||||
const pathAt = rest.findIndex((x, i) => !/^-/.test(x) && !(i > 0 && /^-(argumentlist|args|workingdirectory|verb|wait)$/i.test(rest[i - 1])));
|
||||
const nameFlag = rest.findIndex(x => /^-(filepath|path)$/i.test(x));
|
||||
const target = nameFlag >= 0 ? rest[nameFlag + 1] : rest[pathAt] ?? '';
|
||||
const lower = baseName(target).toLowerCase();
|
||||
if (/^explorer(\.exe)?$/.test(lower)) {
|
||||
const argAt = rest.findIndex(x => /^-(argumentlist|args)$/i.test(x));
|
||||
const tail = argAt >= 0 ? rest.slice(argAt + 1) : rest.filter((x, i) => i !== (nameFlag >= 0 ? nameFlag + 1 : pathAt));
|
||||
const folder = tail.find(x => !/^-/.test(x) && !/^\/(select|e|root)/i.test(x));
|
||||
return folder ? folderAsk(folder) : { id: 'open-explorer', params: [], why: 'opens-window' };
|
||||
}
|
||||
if (isExecutable(target)) return { id: 'run-program', params: [baseName(target)], why: 'run-program-why', detail: clip(unquote(target), 160) };
|
||||
if (target) return folderAsk(target);
|
||||
}
|
||||
if (verb === 'explorer') return rest.length ? folderAsk(firstPath(rest.filter(x => !/^\/(select|e|root)/i.test(x))) || rest[0]) : { id: 'open-explorer', params: [], why: 'opens-window' };
|
||||
if (verb === 'invoke-item' || verb === 'ii' || verb === 'xdg-open' || verb === 'open') return folderAsk(firstPath(rest));
|
||||
if (verb === 'remove-item' || verb === 'rm' || verb === 'del' || verb === 'erase' || verb === 'rmdir' || verb === 'rd') {
|
||||
const target = firstPath(rest.filter(x => !/^-(recurse|force|r|rf|fr|f|path|literalpath)$/i.test(x)));
|
||||
return { id: 'delete', params: [baseName(target) || 'these files'], why: 'delete-why', detail: clip(unquote(target), 160) };
|
||||
}
|
||||
if (verb === 'new-item' || verb === 'mkdir' || verb === 'md' || verb === 'ni') {
|
||||
const pathAt = rest.findIndex(x => /^-path$/i.test(x));
|
||||
const target = pathAt >= 0 ? rest[pathAt + 1] : firstPath(rest);
|
||||
const asFile = rest.some((x, i) => /^-(itemtype|type)$/i.test(x) && /file/i.test(rest[i + 1] ?? '')) || (verb === 'new-item' && hasExtension(target));
|
||||
return asFile ? { id: 'create-file', params: [baseName(target)], why: 'replaces-if-exists', detail: clip(unquote(target), 160) } : { id: 'create-folder', params: [baseName(target) || target], detail: clip(unquote(target), 160) };
|
||||
}
|
||||
if (['copy-item', 'cp', 'copy'].includes(verb)) return { id: 'copy', params: [baseName(firstPath(rest))], detail: clip(rest.filter(x => !/^-/.test(x)).join(' → '), 160) };
|
||||
if (['move-item', 'mv', 'move'].includes(verb)) return { id: 'move', params: [baseName(firstPath(rest))], detail: clip(rest.filter(x => !/^-/.test(x)).join(' → '), 160) };
|
||||
if (['rename-item', 'ren', 'rni'].includes(verb)) return { id: 'rename', params: [baseName(firstPath(rest))], detail: clip(rest.filter(x => !/^-/.test(x)).join(' → '), 160) };
|
||||
if (/(set-content|add-content|out-file|tee-object)/i.test(first) || />{1,2}\s*\S+/.test(first)) {
|
||||
const redirect = />{1,2}\s*("[^"]+"|'[^']+'|\S+)/.exec(first)?.[1];
|
||||
const all = words(first);
|
||||
const cmdAt = all.findIndex(x => /^(set-content|add-content|out-file|tee-object)$/i.test(x));
|
||||
const after = cmdAt >= 0 ? all.slice(cmdAt + 1) : rest;
|
||||
const pathAt = after.findIndex(x => /^-(path|filepath|literalpath)$/i.test(x));
|
||||
const target = unquote(redirect ?? (pathAt >= 0 ? after[pathAt + 1] : firstPath(after)));
|
||||
return { id: 'write-file', params: [baseName(target) || 'a file'], why: 'replaces-if-exists', detail: clip(target, 160) };
|
||||
}
|
||||
if (verb === 'git') {
|
||||
const sub = (rest[0] ?? '').toLowerCase();
|
||||
if (['commit', 'push', 'pull', 'merge', 'rebase', 'reset', 'checkout', 'clone', 'fetch', 'stash', 'switch', 'tag', 'branch', 'add', 'rm', 'mv', 'cherry-pick', 'revert'].includes(sub)) return { id: 'git', params: [sub], why: 'changes-repo' };
|
||||
}
|
||||
if (['npm', 'pnpm', 'yarn', 'pip', 'pip3', 'winget', 'choco', 'scoop', 'apt', 'apt-get', 'brew', 'cargo', 'dotnet', 'npx'].includes(verb)) {
|
||||
const sub = (rest[0] ?? '').toLowerCase();
|
||||
if (/^(install|i|add|update|upgrade|remove|uninstall|restore|publish)$/.test(sub)) return { id: 'install', params: [verb], why: 'install-why', detail: clip(rest.slice(1).filter(x => !/^-/.test(x)).join(' '), 120) };
|
||||
}
|
||||
if (isExecutable(w[0] ?? '') || /^[.&]/.test(first) && isExecutable(rest[0] ?? '')) return { id: 'run-program', params: [baseName(isExecutable(w[0]) ? w[0] : rest[0])], why: 'run-program-why' };
|
||||
if (['python', 'python3', 'py', 'node', 'pwsh', 'powershell', 'bash', 'sh', 'cmd'].includes(verb)) {
|
||||
const script = rest.find(x => /\.(py|js|mjs|ps1|sh|bat|cmd)$/i.test(x));
|
||||
return script ? { id: 'run-script', params: [baseName(script)], why: 'run-program-why' } : { id: 'run-command', params: [], why: 'run-command-why' };
|
||||
}
|
||||
return { id: 'run-command', params: [], why: 'run-command-why' };
|
||||
}
|
||||
|
||||
/** @param {string} name tool name @param {object} args @param {string|null} [described] the model's own one-line description */
|
||||
export function askFor(name, args = {}, described = null) {
|
||||
const path = args.file_path ?? args.path ?? args.filePath ?? '';
|
||||
switch (name) {
|
||||
case 'write': return { id: 'create-file', params: [baseName(path) || 'a file'], why: 'replaces-if-exists', detail: clip(path, 160) };
|
||||
case 'edit': return { id: 'change-file', params: [baseName(path) || 'a file'], detail: clip(path, 160) };
|
||||
case 'pwsh': case 'bash': {
|
||||
const ask = shellAsk(args.command);
|
||||
// A command nothing above could read: the model's own one-line description is better than "Run a command" alone.
|
||||
if (ask.id === 'run-command' && described) return { ...ask, detail: clip(described, 140) };
|
||||
return ask;
|
||||
}
|
||||
case 'job_kill': return { id: 'stop-task', params: [] };
|
||||
case 'web_fetch': {
|
||||
let host = String(args.url ?? '');
|
||||
try { host = new URL(host).hostname; } catch { /* keep the text */ }
|
||||
return { id: 'open-page', params: [clip(host, 80)], detail: clip(args.url, 160) };
|
||||
}
|
||||
default:
|
||||
if (String(name).startsWith('mcp__')) return { id: 'use-tool', params: [String(name).split('__').slice(1).join(' / ')], why: 'use-tool-why' };
|
||||
return { id: 'use-other', params: [String(name)] };
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
* reasoning deltas, usage).
|
||||
*/
|
||||
|
||||
import { askFor } from './ask.mjs';
|
||||
|
||||
const SHELL = new Set(['pwsh', 'bash', 'job_kill', 'job_output', 'job_list']);
|
||||
const WRITE = new Set(['write', 'edit']);
|
||||
const READ = new Set(['read', 'glob', 'grep', 'read_image', 'working_directory', 'todo_write', 'get_goal', 'list_agents', 'skill', 'present']);
|
||||
@@ -98,7 +100,7 @@ export function presentTool({ callId, name, args, reason = null }) {
|
||||
const effect = reason ?? (words ? words[0] : scope === 'shell' ? (args.description ?? 'Runs a command in the workspace.')
|
||||
: scope === 'write' ? 'Changes a file in the workspace.'
|
||||
: scope === 'read' ? 'Reads from the workspace.' : 'Uses a connected tool.');
|
||||
return { id: callId, name, args, preview: clip(preview, 600), effect: clip(effect, 400), scope, verb: toolVerb(name, 'running'), target: clip(name in VERBS ? preview : name, 300) };
|
||||
return { id: callId, name, args, preview: clip(preview, 600), effect: clip(effect, 400), scope, verb: toolVerb(name, 'running'), target: clip(name in VERBS ? preview : name, 300), ask: askFor(name, args, typeof args.description === 'string' ? args.description : null) };
|
||||
}
|
||||
|
||||
/** A tool's failure in plain words, or null when there is nothing better than the original text. */
|
||||
|
||||
@@ -88,6 +88,8 @@ export function createAutoTune({ launcher, workDir, spawnProcess = spawn, pollMs
|
||||
args.push('--goal', opts.goal === 'room' ? 'room' : 'speed');
|
||||
if (opts.leaveRam) args.push('--leave-ram', String(Math.max(0, Math.min(50, Math.round(opts.leaveRam)))));
|
||||
if (opts.allowSmallDifferences) args.push('--allow-small-differences');
|
||||
// For headless checks on a PC where another engine runs: do not wait for it (the tuner would sit in its quiet check).
|
||||
if (process.env.STRATA_TUNE_NO_QUIET_CHECK === '1') args.push('--no-quiet-check');
|
||||
if (opts.name) args.push('--name', String(opts.name).slice(0, 80));
|
||||
}
|
||||
const child = spawnProcess(l.command, [...(l.prefix ?? []), ...args], { cwd: l.cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: l.env });
|
||||
@@ -98,7 +100,7 @@ export function createAutoTune({ launcher, workDir, spawnProcess = spawn, pollMs
|
||||
const poll = () => {
|
||||
try {
|
||||
const p = JSON.parse(readFileSync(statusFile, 'utf8'));
|
||||
if (p && p.state !== 'done') { job.progress = { stage: p.stage, text: p.text ?? null, minutesLeft: p.minutes_left ?? null, runsDone: p.runs_done ?? 0, runsPlanned: p.runs_planned ?? 0, elapsedS: p.elapsed_s ?? 0, step: p.message ?? '' }; onProgress(job.progress); }
|
||||
if (p && p.state !== 'done') { job.progress = { stage: p.stage, text: p.text ?? null, minutesLeft: p.minutes_left ?? null, runsDone: p.runs_done ?? 0, runsPlanned: p.runs_planned ?? 0, elapsedS: p.elapsed_s ?? 0, step: p.message ?? '', speeds: Array.isArray(p.speeds) ? p.speeds : [] }; onProgress(job.progress); }
|
||||
} catch { /* not written yet */ }
|
||||
};
|
||||
const timer = setInterval(poll, pollMs);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Screen } from './layout';
|
||||
import { VersionPicker } from './VersionPicker';
|
||||
import { PcFitCard } from './PcFitCard';
|
||||
import { fitOn } from '../lib/native-fit';
|
||||
import { Btn, Dialog, ErrorNotice, Icon, Select } from './ui';
|
||||
import { installProgress, useDownloadEta } from '../lib/download-eta';
|
||||
import { sizeGb } from '../lib/format';
|
||||
@@ -37,6 +40,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id);
|
||||
// The model that is running is shown as running, with Unload: not offered for loading again.
|
||||
const downloaded = native?.installed?.models ?? [], allSizes = families.flatMap(f => f.sizes);
|
||||
// This computer for the fit figures: the biggest usable graphics card, or the ones chosen under Expert settings.
|
||||
const pcGpus = (native?.hardware?.gpus ?? []).filter(g => g.usable !== false), pcGpu = [...pcGpus].sort((x, y) => y.vram_gb - x.vram_gb)[0] ?? null;
|
||||
const pcRam = native?.hardware?.ram_gb || hardware?.memory.totalGb || null, pcVram = pcGpu?.vram_gb ?? 0;
|
||||
const fitHardware = { ram_gb: pcRam, vram_gb: pcVram };
|
||||
const loadedSelected = native?.instances.find(i => i.model === selectedSize?.id && i.status === 'ready');
|
||||
// A model that is already on this PC loads as it was prepared: the page shows its settings, so loading it never asks to download.
|
||||
useEffect(() => { if (installed?.ready) {
|
||||
@@ -93,14 +100,14 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
else
|
||||
setPlan(await api.post('/api/native/plan', downloadValues)); }); }
|
||||
async function download() { await perform(async () => { const result = await nativeAction<NativeJob>('operations', { kind: 'install', args: downloadValues }); setJobId(result.id); setPlan(null); }); }
|
||||
return <Screen plainHeader title={tr("Model")} titleDetail={selectedFamily ? <Select ariaLabel={tr("Model family")} value={selectedFamily.family} menuWidth={340} options={families.map(f => ({ value: f.family, label: f.title }))} onChange={id => { setFamily(id); setSize(''); setVision('no'); setError(''); }}/> : undefined} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); setGpus([]); setDeviceChoice('auto'); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
|
||||
return <Screen plainHeader title={tr("Model")} actions={<Btn icon="sliders" aria-pressed={expert} aria-expanded={expert} aria-controls="native-expert-settings" onClick={() => { setExpert(v => !v); setMultiple(false); setGpus([]); setDeviceChoice('auto'); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}</Btn>}>
|
||||
{tr(selectedFamily ? <div className="native-model-toolbar"><div className="native-toolbar-summary"><Icon name="models"/><strong>{tr(selectedFamily.title)}</strong><small>{tr(selectedSize ? catalogVersionLabel(selectedSize) : '')} · {tr(context / 1024)}{tr("K")}{tr(vision !== 'no' ? ' · Reads pictures' : '')}</small>{tr(native?.instances.some(i => i.model === selectedSize?.id && i.status === 'ready') ? <span className="native-ready">{tr("Ready")}</span> : null)}</div><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
|
||||
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></div> : null)}
|
||||
<div className="native-model-layout">
|
||||
<div className="stack stack--lg">
|
||||
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map(g => `GPU ${g + 1}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void perform(() => nativeAction(`instances/${i.id}/default`))}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${i.id}/unload`))}>{tr("Unload")}</Btn></div>))}</section> : null)}
|
||||
<section className="st-card native-choice">{tr(selectedFamily && !selectedSize ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
|
||||
{tr(selectedSize ? <><label className="input-field"><span>{tr("Version")}</span><Select ariaLabel={tr("Model version")} value={selectedSize.id} options={[...selectedFamily!.sizes.map(s => ({ value: s.id, label: `${catalogVersionLabel(s)} · ${s.model} · ${s.download_gb.toFixed(1)} GB${s.installed ? ` · ${tr('Downloaded')}` : ''}` })), { value: 'local-import', label: 'Use a local GGUF…' }]} onChange={id => id === 'local-import' ? (onImport ?? onOtherModels)() : setSize(id)}/></label><p className="small muted">{tr(selectedSize.about)}</p><div className="native-choice-facts"><span><Icon name="memory" size={16}/>{tr(selectedSize.ram_needed_gb)}{tr(" GB RAM class")}</span></div>
|
||||
{tr(selectedSize ? <><div className="input-field"><span>{tr("Version")}</span><VersionPicker versions={allSizes} selectedId={selectedSize.id} hardware={fitHardware} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div><p className="small muted">{tr(selectedSize.about)}</p>
|
||||
{tr(noFit ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("May run slower \u00B7 Model data can be read from SSD.")}</p> : null)}
|
||||
{tr(selectedSize?.images ? <div className="native-image-reading"><label><input type="checkbox" checked={vision !== 'no'} disabled={!selectedSize?.images || !!active} onChange={e => setVision(e.target.checked ? 'yes' : 'no')}/><span><strong>{tr("Read pictures ")}<small>{tr("Optional")}</small></strong><small>{tr("Understand photos, screenshots and charts. Vision files download automatically. Enable them when loading this model.")}</small></span></label>{tr(expert && vision !== 'no' ? <Select ariaLabel={tr("Image encoder device")} value={vision === 'yes' ? 'yes' : vision} options={[{ value: 'yes', label: 'Automatic' }, { value: 'gpu', label: 'GPU' }, { value: 'cpu', label: 'CPU' }]} onChange={setVision}/> : null)}</div> : null)}
|
||||
</> : null)}
|
||||
@@ -114,7 +121,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
{tr(error ? <ErrorNotice message={error}/> : null)}
|
||||
<div className="native-model-actions">{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || expert && deviceChoice === 'split' && gpus.length < 2} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
|
||||
</div>
|
||||
<aside className="stack stack--lg"><section className="st-card"><h2 className="h4">{tr("This computer")}</h2><p>{tr(native?.hardware?.ram_gb || hardware?.memory.totalGb || '—')}{tr(" GB RAM")}</p>{tr(native?.hardware?.gpus.length ? native.hardware.gpus.map(g => <p className="small" key={g.index}>{tr(g.name)} · {tr(g.vram_gb)}{tr(" GB")}</p>) : <p className="small muted">{tr("No supported GPU reported by this installation.")}</p>)}<p className="small muted">{tr("Compatibility and sizes come from your installed Strata version.")}</p></section><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
|
||||
<aside className="stack stack--lg"><PcFitCard gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} fit={selectedSize ? fitOn(selectedSize, fitHardware) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
|
||||
</div>
|
||||
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
|
||||
<NativeDeleteDialog id={deleting} onClose={() => setDeleting(null)}/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Btn, Dialog, Icon, Select } from './ui';
|
||||
import { TuneRunView, TuneResultView } from './TuneRunView';
|
||||
import { nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
import { ANSWER_NOTICE, tunePresetAction, tuneStatus, tuneUndo, type TuneProgress, type TunePreset, type TuneResult, type TuneStatus } from '../lib/native-tune';
|
||||
|
||||
@@ -54,26 +55,21 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
|
||||
const result = job?.state === 'done' ? (job.result as TuneResult | null) : null;
|
||||
const stopped = job?.state === 'cancelled';
|
||||
const ratio = progress && progress.runsPlanned ? Math.min(0.96, progress.runsDone / progress.runsPlanned) : 0;
|
||||
const left = progress?.minutesLeft ?? null;
|
||||
const active = status?.active ?? null;
|
||||
|
||||
const orbit = (complete: boolean) => <div className="tune-orbit" data-complete={complete}><svg viewBox="0 0 120 120" aria-hidden="true"><circle cx="60" cy="60" r="50"/><circle cx="60" cy="60" r="50" pathLength="100" strokeDasharray={`${complete ? 100 : Math.max(4, Math.round(ratio * 100))} 100`}/></svg><Icon name={complete ? 'check' : 'gauge'} size={42}/></div>;
|
||||
const notice = (cls?: string) => cls ? <p className="small muted">{tr(ANSWER_NOTICE[cls] ?? '')}</p> : null;
|
||||
|
||||
let body;
|
||||
let actions;
|
||||
if (phase === 'running') {
|
||||
body = <div className="tune-progress">{orbit(false)}<h3 role="status">{tr("Finding the fastest settings for your PC…")}</h3>
|
||||
<p className="small muted">{left != null ? tr("about {0} min left", [left]) : tr("This takes about 20 minutes.")}</p>
|
||||
{progress?.step ? <p className="xs muted">{progress.step}</p> : null}
|
||||
<div className="tune-steps">{[0, 1, 2, 3].map(i => <span key={i} data-done={ratio >= i / 4 && ratio > 0}/>)}</div></div>;
|
||||
body = <TuneRunView progress={progress} ratio={ratio} saving={false}/>;
|
||||
actions = <><Btn onClick={() => void act(() => nativeAction(`operations/${job!.id}/cancel`))} disabled={busy}>{tr("Stop")}</Btn></>;
|
||||
} else if (phase === 'result') {
|
||||
const improved = result?.outcome === 'improved' || result?.verdict === 'keep';
|
||||
const text = stopped ? tr("Stopped. Your settings were not changed.") : job?.state === 'error' ? tr("The tune did not finish. Your settings were not changed.") : result?.verdict ? (result.verdict === 'keep' ? tr("Still as fast as when it was saved.") : result.verdict === 'retune' ? tr("It is slower than before. You can tune again.") : tr("Could not tell right now. Your tuning was kept.")) : result?.outcome === 'improved' ? tr("+{0}% faster answers, saved as '{1}'", [Math.max(0, Math.round(result.gain_pct ?? 0)), result.preset?.name ?? '']) : result?.outcome === 'default_confirmed' ? tr("Your current settings are already the fastest we found.") : (result?.message ?? tr("Nothing was changed."));
|
||||
body = <div className="tune-progress">{orbit(improved || result?.outcome === 'default_confirmed')}<h3 role="status">{text}</h3>
|
||||
const good = improved || result?.outcome === 'default_confirmed';
|
||||
body = result?.outcome === 'improved' ? <div className="stack"><TuneResultView result={result} saved={result.preset?.name ?? null}/>{notice(result.answers?.class)}</div> : <div className="tune-progress"><div className="tune-orbit" data-complete={good}><svg viewBox="0 0 120 120" aria-hidden="true"><circle cx="60" cy="60" r="50"/><circle cx="60" cy="60" r="50" pathLength="100" strokeDasharray={`${good ? 100 : 4} 100`}/></svg><Icon name={good ? 'check' : 'gauge'} size={42}/></div><h3 role="status">{text}</h3>
|
||||
{result?.outcome === 'default_confirmed' && result.preset ? <p className="small muted">{tr("Saved as '{0}', so it is not tuned again when you reopen the app.", [result.preset.name])}</p> : null}
|
||||
{result?.outcome === 'improved' ? <><p className="small muted">{tr("Speed: {0} → {1} tok/s", [Math.round((result.base?.decode_tok_s ?? 0) * 10) / 10, Math.round((result.chosen?.decode_tok_s ?? 0) * 10) / 10])}</p>{notice(result.answers?.class)}</> : null}
|
||||
|
||||
{job?.state === 'error' ? <p className="small inline-error" role="alert">{job.error}</p> : null}</div>;
|
||||
actions = <>{status?.can_undo && result?.outcome === 'improved' ? <Btn disabled={busy} onClick={() => void act(async () => { await tuneUndo(model, resourceProfile, vision); setJobId(''); })}>{tr("Undo")}</Btn> : null}<Btn variant="primary" onClick={close}>{tr("Close")}</Btn></>;
|
||||
} else {
|
||||
@@ -104,5 +100,5 @@ export function NativeTune({ open, onClose, model, resourceProfile, vision }: {
|
||||
{active && status?.state === 'soft' ? <Btn disabled={busy || !!other} onClick={() => void start('recheck')}>{tr("Check again")}</Btn> : null}
|
||||
<Btn variant="primary" icon="gauge" disabled={busy || !!other || !status} onClick={() => void start('autotune')}>{tr(active ? "Tune again" : "Start tuning")}</Btn></>;
|
||||
}
|
||||
return <Dialog open={open} title={tr("Tune this model")} onClose={close} actions={actions}>{body}</Dialog>;
|
||||
return <Dialog open={open} wide={phase !== 'idle'} title={phase === 'result' && result?.outcome === 'improved' ? tr("Tuned and saved") : tr("Tune this model")} onClose={close} actions={actions}>{body}</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import type { Fit } from '../lib/native-fit';
|
||||
|
||||
const gb = (n: number) => `${Number.isInteger(n) ? n : n.toFixed(1)} GB`;
|
||||
|
||||
function Meter({ label, total, use }: { label: string; total: number; use: number }) {
|
||||
const pct = total > 0 ? Math.max(2, Math.min(100, (use / total) * 100)) : 0;
|
||||
return <div className="pc-meter">
|
||||
<div className="pc-meter__top"><span>{tr(label)}</span><b>{gb(use)}</b><span className="muted">{tr("of {0}", [gb(total)])}</span></div>
|
||||
<div className="pc-meter__track" role="img" aria-label={tr("{0}: about {1} of {2} used by this model", [tr(label), gb(use), gb(total)])}><i style={{ width: `${pct}%` }}/></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* "This computer": the graphics memory and RAM, and what the chosen version would take of each. One plain line says whether it fits
|
||||
* or how much would be read from the SSD (the n-gram table, which always sits on the SSD, is not counted).
|
||||
*/
|
||||
export function PcFitCard({ gpuName, ramGb, vramGb, fit }: { gpuName?: string | null; ramGb: number | null; vramGb: number; fit: Fit | null }) {
|
||||
return <section className="st-card pc-card">
|
||||
<div><h2 className="h4">{tr("This computer")}</h2>
|
||||
<p className="small muted">{tr(gpuName ? tr("{0} · {1} graphics memory", [gpuName, gb(vramGb)]) : tr("No supported graphics card reported by this installation."))}{ramGb ? tr(" · {0} RAM", [gb(ramGb)]) : ''}</p></div>
|
||||
{tr(fit?.known && vramGb > 0 ? <Meter label="Graphics memory" total={vramGb} use={fit.vramUseGb}/> : null)}
|
||||
{tr(fit?.known && ramGb ? <Meter label="RAM" total={ramGb} use={fit.ramUseGb}/> : null)}
|
||||
{tr(fit?.known ? <p className="pc-note">{tr(fit.spillGb >= 1 ? 'About {0} GB will be read from the SSD, so answers can be a little slower.' : 'Fits fully in memory.', [Math.round(fit.spillGb)])}</p> : null)}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { tr, localeTag } from '../lib/i18n';
|
||||
import { Icon } from './ui';
|
||||
import { Markdown } from './Markdown';
|
||||
import { ErrorNotice } from './ui';
|
||||
import type { Task } from '../lib/tasks';
|
||||
|
||||
const FREQUENCY: Record<string, string> = { hourly: 'Every hour', daily: 'Every day', weekdays: 'Weekdays', weekly: 'Every week' };
|
||||
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const MODE: Record<string, string> = { plan: 'Look only, change nothing', manual: 'Stop and tell me before changing anything', bypass: 'Allowed to make changes' };
|
||||
|
||||
const when = (iso?: string) => (iso ? new Date(iso).toLocaleString(localeTag(), { dateStyle: 'medium', timeStyle: 'short' }) : '');
|
||||
|
||||
/** The schedule in one line: "Weekdays · 08:00", "Every week · Friday · 16:00". */
|
||||
export function scheduleText(t: Task): string {
|
||||
const base = tr(FREQUENCY[t.frequency] ?? t.frequency);
|
||||
if (t.frequency === 'hourly') return base;
|
||||
return [base, t.frequency === 'weekly' ? tr(WEEKDAYS[t.weekday] ?? '') : '', t.at].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
export type TaskState = 'running' | 'active' | 'paused';
|
||||
export const taskState = (t: Task): TaskState => (t.status === 'running' ? 'running' : t.enabled ? 'active' : 'paused');
|
||||
|
||||
/**
|
||||
* An opened task, skimmable: when it runs, what it may do, what it did last, then the instructions and the last answer.
|
||||
* The title, the state and the actions (pause or resume, delete, Edit, Run now) are in the header the screen draws.
|
||||
*/
|
||||
export function TaskDetail({ task, modelName }: { task: Task; modelName: string | null }) {
|
||||
const state = taskState(task);
|
||||
const result = task.status === 'running' ? { label: 'Running…', tone: '' }
|
||||
: task.error ? { label: 'Last run failed', tone: 'is-bad' }
|
||||
: task.lastRunAt || task.output ? { label: 'Done', tone: 'is-good' } : { label: 'Not run yet', tone: '' };
|
||||
return <div className="task-detail">
|
||||
<section className="st-card task-facts" aria-label={tr("Task summary")}>
|
||||
<div><span className="label">{tr("Schedule")}</span><strong><Icon name="clock" size={16}/>{scheduleText(task)}</strong>
|
||||
<span className="small muted">{tr(state === 'paused' ? 'Paused' : task.nextRunAt ? tr("Next: {0}", [when(task.nextRunAt)]) : 'Calculating the next run…')}</span></div>
|
||||
<div><span className="label">{tr("What it may do")}</span><strong><Icon name="shield" size={16}/>{tr(MODE[task.mode ?? 'plan'])}</strong></div>
|
||||
{modelName ? <div><span className="label">{tr("Model")}</span><strong><Icon name="models" size={16}/>{modelName}</strong></div> : null}
|
||||
<div><span className="label">{tr("Last result")}</span><strong className={result.tone}>{result.tone === 'is-good' ? <Icon name="check" size={16}/> : null}{tr(result.label)}</strong>
|
||||
<span className="small muted">{when(task.lastRunAt)}</span></div>
|
||||
</section>
|
||||
<section className="st-card"><h2 className="label">{tr("What it does")}</h2><p className="task-instructions">{task.prompt}</p></section>
|
||||
<section className="st-card"><h2 className="label">{tr("Last answer")}{task.demo ? tr(' · Example') : ''}</h2>
|
||||
{task.error ? <ErrorNotice message={task.error} next={tr("Try running this task again.")}/> : task.output ? <Markdown text={task.output}/> : <p className="small muted">{tr("Run this task to see its first result here.")}</p>}</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { Icon, type IconName } from './ui';
|
||||
import type { TuneProgress, TuneResult } from '../lib/native-tune';
|
||||
import { tunePhase, tuneSpeeds } from '../lib/tune-phase';
|
||||
|
||||
const STEPS: { icon: IconName; title: string; running: string }[] = [
|
||||
{ icon: 'cpu', title: 'Checking your PC', running: 'Looking at your graphics card and memory' },
|
||||
{ icon: 'gauge', title: 'Measuring the base speed', running: 'Timing your current settings' },
|
||||
{ icon: 'sliders', title: 'Trying settings', running: 'Trying other settings, one at a time' },
|
||||
{ icon: 'thinking', title: 'Confirming the best one', running: 'Repeating the test to be sure' },
|
||||
{ icon: 'disk', title: 'Saving', running: 'Keeping it for this model; you can undo it' },
|
||||
];
|
||||
|
||||
const fmt = (n: number) => (Math.round(n * 10) / 10).toFixed(1);
|
||||
|
||||
function Spark({ values }: { values: number[] }) {
|
||||
if (values.length < 2) return <div className="tune-spark tune-spark--empty" aria-hidden="true"/>;
|
||||
const lo = Math.min(...values), hi = Math.max(...values), span = Math.max(0.0001, hi - lo);
|
||||
const pts = values.map((v, i) => [(i / (values.length - 1)) * 220, 56 - ((v - lo) / span) * 46] as const);
|
||||
const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
|
||||
const [lx, ly] = pts[pts.length - 1];
|
||||
return <svg className="tune-spark" viewBox="0 0 220 64" preserveAspectRatio="none" aria-hidden="true"><path d={`${line} L220 64 L0 64Z`} className="tune-spark__fill"/><path d={line} className="tune-spark__line"/><circle cx={lx} cy={ly} r="4"/></svg>;
|
||||
}
|
||||
|
||||
/** The tuner at work: five steps, each with its own icon and state, and the speed it has measured so far. */
|
||||
export function TuneRunView({ progress, ratio, saving }: { progress: TuneProgress | undefined; ratio: number; saving: boolean }) {
|
||||
const at = saving ? 4 : tunePhase(progress?.stage);
|
||||
const speeds = tuneSpeeds(progress);
|
||||
const base = speeds[0]?.tok_s ?? null;
|
||||
const now = speeds.length ? speeds[speeds.length - 1].tok_s : null;
|
||||
const left = progress?.minutesLeft ?? null;
|
||||
const sub = (i: number): string => {
|
||||
if (i < at) return i === 1 && base != null ? tr("Starting point: {0} tokens per second", [fmt(base)]) : '';
|
||||
if (i === at && progress?.step) return progress.step;
|
||||
return i === at ? tr(STEPS[i].running) : '';
|
||||
};
|
||||
return <div className="tune-run">
|
||||
<ol className="tune-steps2" aria-label={tr("Tuning steps")}>
|
||||
{STEPS.map((s, i) => {
|
||||
const state = i < at ? 'done' : i === at ? 'run' : 'pending';
|
||||
return <li key={s.title} className="tune-step" data-s={state} data-step={s.icon} aria-current={state === 'run' ? 'step' : undefined}>
|
||||
<span className="tune-step__badge"><Icon name={state === 'done' ? 'check' : s.icon} size={22}/></span>
|
||||
<span className="tune-step__text"><strong>{tr(s.title)}</strong><small>{sub(i)}</small></span>
|
||||
<span className="tune-step__state">{tr(state === 'done' ? 'Done' : state === 'run' ? 'Running' : 'Waiting')}</span>
|
||||
</li>;
|
||||
})}
|
||||
</ol>
|
||||
<aside className="tune-live" aria-live="polite">
|
||||
<span className="label">{tr("Speed now")}</span>
|
||||
<div className="tune-live__big">{now != null ? fmt(now) : '—'}<small>{tr("tokens per second")}</small></div>
|
||||
<Spark values={speeds.map(x => x.tok_s)}/>
|
||||
<div className="tune-live__bar" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(ratio * 100)}><i style={{ width: `${Math.max(3, Math.round(ratio * 100))}%` }}/></div>
|
||||
<p className="small muted">{left != null ? tr("about {0} min left", [left]) : tr("This takes about 20 minutes.")}</p>
|
||||
<ul className="tune-live__list">{speeds.slice(-6).map((x, i) => <li key={`${x.label}-${i}`}><span>{x.label === 'base' ? tr("Your settings") : x.label}</span><b data-up={base != null && x.label !== 'base' && x.tok_s > base ? 'true' : undefined}>{fmt(x.tok_s)}</b></li>)}</ul>
|
||||
</aside>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** The result: the gain large, before and after, and what it was saved as. */
|
||||
export function TuneResultView({ result, saved }: { result: TuneResult; saved: string | null }) {
|
||||
const before = result.base?.decode_tok_s ?? 0, after = result.chosen?.decode_tok_s ?? 0;
|
||||
const gain = result.gain_pct ?? (before ? ((after / before) - 1) * 100 : 0);
|
||||
const top = Math.max(before, after, 1);
|
||||
return <div className="tune-result">
|
||||
<div className="tune-hero"><span className="label">{tr("Faster answers")}</span><div className="tune-hero__big">+{Math.round(gain)}%</div>
|
||||
<p className="small muted">{tr("{0} to {1} tokens per second", [fmt(before), fmt(after)])}</p></div>
|
||||
<div className="tune-compare">
|
||||
<div><span className="small muted">{tr("Before")}</span><div className="tune-compare__bar"><i style={{ width: `${(before / top) * 100}%` }}/></div><b>{fmt(before)}</b></div>
|
||||
<div data-new="true"><span className="small">{tr("After")}</span><div className="tune-compare__bar"><i style={{ width: `${(after / top) * 100}%` }}/></div><b>{fmt(after)}</b></div>
|
||||
{saved ? <p className="small muted">{tr("Saved as {0}. It is used every time you load this model.", [saved])}</p> : null}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { tr } from '../lib/i18n';
|
||||
import { Icon } from './ui';
|
||||
import { FloatingMenu } from './FloatingMenu';
|
||||
import { cx } from '../lib/format';
|
||||
import type { CatalogVersion } from '../lib/native-catalog';
|
||||
import { RECOMMENDED, VERSION_GROUPS, fitOn, type FitHardware } from '../lib/native-fit';
|
||||
|
||||
/** The name a version goes by in the list: the quantization, with the maker where the quantization alone is ambiguous. */
|
||||
export function versionName(v: CatalogVersion, withMaker = false): string {
|
||||
switch (v.sourceFamily) {
|
||||
case 'swift': return `Swift ${v.model}`;
|
||||
case 'unsloth': return `Unsloth ${v.model}`;
|
||||
case 'coder': return `Coder ${v.model}`;
|
||||
case 'qwen': return withMaker ? `${v.model} (GSQ-RCO)` : v.model;
|
||||
default: return v.model;
|
||||
}
|
||||
}
|
||||
|
||||
const gb = (n: number) => `${Number.isInteger(n) ? n : n.toFixed(1)} GB`;
|
||||
|
||||
/**
|
||||
* The version list of the Models screen: one line per model. The bold figure is the RAM + VRAM it needs together (the n-gram table,
|
||||
* which stays on the SSD, is not in it); the download size is secondary. "Uses SSD N GB" appears only where this computer would read
|
||||
* part of the model from its SSD. The first group is a fixed list of four; the rest fold away.
|
||||
*/
|
||||
export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnGguf }: {
|
||||
versions: CatalogVersion[];
|
||||
selectedId: string;
|
||||
hardware: FitHardware | null | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
onOwnGguf: () => void;
|
||||
}) {
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [folded, setFolded] = useState<Record<string, boolean>>({});
|
||||
const selected = versions.find(v => v.id === selectedId);
|
||||
const recommended = RECOMMENDED.map(r => ({ r, v: versions.find(v => v.sourceFamily === r.family && v.model === r.model) })).filter((x): x is { r: typeof RECOMMENDED[number]; v: CatalogVersion } => !!x.v);
|
||||
const groups = [
|
||||
...VERSION_GROUPS.map(g => ({ id: g.id, title: g.title, items: versions.filter(v => g.families.includes(v.sourceFamily)) })),
|
||||
{ id: 'other', title: 'Qwen3.6 models', items: versions.filter(v => !VERSION_GROUPS.some(g => g.families.includes(v.sourceFamily))) },
|
||||
].filter(g => g.items.length);
|
||||
const row = (v: CatalogVersion, label: string, note?: string) => {
|
||||
const fit = fitOn(v, hardware);
|
||||
const on = v.id === selectedId;
|
||||
return <button key={`${label}-${v.id}`} role="menuitemradio" aria-checked={on} className={cx('version-row', on && 'is-on')} onClick={() => { onSelect(v.id); setOpen(false); }}>
|
||||
<span className="version-row__name">{tr(on ? <Icon name="check" size={14}/> : null)}<strong>{label}</strong>{tr(note ? <span className="muted"> · {tr(note)}</span> : null)}{tr(fit.known && fit.spillGb >= 1 ? <span className="version-row__ssd">{tr("Uses SSD {0} GB", [Math.round(fit.spillGb)])}</span> : null)}</span>
|
||||
<span className="version-row__ram">{gb(fit.memoryGb)}</span>
|
||||
<span className="version-row__dl">{gb(v.download_gb)}</span>
|
||||
</button>;
|
||||
};
|
||||
const fit = selected ? fitOn(selected, hardware) : null;
|
||||
return <>
|
||||
<button ref={trigger} type="button" className="version-trigger" aria-haspopup="menu" aria-expanded={open} aria-label={tr("Model version")} onClick={() => setOpen(v => !v)}>
|
||||
<strong>{selected ? versionName(selected, true) : tr("Choose a version")}</strong>
|
||||
<span className="version-trigger__need muted">{tr(fit ? `${gb(fit.memoryGb)} RAM + VRAM` : '')}</span>
|
||||
<Icon name="chevron" size={14}/>
|
||||
</button>
|
||||
{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Model version")} width={620} onClose={() => setOpen(false)}>
|
||||
<div className="version-menu">
|
||||
<div className="version-head"><span>{tr("Recommended")}</span><span>{tr("RAM + VRAM")}</span><span>{tr("Download")}</span></div>
|
||||
{tr(recommended.map(({ r, v }) => row(v, versionName(v, true), r.note)))}
|
||||
{tr(groups.map(g => {
|
||||
const closed = folded[g.id] !== false && !g.items.some(v => v.id === selectedId);
|
||||
return <div className="version-group" key={g.id} data-open={!closed || undefined}>
|
||||
<button role="menuitem" className="version-group__head" aria-expanded={!closed} onClick={() => setFolded(f => ({ ...f, [g.id]: closed ? false : true }))}><span>{tr(g.title)} <span className="muted">{tr(g.items.length)}</span></span><Icon name="chevron" size={14} className={cx(!closed && 'is-open')}/></button>
|
||||
{tr(!closed ? g.items.map(v => row(v, versionName(v))) : null)}
|
||||
</div>;
|
||||
}))}
|
||||
<button role="menuitem" className="version-group__head version-own" onClick={() => { setOpen(false); onOwnGguf(); }}><span>{tr("Use your own GGUF…")}</span><Icon name="folder" size={14}/></button>
|
||||
</div>
|
||||
</FloatingMenu> : null)}
|
||||
</>;
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { tr } from '../lib/i18n';
|
||||
/** Screen chrome: a titled header pinned above a scrolling body. */
|
||||
import type { ReactNode } from 'react';
|
||||
import { cx } from '../lib/format';
|
||||
export function Screen({ title, sub, actions, children, width = 'normal', bare, plainHeader, titleDetail, }: {
|
||||
import { Icon } from './ui';
|
||||
export function Screen({ title, sub, actions, children, width = 'normal', bare, plainHeader, titleDetail, back, }: {
|
||||
title: ReactNode;
|
||||
sub?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
@@ -11,11 +12,13 @@ export function Screen({ title, sub, actions, children, width = 'normal', bare,
|
||||
bare?: boolean;
|
||||
plainHeader?: boolean;
|
||||
titleDetail?: ReactNode;
|
||||
/** A way back to the list this screen was opened from, shown before the title (as the chat's navbar shows its own title). */
|
||||
back?: { label: string; onClick: () => void };
|
||||
}) {
|
||||
return (<>
|
||||
<header className={cx("st-header main__head", plainHeader && "main__head--plain")}>
|
||||
<div className="main__titles">
|
||||
<div className="main__title-line"><h1 className="main__title">{tr(title)}</h1>{tr(titleDetail ? <span className="main__title-detail">{tr(titleDetail)}</span> : null)}</div>
|
||||
<div className="main__title-line">{tr(back ? <button type="button" className="main__back" onClick={back.onClick}><Icon name="chevron" size={14}/>{tr(back.label)}</button> : null)}<h1 className="main__title">{tr(title)}</h1>{tr(titleDetail ? <span className="main__title-detail">{tr(titleDetail)}</span> : null)}</div>
|
||||
{tr(sub ? <p className="main__sub">{tr(sub)}</p> : null)}
|
||||
</div>
|
||||
{tr(actions ? <div className="main__actions">{tr(actions)}</div> : null)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { plainFailure } from '../lib/plain-errors';
|
||||
import { dismissActionError, dismissToast, holdToast, releaseToast, useStore } from '../lib/store';
|
||||
import { isTopSurface, useSurfaceFocus } from '../lib/focus';
|
||||
/* -------------------------------------------------------------------- icon */
|
||||
export type IconName = 'activity' | 'plus' | 'shield' | 'computer'
|
||||
export type IconName = 'pause' | 'play' | 'activity' | 'plus' | 'shield' | 'computer'
|
||||
// Authored for the shell: see docs/design-system-notes.md.
|
||||
| 'panel' | 'search' | 'sliders' | 'folder' | 'folder-plus' | 'file' | 'ellipsis' | 'pencil' | 'pin' | 'x' | 'attach' | 'bolt' | 'branch' | 'chat' | 'check' | 'chevron' | 'clock' | 'copy' | 'cpu' | 'disk' | 'download' | 'error' | 'experts' | 'gauge' | 'gpu' | 'image' | 'info' | 'expand' | 'contract' | 'layers' | 'models' | 'skills' | 'engine' | 'family-qwen' | 'model-file' | 'link' | 'memory' | 'mic' | 'moon' | 'new-chat' | 'refresh' | 'send' | 'settings' | 'stop' | 'sun' | 'thermometer' | 'thinking' | 'tool' | 'trash' | 'warning';
|
||||
export function Icon({ name, size, className, style, }: {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { tr } from './i18n';
|
||||
import type { IconName } from '../components/ui';
|
||||
|
||||
/** The question and its one muted line for an approval card. The host names a sentence (server/agent/ask.mjs); the words are here, translated. */
|
||||
export interface Ask { id: string; params?: (string | number)[]; why?: string; detail?: string }
|
||||
|
||||
export function askQuestion(ask: Ask): string {
|
||||
const p = (ask.params ?? []).map(String);
|
||||
switch (ask.id) {
|
||||
case 'open-known-folder': return tr("Open your {0} folder in File Explorer?", p);
|
||||
case 'open-folder': return tr("Open the folder {0} in File Explorer?", p);
|
||||
case 'open-file': return tr("Open the file {0}?", p);
|
||||
case 'open-explorer': return tr("Open File Explorer?");
|
||||
case 'run-program': return tr("Run a program: {0}?", p);
|
||||
case 'run-script': return tr("Run the script {0}?", p);
|
||||
case 'run-steps': return tr("Run {0} steps on your computer?", p);
|
||||
case 'run-command': return tr("Run a command on your computer?");
|
||||
case 'delete': return tr("Delete {0}?", p);
|
||||
case 'create-file': return tr("Create the file {0}?", p);
|
||||
case 'create-folder': return tr("Create the folder {0}?", p);
|
||||
case 'write-file': return tr("Write to the file {0}?", p);
|
||||
case 'change-file': return tr("Change the file {0}?", p);
|
||||
case 'copy': return tr("Copy {0}?", p);
|
||||
case 'move': return tr("Move {0}?", p);
|
||||
case 'rename': return tr("Rename {0}?", p);
|
||||
case 'git': return tr("Run git {0}?", p);
|
||||
case 'install': return tr("Install packages with {0}?", p);
|
||||
case 'stop-task': return tr("Stop a background task?");
|
||||
case 'open-page': return tr("Open the web page {0}?", p);
|
||||
case 'use-tool': return tr("Use the connected tool {0}?", p);
|
||||
default: return tr("Use the tool {0}?", p);
|
||||
}
|
||||
}
|
||||
|
||||
function askWhy(id?: string): string | null {
|
||||
switch (id) {
|
||||
case 'opens-window': return tr("A window will open. Nothing is changed.");
|
||||
case 'run-program-why': return tr("It can change your computer.");
|
||||
case 'delete-why': return tr("It is removed from your computer.");
|
||||
case 'replaces-if-exists': return tr("Replaces the file if it already exists.");
|
||||
case 'changes-repo': return tr("It changes the project's files or history.");
|
||||
case 'install-why': return tr("It downloads software and changes your computer.");
|
||||
case 'run-steps-why': return tr("More than one thing happens. Details shows each step.");
|
||||
case 'run-command-why': return tr("Details shows exactly what it does.");
|
||||
case 'use-tool-why': return tr("It can act on the service you connected.");
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The muted line under the question: the folder or file for "open" questions, otherwise what to know before answering. */
|
||||
export function askSub(ask: Ask): string {
|
||||
const why = askWhy(ask.why);
|
||||
if (ask.id.startsWith('open-') && ask.detail) return ask.detail;
|
||||
return why ?? ask.detail ?? '';
|
||||
}
|
||||
|
||||
export function askIcon(ask: Ask): IconName {
|
||||
if (ask.id.startsWith('open-')) return ask.id === 'open-page' ? 'link' : 'folder';
|
||||
if (ask.id === 'delete') return 'trash';
|
||||
if (['create-file', 'write-file', 'change-file', 'copy', 'move', 'rename'].includes(ask.id)) return 'file';
|
||||
if (ask.id === 'create-folder') return 'folder-plus';
|
||||
if (['run-program', 'run-script', 'run-steps', 'run-command', 'git', 'install'].includes(ask.id)) return 'bolt';
|
||||
return 'tool';
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* What a model version asks of this computer, from the catalog's own figures and the hardware report.
|
||||
*
|
||||
* The numbers are RAM + VRAM together WITHOUT the n-gram table (it stays on the SSD and is read when needed). The experts are held
|
||||
* where there is room: first in the graphics memory beyond about 5 GB (the dense weights, buffers and a 32K cache), then in RAM beyond
|
||||
* about 10 GB left for the system; whatever is left is read from the SSD. The same rules setup uses to choose its low-RAM mode
|
||||
* (setup.py: low_ram_gpu_gb, LOW_RAM_HEADROOM_GB), so the page and the engine agree.
|
||||
*/
|
||||
export interface FitInput { ram_needed_gb?: number; experts_gb?: number | null; memory_gb?: number; mem_total?: boolean }
|
||||
export interface FitHardware { ram_gb?: number | null; vram_gb?: number | null }
|
||||
export interface Fit {
|
||||
/** RAM + VRAM the version needs (the n-gram table not counted). */
|
||||
memoryGb: number;
|
||||
/** About what it takes of the graphics memory / of RAM on this PC. */
|
||||
vramUseGb: number;
|
||||
ramUseGb: number;
|
||||
/** About how much is read from the SSD while answering; 0 when it all fits in memory. */
|
||||
spillGb: number;
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
const DENSE_GB = 5, SYSTEM_ROOM_GB = 10, DENSE_EXTRA_GB = 1;
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
export function memoryNeeded(v: FitInput): number {
|
||||
if (typeof v.memory_gb === 'number' && Number.isFinite(v.memory_gb)) return v.memory_gb;
|
||||
if (v.mem_total && typeof v.ram_needed_gb === 'number') return v.ram_needed_gb;
|
||||
return round1((v.experts_gb ?? v.ram_needed_gb ?? 0) + DENSE_EXTRA_GB);
|
||||
}
|
||||
|
||||
export function fitOn(v: FitInput, hw: FitHardware | null | undefined): Fit {
|
||||
const memoryGb = memoryNeeded(v);
|
||||
const ram = Number(hw?.ram_gb), vram = Number(hw?.vram_gb) || 0;
|
||||
if (!Number.isFinite(ram) || ram <= 0) return { memoryGb, vramUseGb: 0, ramUseGb: 0, spillGb: 0, known: false };
|
||||
const experts = Math.max(0, memoryNeeded(v) - DENSE_EXTRA_GB);
|
||||
const onGpu = Math.max(0, Math.min(experts, vram - DENSE_GB));
|
||||
const ramRoom = Math.max(0, ram - SYSTEM_ROOM_GB);
|
||||
const spillGb = Math.max(0, experts - onGpu - ramRoom);
|
||||
const inRam = Math.max(0, experts - onGpu - spillGb);
|
||||
const vramUseGb = vram > 0 ? Math.min(vram, DENSE_GB + onGpu) : 0;
|
||||
return { memoryGb, vramUseGb: round1(vramUseGb), ramUseGb: round1(inRam + DENSE_EXTRA_GB * (vram > 0 ? 0 : 1)), spillGb: round1(spillGb), known: true };
|
||||
}
|
||||
|
||||
/** The family groups of the version list, in the order shown. `family` is the engine's family id. */
|
||||
export const VERSION_GROUPS: { id: string; title: string; families: string[] }[] = [
|
||||
{ id: 'swift', title: 'Swift models', families: ['swift'] },
|
||||
{ id: 'gsq', title: 'GSQ-RCO models', families: ['qwen', 'coder'] },
|
||||
{ id: 'nvfp4', title: 'NVFP4 models', families: ['nvfp4'] },
|
||||
{ id: 'gyro', title: 'Gyro models', families: ['gyro'] },
|
||||
{ id: 'unsloth', title: 'Unsloth models', families: ['unsloth'] },
|
||||
];
|
||||
|
||||
/** The fixed "Recommended" picks, in order: engine family + model name + the short note shown beside the name. */
|
||||
export const RECOMMENDED: { family: string; model: string; note: string }[] = [
|
||||
{ family: 'gyro', model: 'Gyro-S', note: 'the smallest' },
|
||||
{ family: 'swift', model: 'IQ2_XS', note: 'good balance' },
|
||||
{ family: 'qwen', model: 'IQ3_S', note: 'best quality for its size' },
|
||||
{ family: 'unsloth', model: 'UD-Q4_K_XL', note: 'more quality' },
|
||||
];
|
||||
@@ -27,7 +27,7 @@ export interface TuneStatus {
|
||||
can_undo: boolean;
|
||||
}
|
||||
|
||||
export interface TuneProgress { stage: string; text: string | null; minutesLeft: number | null; runsDone: number; runsPlanned: number; elapsedS: number; step: string }
|
||||
export interface TuneProgress { stage: string; text: string | null; minutesLeft: number | null; runsDone: number; runsPlanned: number; elapsedS: number; step: string; /** each measurement so far, as decode tokens per second */ speeds?: { label: string; tok_s: number }[] }
|
||||
|
||||
export interface TuneResult {
|
||||
summary: string;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { TuneProgress } from './native-tune';
|
||||
|
||||
/**
|
||||
* Which of the five steps the tuner is in, from the stage its status file names (strata_control/autotune.py):
|
||||
* start and waiting are the PC check, base the first measurement, plan / rounds / drift / per_request the settings tried,
|
||||
* confirm the repeat test, and done the last moment before the result is saved and shown.
|
||||
*/
|
||||
export function tunePhase(stage: string | undefined | null): number {
|
||||
switch (stage) {
|
||||
case 'base': return 1;
|
||||
case 'plan': case 'rounds': case 'drift': case 'per_request': return 2;
|
||||
case 'confirm': return 3;
|
||||
case 'done': return 4;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every measurement so far: "base" first, then each setting tried, as decode tokens per second. */
|
||||
export function tuneSpeeds(progress: TuneProgress | undefined | null): { label: string; tok_s: number }[] {
|
||||
const list = (progress as (TuneProgress & { speeds?: { label?: unknown; tok_s?: unknown }[] }) | undefined | null)?.speeds;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.filter(x => typeof x?.tok_s === 'number' && Number.isFinite(x.tok_s) && x.tok_s > 0).map(x => ({ label: String(x.label ?? ''), tok_s: x.tok_s as number }));
|
||||
}
|
||||
@@ -358,6 +358,8 @@ export interface ToolCall {
|
||||
verb?: string;
|
||||
/** What the action is on: a file path, the command, a web address. */
|
||||
target?: string;
|
||||
/** The question an approval card asks in plain words (server/agent/ask.mjs); the page translates it. */
|
||||
ask?: { id: string; params?: (string | number)[]; why?: string; detail?: string };
|
||||
state: ToolState;
|
||||
result?: string;
|
||||
durationMs?: number;
|
||||
|
||||
@@ -2678,5 +2678,88 @@
|
||||
"s2677": "Arbeitsbereich umbenennen",
|
||||
"s2678": "Name des Arbeitsbereichs",
|
||||
"s2679": "Nur der Name ändert sich. Der Ordner bleibt, wo er ist.",
|
||||
"s2680": "Dieser Arbeitsbereich konnte nicht umbenannt werden"
|
||||
"s2680": "Dieser Arbeitsbereich konnte nicht umbenannt werden",
|
||||
"s2681": "Swift-Modelle",
|
||||
"s2682": "GSQ-RCO-Modelle",
|
||||
"s2683": "NVFP4-Modelle",
|
||||
"s2684": "Gyro-Modelle",
|
||||
"s2685": "Unsloth-Modelle",
|
||||
"s2686": "Qwen3.6-Modelle",
|
||||
"s2687": "Eigene GGUF-Datei verwenden…",
|
||||
"s2688": "RAM + VRAM",
|
||||
"s2689": "Nutzt SSD {0} GB",
|
||||
"s2690": "das kleinste",
|
||||
"s2691": "gute Balance",
|
||||
"s2692": "beste Qualität für die Größe",
|
||||
"s2693": "mehr Qualität",
|
||||
"s2694": "von {0}",
|
||||
"s2695": "{0}: dieses Modell nutzt etwa {1} von {2}",
|
||||
"s2696": "{0} · {1} Grafikspeicher",
|
||||
"s2697": "Diese Installation meldet keine unterstützte Grafikkarte.",
|
||||
"s2698": "Grafikspeicher",
|
||||
"s2699": " · {0} RAM",
|
||||
"s2700": "Etwa {0} GB werden von der SSD gelesen, daher können Antworten etwas langsamer sein.",
|
||||
"s2701": "Passt vollständig in den Speicher.",
|
||||
"s2702": "Ihren Ordner „{0}“ im Datei-Explorer öffnen?",
|
||||
"s2703": "Den Ordner {0} im Datei-Explorer öffnen?",
|
||||
"s2704": "Die Datei {0} öffnen?",
|
||||
"s2705": "Den Datei-Explorer öffnen?",
|
||||
"s2706": "Ein Programm ausführen: {0}?",
|
||||
"s2707": "Das Skript {0} ausführen?",
|
||||
"s2708": "{0} Schritte auf Ihrem Computer ausführen?",
|
||||
"s2709": "Einen Befehl auf Ihrem Computer ausführen?",
|
||||
"s2710": "Die Datei {0} erstellen?",
|
||||
"s2711": "Den Ordner {0} erstellen?",
|
||||
"s2712": "In die Datei {0} schreiben?",
|
||||
"s2713": "Die Datei {0} ändern?",
|
||||
"s2714": "{0} kopieren?",
|
||||
"s2715": "{0} verschieben?",
|
||||
"s2716": "{0} umbenennen?",
|
||||
"s2717": "git {0} ausführen?",
|
||||
"s2718": "Pakete mit {0} installieren?",
|
||||
"s2719": "Eine Hintergrundaufgabe stoppen?",
|
||||
"s2720": "Die Webseite {0} öffnen?",
|
||||
"s2721": "Das verbundene Werkzeug {0} verwenden?",
|
||||
"s2722": "Das Werkzeug {0} verwenden?",
|
||||
"s2723": "Ein Fenster wird geöffnet. Es wird nichts geändert.",
|
||||
"s2724": "Es kann Ihren Computer verändern.",
|
||||
"s2725": "Es wird von Ihrem Computer entfernt.",
|
||||
"s2726": "Ersetzt die Datei, falls sie bereits existiert.",
|
||||
"s2727": "Es ändert die Dateien oder den Verlauf des Projekts.",
|
||||
"s2728": "Es lädt Software herunter und verändert Ihren Computer.",
|
||||
"s2729": "Es passiert mehr als eine Sache. Die Details zeigen jeden Schritt.",
|
||||
"s2730": "Die Details zeigen genau, was passiert.",
|
||||
"s2731": "Es kann auf den verbundenen Dienst zugreifen.",
|
||||
"s2732": "{0} pausieren",
|
||||
"s2733": "{0} fortsetzen",
|
||||
"s2734": "Aufgabenübersicht",
|
||||
"s2735": "Zeitplan",
|
||||
"s2736": "Was erlaubt ist",
|
||||
"s2737": "Letzter Lauf fehlgeschlagen",
|
||||
"s2738": "Noch nicht ausgeführt",
|
||||
"s2739": "Was sie tut",
|
||||
"s2740": "Letzte Antwort",
|
||||
"s2741": "Nächster Lauf: {0}",
|
||||
"s2742": "Nächster Lauf wird berechnet …",
|
||||
"s2743": "Ihr PC wird geprüft",
|
||||
"s2744": "Grafikkarte und Speicher werden angesehen",
|
||||
"s2745": "Basisgeschwindigkeit wird gemessen",
|
||||
"s2746": "Ihre aktuellen Einstellungen werden gemessen",
|
||||
"s2747": "Ausgangswert: {0} Tokens pro Sekunde",
|
||||
"s2748": "Einstellungen werden ausprobiert",
|
||||
"s2749": "Weitere Einstellungen werden nacheinander getestet",
|
||||
"s2750": "Die beste Einstellung wird bestätigt",
|
||||
"s2751": "Der Test wird zur Sicherheit wiederholt",
|
||||
"s2752": "Speichern",
|
||||
"s2753": "Wird für dieses Modell gespeichert; Sie können es rückgängig machen",
|
||||
"s2754": "Schritte der Optimierung",
|
||||
"s2755": "Wartet",
|
||||
"s2756": "Aktuelle Geschwindigkeit",
|
||||
"s2757": "Tokens pro Sekunde",
|
||||
"s2758": "Ihre Einstellungen",
|
||||
"s2759": "{0} auf {1} Tokens pro Sekunde",
|
||||
"s2760": "Vorher",
|
||||
"s2761": "Nachher",
|
||||
"s2762": "Gespeichert als {0}. Es wird bei jedem Laden dieses Modells verwendet.",
|
||||
"s2763": "Optimiert und gespeichert"
|
||||
}
|
||||
@@ -2678,5 +2678,88 @@
|
||||
"s2677": "Rename workspace",
|
||||
"s2678": "Workspace name",
|
||||
"s2679": "Only the name changes. The folder stays where it is.",
|
||||
"s2680": "Couldn't rename that workspace"
|
||||
"s2680": "Couldn't rename that workspace",
|
||||
"s2681": "Swift models",
|
||||
"s2682": "GSQ-RCO models",
|
||||
"s2683": "NVFP4 models",
|
||||
"s2684": "Gyro models",
|
||||
"s2685": "Unsloth models",
|
||||
"s2686": "Qwen3.6 models",
|
||||
"s2687": "Use your own GGUF…",
|
||||
"s2688": "RAM + VRAM",
|
||||
"s2689": "Uses SSD {0} GB",
|
||||
"s2690": "the smallest",
|
||||
"s2691": "good balance",
|
||||
"s2692": "best quality for its size",
|
||||
"s2693": "more quality",
|
||||
"s2694": "of {0}",
|
||||
"s2695": "{0}: about {1} of {2} used by this model",
|
||||
"s2696": "{0} · {1} graphics memory",
|
||||
"s2697": "No supported graphics card reported by this installation.",
|
||||
"s2698": "Graphics memory",
|
||||
"s2699": " · {0} RAM",
|
||||
"s2700": "About {0} GB will be read from the SSD, so answers can be a little slower.",
|
||||
"s2701": "Fits fully in memory.",
|
||||
"s2702": "Open your {0} folder in File Explorer?",
|
||||
"s2703": "Open the folder {0} in File Explorer?",
|
||||
"s2704": "Open the file {0}?",
|
||||
"s2705": "Open File Explorer?",
|
||||
"s2706": "Run a program: {0}?",
|
||||
"s2707": "Run the script {0}?",
|
||||
"s2708": "Run {0} steps on your computer?",
|
||||
"s2709": "Run a command on your computer?",
|
||||
"s2710": "Create the file {0}?",
|
||||
"s2711": "Create the folder {0}?",
|
||||
"s2712": "Write to the file {0}?",
|
||||
"s2713": "Change the file {0}?",
|
||||
"s2714": "Copy {0}?",
|
||||
"s2715": "Move {0}?",
|
||||
"s2716": "Rename {0}?",
|
||||
"s2717": "Run git {0}?",
|
||||
"s2718": "Install packages with {0}?",
|
||||
"s2719": "Stop a background task?",
|
||||
"s2720": "Open the web page {0}?",
|
||||
"s2721": "Use the connected tool {0}?",
|
||||
"s2722": "Use the tool {0}?",
|
||||
"s2723": "A window will open. Nothing is changed.",
|
||||
"s2724": "It can change your computer.",
|
||||
"s2725": "It is removed from your computer.",
|
||||
"s2726": "Replaces the file if it already exists.",
|
||||
"s2727": "It changes the project's files or history.",
|
||||
"s2728": "It downloads software and changes your computer.",
|
||||
"s2729": "More than one thing happens. Details shows each step.",
|
||||
"s2730": "Details shows exactly what it does.",
|
||||
"s2731": "It can act on the service you connected.",
|
||||
"s2732": "Pause {0}",
|
||||
"s2733": "Resume {0}",
|
||||
"s2734": "Task summary",
|
||||
"s2735": "Schedule",
|
||||
"s2736": "What it may do",
|
||||
"s2737": "Last run failed",
|
||||
"s2738": "Not run yet",
|
||||
"s2739": "What it does",
|
||||
"s2740": "Last answer",
|
||||
"s2741": "Next: {0}",
|
||||
"s2742": "Calculating the next run…",
|
||||
"s2743": "Checking your PC",
|
||||
"s2744": "Looking at your graphics card and memory",
|
||||
"s2745": "Measuring the base speed",
|
||||
"s2746": "Timing your current settings",
|
||||
"s2747": "Starting point: {0} tokens per second",
|
||||
"s2748": "Trying settings",
|
||||
"s2749": "Trying other settings, one at a time",
|
||||
"s2750": "Confirming the best one",
|
||||
"s2751": "Repeating the test to be sure",
|
||||
"s2752": "Saving",
|
||||
"s2753": "Keeping it for this model; you can undo it",
|
||||
"s2754": "Tuning steps",
|
||||
"s2755": "Waiting",
|
||||
"s2756": "Speed now",
|
||||
"s2757": "tokens per second",
|
||||
"s2758": "Your settings",
|
||||
"s2759": "{0} to {1} tokens per second",
|
||||
"s2760": "Before",
|
||||
"s2761": "After",
|
||||
"s2762": "Saved as {0}. It is used every time you load this model.",
|
||||
"s2763": "Tuned and saved"
|
||||
}
|
||||
@@ -2678,5 +2678,88 @@
|
||||
"s2677": "Renombrar espacio de trabajo",
|
||||
"s2678": "Nombre del espacio de trabajo",
|
||||
"s2679": "Solo cambia el nombre. La carpeta sigue donde está.",
|
||||
"s2680": "No se pudo renombrar ese espacio de trabajo"
|
||||
"s2680": "No se pudo renombrar ese espacio de trabajo",
|
||||
"s2681": "Modelos Swift",
|
||||
"s2682": "Modelos GSQ-RCO",
|
||||
"s2683": "Modelos NVFP4",
|
||||
"s2684": "Modelos Gyro",
|
||||
"s2685": "Modelos Unsloth",
|
||||
"s2686": "Modelos Qwen3.6",
|
||||
"s2687": "Usar tu propio GGUF…",
|
||||
"s2688": "RAM + VRAM",
|
||||
"s2689": "Usa SSD {0} GB",
|
||||
"s2690": "el más pequeño",
|
||||
"s2691": "buen equilibrio",
|
||||
"s2692": "la mejor calidad para su tamaño",
|
||||
"s2693": "más calidad",
|
||||
"s2694": "de {0}",
|
||||
"s2695": "{0}: este modelo usa unos {1} de {2}",
|
||||
"s2696": "{0} · {1} de memoria gráfica",
|
||||
"s2697": "Esta instalación no informa de ninguna tarjeta gráfica compatible.",
|
||||
"s2698": "Memoria gráfica",
|
||||
"s2699": " · {0} de RAM",
|
||||
"s2700": "Se leerán unos {0} GB del SSD, así que las respuestas pueden ser algo más lentas.",
|
||||
"s2701": "Cabe por completo en la memoria.",
|
||||
"s2702": "¿Abrir tu carpeta {0} en el Explorador de archivos?",
|
||||
"s2703": "¿Abrir la carpeta {0} en el Explorador de archivos?",
|
||||
"s2704": "¿Abrir el archivo {0}?",
|
||||
"s2705": "¿Abrir el Explorador de archivos?",
|
||||
"s2706": "¿Ejecutar un programa: {0}?",
|
||||
"s2707": "¿Ejecutar el script {0}?",
|
||||
"s2708": "¿Ejecutar {0} pasos en tu ordenador?",
|
||||
"s2709": "¿Ejecutar un comando en tu ordenador?",
|
||||
"s2710": "¿Crear el archivo {0}?",
|
||||
"s2711": "¿Crear la carpeta {0}?",
|
||||
"s2712": "¿Escribir en el archivo {0}?",
|
||||
"s2713": "¿Modificar el archivo {0}?",
|
||||
"s2714": "¿Copiar {0}?",
|
||||
"s2715": "¿Mover {0}?",
|
||||
"s2716": "¿Renombrar {0}?",
|
||||
"s2717": "¿Ejecutar git {0}?",
|
||||
"s2718": "¿Instalar paquetes con {0}?",
|
||||
"s2719": "¿Detener una tarea en segundo plano?",
|
||||
"s2720": "¿Abrir la página web {0}?",
|
||||
"s2721": "¿Usar la herramienta conectada {0}?",
|
||||
"s2722": "¿Usar la herramienta {0}?",
|
||||
"s2723": "Se abrirá una ventana. No se cambia nada.",
|
||||
"s2724": "Puede cambiar tu ordenador.",
|
||||
"s2725": "Se elimina de tu ordenador.",
|
||||
"s2726": "Reemplaza el archivo si ya existe.",
|
||||
"s2727": "Cambia los archivos o el historial del proyecto.",
|
||||
"s2728": "Descarga software y cambia tu ordenador.",
|
||||
"s2729": "Ocurre más de una cosa. Detalles muestra cada paso.",
|
||||
"s2730": "Detalles muestra exactamente lo que hace.",
|
||||
"s2731": "Puede actuar sobre el servicio que conectaste.",
|
||||
"s2732": "Pausar {0}",
|
||||
"s2733": "Reanudar {0}",
|
||||
"s2734": "Resumen de la tarea",
|
||||
"s2735": "Programación",
|
||||
"s2736": "Qué puede hacer",
|
||||
"s2737": "La última ejecución falló",
|
||||
"s2738": "Aún no se ha ejecutado",
|
||||
"s2739": "Qué hace",
|
||||
"s2740": "Última respuesta",
|
||||
"s2741": "Próxima: {0}",
|
||||
"s2742": "Calculando la próxima ejecución…",
|
||||
"s2743": "Comprobando tu PC",
|
||||
"s2744": "Mirando tu tarjeta gráfica y la memoria",
|
||||
"s2745": "Midiendo la velocidad base",
|
||||
"s2746": "Cronometrando tu configuración actual",
|
||||
"s2747": "Punto de partida: {0} tokens por segundo",
|
||||
"s2748": "Probando ajustes",
|
||||
"s2749": "Probando otros ajustes, uno a uno",
|
||||
"s2750": "Confirmando la mejor",
|
||||
"s2751": "Repitiendo la prueba para estar seguros",
|
||||
"s2752": "Guardando",
|
||||
"s2753": "Se guarda para este modelo; puedes deshacerlo",
|
||||
"s2754": "Pasos del ajuste",
|
||||
"s2755": "En espera",
|
||||
"s2756": "Velocidad actual",
|
||||
"s2757": "tokens por segundo",
|
||||
"s2758": "Tu configuración",
|
||||
"s2759": "de {0} a {1} tokens por segundo",
|
||||
"s2760": "Antes",
|
||||
"s2761": "Después",
|
||||
"s2762": "Guardado como {0}. Se usa cada vez que cargas este modelo.",
|
||||
"s2763": "Ajustado y guardado"
|
||||
}
|
||||
@@ -2678,5 +2678,88 @@
|
||||
"s2677": "ワークスペースの名前を変更",
|
||||
"s2678": "ワークスペース名",
|
||||
"s2679": "変わるのは名前だけです。フォルダーはそのままです。",
|
||||
"s2680": "このワークスペースの名前を変更できませんでした"
|
||||
"s2680": "このワークスペースの名前を変更できませんでした",
|
||||
"s2681": "Swift モデル",
|
||||
"s2682": "GSQ-RCO モデル",
|
||||
"s2683": "NVFP4 モデル",
|
||||
"s2684": "Gyro モデル",
|
||||
"s2685": "Unsloth モデル",
|
||||
"s2686": "Qwen3.6 モデル",
|
||||
"s2687": "自分の GGUF を使う…",
|
||||
"s2688": "RAM + VRAM",
|
||||
"s2689": "SSD を {0} GB 使用",
|
||||
"s2690": "最小",
|
||||
"s2691": "バランスが良い",
|
||||
"s2692": "サイズの割に最高の品質",
|
||||
"s2693": "より高品質",
|
||||
"s2694": "{0} 中",
|
||||
"s2695": "{0}:このモデルは {2} のうち約 {1} を使用",
|
||||
"s2696": "{0} · グラフィックスメモリ {1}",
|
||||
"s2697": "この環境では対応するグラフィックスカードが見つかりません。",
|
||||
"s2698": "グラフィックスメモリ",
|
||||
"s2699": " · RAM {0}",
|
||||
"s2700": "約 {0} GB を SSD から読み込むため、応答が少し遅くなることがあります。",
|
||||
"s2701": "すべてメモリに収まります。",
|
||||
"s2702": "エクスプローラーで「{0}」フォルダーを開きますか?",
|
||||
"s2703": "エクスプローラーでフォルダー {0} を開きますか?",
|
||||
"s2704": "ファイル {0} を開きますか?",
|
||||
"s2705": "エクスプローラーを開きますか?",
|
||||
"s2706": "プログラムを実行しますか:{0}",
|
||||
"s2707": "スクリプト {0} を実行しますか?",
|
||||
"s2708": "このコンピューターで {0} つの手順を実行しますか?",
|
||||
"s2709": "このコンピューターでコマンドを実行しますか?",
|
||||
"s2710": "ファイル {0} を作成しますか?",
|
||||
"s2711": "フォルダー {0} を作成しますか?",
|
||||
"s2712": "ファイル {0} に書き込みますか?",
|
||||
"s2713": "ファイル {0} を変更しますか?",
|
||||
"s2714": "{0} をコピーしますか?",
|
||||
"s2715": "{0} を移動しますか?",
|
||||
"s2716": "{0} の名前を変更しますか?",
|
||||
"s2717": "git {0} を実行しますか?",
|
||||
"s2718": "{0} でパッケージをインストールしますか?",
|
||||
"s2719": "バックグラウンドのタスクを停止しますか?",
|
||||
"s2720": "Webページ {0} を開きますか?",
|
||||
"s2721": "接続済みのツール {0} を使いますか?",
|
||||
"s2722": "ツール {0} を使いますか?",
|
||||
"s2723": "ウィンドウが開きます。何も変更されません。",
|
||||
"s2724": "コンピューターに変更を加える可能性があります。",
|
||||
"s2725": "コンピューターから削除されます。",
|
||||
"s2726": "同名のファイルがある場合は置き換えられます。",
|
||||
"s2727": "プロジェクトのファイルや履歴が変更されます。",
|
||||
"s2728": "ソフトウェアをダウンロードし、コンピューターに変更を加えます。",
|
||||
"s2729": "複数の処理が行われます。詳細で各手順を確認できます。",
|
||||
"s2730": "詳細で正確な内容を確認できます。",
|
||||
"s2731": "接続したサービスに対して操作できます。",
|
||||
"s2732": "{0} を一時停止",
|
||||
"s2733": "{0} を再開",
|
||||
"s2734": "タスクの概要",
|
||||
"s2735": "スケジュール",
|
||||
"s2736": "許可されている操作",
|
||||
"s2737": "前回の実行に失敗しました",
|
||||
"s2738": "まだ実行されていません",
|
||||
"s2739": "実行内容",
|
||||
"s2740": "前回の回答",
|
||||
"s2741": "次回:{0}",
|
||||
"s2742": "次回の実行を計算中…",
|
||||
"s2743": "PC を確認中",
|
||||
"s2744": "グラフィックスカードとメモリを確認しています",
|
||||
"s2745": "基本の速度を測定",
|
||||
"s2746": "現在の設定の速度を計測しています",
|
||||
"s2747": "出発点:毎秒 {0} トークン",
|
||||
"s2748": "設定を試しています",
|
||||
"s2749": "ほかの設定を一つずつ試しています",
|
||||
"s2750": "最良の設定を確認",
|
||||
"s2751": "確実にするためテストを繰り返しています",
|
||||
"s2752": "保存",
|
||||
"s2753": "このモデル用に保存します。元に戻せます",
|
||||
"s2754": "チューニングの手順",
|
||||
"s2755": "待機中",
|
||||
"s2756": "現在の速度",
|
||||
"s2757": "トークン/秒",
|
||||
"s2758": "現在の設定",
|
||||
"s2759": "毎秒 {0} から {1} トークン",
|
||||
"s2760": "変更前",
|
||||
"s2761": "変更後",
|
||||
"s2762": "{0} として保存しました。このモデルを読み込むたびに使われます。",
|
||||
"s2763": "チューニングして保存しました"
|
||||
}
|
||||
@@ -2678,5 +2678,88 @@
|
||||
"s2677": "작업 공간 이름 바꾸기",
|
||||
"s2678": "작업 공간 이름",
|
||||
"s2679": "이름만 바뀝니다. 폴더는 그대로 있습니다.",
|
||||
"s2680": "이 작업 공간의 이름을 바꿀 수 없었습니다"
|
||||
"s2680": "이 작업 공간의 이름을 바꿀 수 없었습니다",
|
||||
"s2681": "Swift 모델",
|
||||
"s2682": "GSQ-RCO 모델",
|
||||
"s2683": "NVFP4 모델",
|
||||
"s2684": "Gyro 모델",
|
||||
"s2685": "Unsloth 모델",
|
||||
"s2686": "Qwen3.6 모델",
|
||||
"s2687": "내 GGUF 사용…",
|
||||
"s2688": "RAM + VRAM",
|
||||
"s2689": "SSD {0} GB 사용",
|
||||
"s2690": "가장 작음",
|
||||
"s2691": "균형이 좋음",
|
||||
"s2692": "크기 대비 최고 품질",
|
||||
"s2693": "더 높은 품질",
|
||||
"s2694": "{0} 중",
|
||||
"s2695": "{0}: 이 모델은 {2} 중 약 {1} 사용",
|
||||
"s2696": "{0} · 그래픽 메모리 {1}",
|
||||
"s2697": "이 설치에서 지원되는 그래픽 카드가 보고되지 않았습니다.",
|
||||
"s2698": "그래픽 메모리",
|
||||
"s2699": " · RAM {0}",
|
||||
"s2700": "약 {0} GB를 SSD에서 읽기 때문에 응답이 조금 느려질 수 있습니다.",
|
||||
"s2701": "메모리에 모두 들어갑니다.",
|
||||
"s2702": "파일 탐색기에서 {0} 폴더를 열까요?",
|
||||
"s2703": "파일 탐색기에서 {0} 폴더를 열까요?",
|
||||
"s2704": "{0} 파일을 열까요?",
|
||||
"s2705": "파일 탐색기를 열까요?",
|
||||
"s2706": "프로그램을 실행할까요: {0}?",
|
||||
"s2707": "{0} 스크립트를 실행할까요?",
|
||||
"s2708": "컴퓨터에서 {0}단계를 실행할까요?",
|
||||
"s2709": "컴퓨터에서 명령을 실행할까요?",
|
||||
"s2710": "{0} 파일을 만들까요?",
|
||||
"s2711": "{0} 폴더를 만들까요?",
|
||||
"s2712": "{0} 파일에 쓸까요?",
|
||||
"s2713": "{0} 파일을 수정할까요?",
|
||||
"s2714": "{0}을(를) 복사할까요?",
|
||||
"s2715": "{0}을(를) 이동할까요?",
|
||||
"s2716": "{0}의 이름을 바꿀까요?",
|
||||
"s2717": "git {0}을(를) 실행할까요?",
|
||||
"s2718": "{0}(으)로 패키지를 설치할까요?",
|
||||
"s2719": "백그라운드 작업을 중지할까요?",
|
||||
"s2720": "{0} 웹 페이지를 열까요?",
|
||||
"s2721": "연결된 도구 {0}을(를) 사용할까요?",
|
||||
"s2722": "{0} 도구를 사용할까요?",
|
||||
"s2723": "창이 열립니다. 아무것도 변경되지 않습니다.",
|
||||
"s2724": "컴퓨터를 변경할 수 있습니다.",
|
||||
"s2725": "컴퓨터에서 삭제됩니다.",
|
||||
"s2726": "파일이 이미 있으면 덮어씁니다.",
|
||||
"s2727": "프로젝트의 파일이나 기록을 변경합니다.",
|
||||
"s2728": "소프트웨어를 다운로드하고 컴퓨터를 변경합니다.",
|
||||
"s2729": "여러 작업이 수행됩니다. 자세히에서 각 단계를 볼 수 있습니다.",
|
||||
"s2730": "자세히에서 정확한 동작을 볼 수 있습니다.",
|
||||
"s2731": "연결한 서비스에서 동작할 수 있습니다.",
|
||||
"s2732": "{0} 일시중지",
|
||||
"s2733": "{0} 다시 시작",
|
||||
"s2734": "작업 요약",
|
||||
"s2735": "일정",
|
||||
"s2736": "허용되는 작업",
|
||||
"s2737": "마지막 실행 실패",
|
||||
"s2738": "아직 실행되지 않음",
|
||||
"s2739": "하는 일",
|
||||
"s2740": "마지막 답변",
|
||||
"s2741": "다음: {0}",
|
||||
"s2742": "다음 실행 계산 중…",
|
||||
"s2743": "PC 확인 중",
|
||||
"s2744": "그래픽 카드와 메모리를 확인하는 중",
|
||||
"s2745": "기본 속도 측정",
|
||||
"s2746": "현재 설정의 속도를 재는 중",
|
||||
"s2747": "시작 값: 초당 {0}토큰",
|
||||
"s2748": "설정 시도 중",
|
||||
"s2749": "다른 설정을 하나씩 시도하는 중",
|
||||
"s2750": "가장 좋은 설정 확인",
|
||||
"s2751": "확실히 하기 위해 테스트를 반복하는 중",
|
||||
"s2752": "저장 중",
|
||||
"s2753": "이 모델용으로 저장합니다. 되돌릴 수 있습니다",
|
||||
"s2754": "튜닝 단계",
|
||||
"s2755": "대기 중",
|
||||
"s2756": "현재 속도",
|
||||
"s2757": "초당 토큰",
|
||||
"s2758": "내 설정",
|
||||
"s2759": "초당 {0}에서 {1}토큰",
|
||||
"s2760": "이전",
|
||||
"s2761": "이후",
|
||||
"s2762": "{0}(으)로 저장했습니다. 이 모델을 불러올 때마다 사용됩니다.",
|
||||
"s2763": "튜닝 후 저장됨"
|
||||
}
|
||||
@@ -2678,5 +2678,88 @@
|
||||
"s2677": "Renomear espaço de trabalho",
|
||||
"s2678": "Nome do espaço de trabalho",
|
||||
"s2679": "Só o nome muda. A pasta continua onde está.",
|
||||
"s2680": "Não foi possível renomear esse espaço de trabalho"
|
||||
"s2680": "Não foi possível renomear esse espaço de trabalho",
|
||||
"s2681": "Modelos Swift",
|
||||
"s2682": "Modelos GSQ-RCO",
|
||||
"s2683": "Modelos NVFP4",
|
||||
"s2684": "Modelos Gyro",
|
||||
"s2685": "Modelos Unsloth",
|
||||
"s2686": "Modelos Qwen3.6",
|
||||
"s2687": "Usar seu próprio GGUF…",
|
||||
"s2688": "RAM + VRAM",
|
||||
"s2689": "Usa SSD {0} GB",
|
||||
"s2690": "o menor",
|
||||
"s2691": "bom equilíbrio",
|
||||
"s2692": "a melhor qualidade para o tamanho",
|
||||
"s2693": "mais qualidade",
|
||||
"s2694": "de {0}",
|
||||
"s2695": "{0}: este modelo usa cerca de {1} de {2}",
|
||||
"s2696": "{0} · {1} de memória gráfica",
|
||||
"s2697": "Esta instalação não informa nenhuma placa de vídeo compatível.",
|
||||
"s2698": "Memória gráfica",
|
||||
"s2699": " · {0} de RAM",
|
||||
"s2700": "Cerca de {0} GB serão lidos do SSD, então as respostas podem ficar um pouco mais lentas.",
|
||||
"s2701": "Cabe totalmente na memória.",
|
||||
"s2702": "Abrir sua pasta {0} no Explorador de Arquivos?",
|
||||
"s2703": "Abrir a pasta {0} no Explorador de Arquivos?",
|
||||
"s2704": "Abrir o arquivo {0}?",
|
||||
"s2705": "Abrir o Explorador de Arquivos?",
|
||||
"s2706": "Executar um programa: {0}?",
|
||||
"s2707": "Executar o script {0}?",
|
||||
"s2708": "Executar {0} etapas no seu computador?",
|
||||
"s2709": "Executar um comando no seu computador?",
|
||||
"s2710": "Criar o arquivo {0}?",
|
||||
"s2711": "Criar a pasta {0}?",
|
||||
"s2712": "Gravar no arquivo {0}?",
|
||||
"s2713": "Alterar o arquivo {0}?",
|
||||
"s2714": "Copiar {0}?",
|
||||
"s2715": "Mover {0}?",
|
||||
"s2716": "Renomear {0}?",
|
||||
"s2717": "Executar git {0}?",
|
||||
"s2718": "Instalar pacotes com {0}?",
|
||||
"s2719": "Parar uma tarefa em segundo plano?",
|
||||
"s2720": "Abrir a página da web {0}?",
|
||||
"s2721": "Usar a ferramenta conectada {0}?",
|
||||
"s2722": "Usar a ferramenta {0}?",
|
||||
"s2723": "Uma janela será aberta. Nada é alterado.",
|
||||
"s2724": "Pode alterar o seu computador.",
|
||||
"s2725": "Será removido do seu computador.",
|
||||
"s2726": "Substitui o arquivo se ele já existir.",
|
||||
"s2727": "Altera os arquivos ou o histórico do projeto.",
|
||||
"s2728": "Baixa software e altera o seu computador.",
|
||||
"s2729": "Mais de uma coisa acontece. Detalhes mostra cada etapa.",
|
||||
"s2730": "Detalhes mostra exatamente o que ele faz.",
|
||||
"s2731": "Pode agir no serviço que você conectou.",
|
||||
"s2732": "Pausar {0}",
|
||||
"s2733": "Retomar {0}",
|
||||
"s2734": "Resumo da tarefa",
|
||||
"s2735": "Agenda",
|
||||
"s2736": "O que pode fazer",
|
||||
"s2737": "A última execução falhou",
|
||||
"s2738": "Ainda não executada",
|
||||
"s2739": "O que faz",
|
||||
"s2740": "Última resposta",
|
||||
"s2741": "Próxima: {0}",
|
||||
"s2742": "Calculando a próxima execução…",
|
||||
"s2743": "Verificando o seu PC",
|
||||
"s2744": "Olhando sua placa de vídeo e a memória",
|
||||
"s2745": "Medindo a velocidade base",
|
||||
"s2746": "Cronometrando suas configurações atuais",
|
||||
"s2747": "Ponto de partida: {0} tokens por segundo",
|
||||
"s2748": "Testando configurações",
|
||||
"s2749": "Testando outras configurações, uma de cada vez",
|
||||
"s2750": "Confirmando a melhor",
|
||||
"s2751": "Repetindo o teste para ter certeza",
|
||||
"s2752": "Salvando",
|
||||
"s2753": "Guardado para este modelo; você pode desfazer",
|
||||
"s2754": "Etapas do ajuste",
|
||||
"s2755": "Aguardando",
|
||||
"s2756": "Velocidade agora",
|
||||
"s2757": "tokens por segundo",
|
||||
"s2758": "Suas configurações",
|
||||
"s2759": "de {0} para {1} tokens por segundo",
|
||||
"s2760": "Antes",
|
||||
"s2761": "Depois",
|
||||
"s2762": "Salvo como {0}. É usado toda vez que você carrega este modelo.",
|
||||
"s2763": "Ajustado e salvo"
|
||||
}
|
||||
@@ -2678,5 +2678,88 @@
|
||||
"s2677": "Переименовать рабочее пространство",
|
||||
"s2678": "Название рабочего пространства",
|
||||
"s2679": "Меняется только название. Папка остаётся на месте.",
|
||||
"s2680": "Не удалось переименовать это рабочее пространство"
|
||||
"s2680": "Не удалось переименовать это рабочее пространство",
|
||||
"s2681": "Модели Swift",
|
||||
"s2682": "Модели GSQ-RCO",
|
||||
"s2683": "Модели NVFP4",
|
||||
"s2684": "Модели Gyro",
|
||||
"s2685": "Модели Unsloth",
|
||||
"s2686": "Модели Qwen3.6",
|
||||
"s2687": "Использовать свой GGUF…",
|
||||
"s2688": "RAM + VRAM",
|
||||
"s2689": "Использует SSD {0} ГБ",
|
||||
"s2690": "самая маленькая",
|
||||
"s2691": "хороший баланс",
|
||||
"s2692": "лучшее качество для своего размера",
|
||||
"s2693": "выше качество",
|
||||
"s2694": "из {0}",
|
||||
"s2695": "{0}: эта модель использует около {1} из {2}",
|
||||
"s2696": "{0} · {1} видеопамяти",
|
||||
"s2697": "Эта установка не сообщает о поддерживаемой видеокарте.",
|
||||
"s2698": "Видеопамять",
|
||||
"s2699": " · {0} ОЗУ",
|
||||
"s2700": "Около {0} ГБ будет читаться с SSD, поэтому ответы могут быть чуть медленнее.",
|
||||
"s2701": "Полностью помещается в память.",
|
||||
"s2702": "Открыть вашу папку «{0}» в проводнике?",
|
||||
"s2703": "Открыть папку {0} в проводнике?",
|
||||
"s2704": "Открыть файл {0}?",
|
||||
"s2705": "Открыть проводник?",
|
||||
"s2706": "Запустить программу: {0}?",
|
||||
"s2707": "Запустить скрипт {0}?",
|
||||
"s2708": "Выполнить {0} шагов на вашем компьютере?",
|
||||
"s2709": "Выполнить команду на вашем компьютере?",
|
||||
"s2710": "Создать файл {0}?",
|
||||
"s2711": "Создать папку {0}?",
|
||||
"s2712": "Записать в файл {0}?",
|
||||
"s2713": "Изменить файл {0}?",
|
||||
"s2714": "Копировать {0}?",
|
||||
"s2715": "Переместить {0}?",
|
||||
"s2716": "Переименовать {0}?",
|
||||
"s2717": "Выполнить git {0}?",
|
||||
"s2718": "Установить пакеты с помощью {0}?",
|
||||
"s2719": "Остановить фоновую задачу?",
|
||||
"s2720": "Открыть веб-страницу {0}?",
|
||||
"s2721": "Использовать подключённый инструмент {0}?",
|
||||
"s2722": "Использовать инструмент {0}?",
|
||||
"s2723": "Откроется окно. Ничего не изменится.",
|
||||
"s2724": "Это может изменить ваш компьютер.",
|
||||
"s2725": "Это будет удалено с вашего компьютера.",
|
||||
"s2726": "Заменит файл, если он уже существует.",
|
||||
"s2727": "Это изменит файлы или историю проекта.",
|
||||
"s2728": "Это скачает программы и изменит ваш компьютер.",
|
||||
"s2729": "Выполняется несколько действий. В подробностях виден каждый шаг.",
|
||||
"s2730": "В подробностях видно, что именно она делает.",
|
||||
"s2731": "Он может действовать в подключённом вами сервисе.",
|
||||
"s2732": "Приостановить {0}",
|
||||
"s2733": "Возобновить {0}",
|
||||
"s2734": "Сводка задачи",
|
||||
"s2735": "Расписание",
|
||||
"s2736": "Что разрешено",
|
||||
"s2737": "Последний запуск не удался",
|
||||
"s2738": "Ещё не запускалась",
|
||||
"s2739": "Что она делает",
|
||||
"s2740": "Последний ответ",
|
||||
"s2741": "Следующий: {0}",
|
||||
"s2742": "Вычисляется следующий запуск…",
|
||||
"s2743": "Проверяю ваш компьютер",
|
||||
"s2744": "Смотрю видеокарту и память",
|
||||
"s2745": "Измеряю исходную скорость",
|
||||
"s2746": "Замеряю ваши текущие настройки",
|
||||
"s2747": "Исходная скорость: {0} токенов в секунду",
|
||||
"s2748": "Пробую настройки",
|
||||
"s2749": "Пробую другие настройки по одной",
|
||||
"s2750": "Подтверждаю лучшую",
|
||||
"s2751": "Повторяю проверку для надёжности",
|
||||
"s2752": "Сохранение",
|
||||
"s2753": "Сохраняется для этой модели; можно отменить",
|
||||
"s2754": "Этапы настройки",
|
||||
"s2755": "Ожидание",
|
||||
"s2756": "Скорость сейчас",
|
||||
"s2757": "токенов в секунду",
|
||||
"s2758": "Ваши настройки",
|
||||
"s2759": "с {0} до {1} токенов в секунду",
|
||||
"s2760": "До",
|
||||
"s2761": "После",
|
||||
"s2762": "Сохранено как {0}. Применяется при каждой загрузке этой модели.",
|
||||
"s2763": "Настроено и сохранено"
|
||||
}
|
||||
@@ -2678,5 +2678,88 @@
|
||||
"s2677": "重命名工作区",
|
||||
"s2678": "工作区名称",
|
||||
"s2679": "只更改名称,文件夹保持原位。",
|
||||
"s2680": "无法重命名该工作区"
|
||||
"s2680": "无法重命名该工作区",
|
||||
"s2681": "Swift 模型",
|
||||
"s2682": "GSQ-RCO 模型",
|
||||
"s2683": "NVFP4 模型",
|
||||
"s2684": "Gyro 模型",
|
||||
"s2685": "Unsloth 模型",
|
||||
"s2686": "Qwen3.6 模型",
|
||||
"s2687": "使用你自己的 GGUF…",
|
||||
"s2688": "内存 + 显存",
|
||||
"s2689": "使用 SSD {0} GB",
|
||||
"s2690": "最小",
|
||||
"s2691": "均衡",
|
||||
"s2692": "同等大小中质量最好",
|
||||
"s2693": "质量更高",
|
||||
"s2694": "共 {0}",
|
||||
"s2695": "{0}:此模型约使用 {2} 中的 {1}",
|
||||
"s2696": "{0} · {1} 显存",
|
||||
"s2697": "此安装未报告受支持的显卡。",
|
||||
"s2698": "显存",
|
||||
"s2699": " · {0} 内存",
|
||||
"s2700": "约 {0} GB 将从 SSD 读取,回答可能会稍慢。",
|
||||
"s2701": "完全装得进内存。",
|
||||
"s2702": "在文件资源管理器中打开你的“{0}”文件夹?",
|
||||
"s2703": "在文件资源管理器中打开文件夹 {0}?",
|
||||
"s2704": "打开文件 {0}?",
|
||||
"s2705": "打开文件资源管理器?",
|
||||
"s2706": "运行程序:{0}?",
|
||||
"s2707": "运行脚本 {0}?",
|
||||
"s2708": "在你的电脑上运行 {0} 个步骤?",
|
||||
"s2709": "在你的电脑上运行命令?",
|
||||
"s2710": "创建文件 {0}?",
|
||||
"s2711": "创建文件夹 {0}?",
|
||||
"s2712": "写入文件 {0}?",
|
||||
"s2713": "修改文件 {0}?",
|
||||
"s2714": "复制 {0}?",
|
||||
"s2715": "移动 {0}?",
|
||||
"s2716": "重命名 {0}?",
|
||||
"s2717": "运行 git {0}?",
|
||||
"s2718": "使用 {0} 安装软件包?",
|
||||
"s2719": "停止后台任务?",
|
||||
"s2720": "打开网页 {0}?",
|
||||
"s2721": "使用已连接的工具 {0}?",
|
||||
"s2722": "使用工具 {0}?",
|
||||
"s2723": "将打开一个窗口,不会更改任何内容。",
|
||||
"s2724": "它可能会更改你的电脑。",
|
||||
"s2725": "它将从你的电脑中删除。",
|
||||
"s2726": "如果文件已存在,将被替换。",
|
||||
"s2727": "它会更改项目的文件或历史记录。",
|
||||
"s2728": "它会下载软件并更改你的电脑。",
|
||||
"s2729": "会执行多个操作。“详情”中列出每一步。",
|
||||
"s2730": "“详情”会准确显示它做了什么。",
|
||||
"s2731": "它可以对你连接的服务执行操作。",
|
||||
"s2732": "暂停 {0}",
|
||||
"s2733": "恢复 {0}",
|
||||
"s2734": "任务摘要",
|
||||
"s2735": "计划",
|
||||
"s2736": "允许的操作",
|
||||
"s2737": "上次运行失败",
|
||||
"s2738": "尚未运行",
|
||||
"s2739": "它做什么",
|
||||
"s2740": "上次回答",
|
||||
"s2741": "下次:{0}",
|
||||
"s2742": "正在计算下次运行时间…",
|
||||
"s2743": "检查你的电脑",
|
||||
"s2744": "正在查看显卡和内存",
|
||||
"s2745": "测量基准速度",
|
||||
"s2746": "正在测试你当前的设置",
|
||||
"s2747": "起点:每秒 {0} 个词元",
|
||||
"s2748": "尝试不同设置",
|
||||
"s2749": "逐个尝试其他设置",
|
||||
"s2750": "确认最佳设置",
|
||||
"s2751": "重复测试以确保结果可靠",
|
||||
"s2752": "保存",
|
||||
"s2753": "为这个模型保存,你可以撤销",
|
||||
"s2754": "调优步骤",
|
||||
"s2755": "等待中",
|
||||
"s2756": "当前速度",
|
||||
"s2757": "词元/秒",
|
||||
"s2758": "你的设置",
|
||||
"s2759": "每秒 {0} 到 {1} 个词元",
|
||||
"s2760": "之前",
|
||||
"s2761": "之后",
|
||||
"s2762": "已保存为 {0}。每次加载这个模型都会使用它。",
|
||||
"s2763": "已调优并保存"
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { ComposerControls } from '../components/ComposerControls';
|
||||
import { ChatPhotos } from '../components/ChatPhotos';
|
||||
import { useChatPhotos, removeChatPhoto } from '../lib/chat-photos';
|
||||
import { useNative } from '../lib/native';
|
||||
import { askQuestion, askSub, askIcon } from '../lib/ask';
|
||||
import { artifactFromUrl, type PreviewArtifact } from '../lib/artifacts';
|
||||
/** Chat — talk to the one loaded model. */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -623,6 +624,13 @@ function ToolCard({ messageId, tool: t }: {
|
||||
const pending = t.state === 'proposed';
|
||||
if (!pending && t.state !== 'running')
|
||||
return <details className="disclosure tool-summary"><summary><Icon name="chevron" size={14}/>{tr(t.verb ?? t.name)}{t.target ? <><span>·</span><span className="tool-summary__target" title={t.target}>{t.target}</span></> : null}<span>·</span><Icon name={t.state === 'denied' || t.state === 'error' ? 'x' : 'check'} size={14}/>{tr(t.state === 'denied' ? 'Denied' : t.state === 'error' ? 'Failed' : t.live && t.state === 'done' && t.approved ? 'Approved' : 'Done')}</summary><div><code>{t.preview}</code><p className="small muted">{tr(t.result ?? t.effect)}</p></div></details>;
|
||||
if (pending && t.ask)
|
||||
return <section className="askcard" data-state={t.state} aria-label={tr(askQuestion(t.ask))}>
|
||||
<div className="askcard__top"><span className="askcard__icon"><Icon name={askIcon(t.ask)} size={18}/></span>
|
||||
<div className="askcard__text"><p className="askcard__q">{askQuestion(t.ask)}</p>{askSub(t.ask) ? <p className="askcard__why" title={askSub(t.ask)}>{askSub(t.ask)}</p> : null}</div>
|
||||
<div className="askcard__acts"><Btn size="sm" onClick={() => resolveTool(messageId, t.id, false)}>{tr("Deny")}</Btn><Btn variant="primary" size="sm" onClick={() => resolveTool(messageId, t.id, true)}>{tr("Approve")}</Btn></div></div>
|
||||
<details className="askcard__details"><summary><Icon name="chevron" size={11}/>{tr("Details")}</summary><pre>{t.preview}</pre></details>
|
||||
</section>;
|
||||
return <section className="toolcard toolcard--compact" data-state={t.state} aria-label={t.name}>
|
||||
<details className="toolcard__disclosure">
|
||||
<summary><Icon name={t.scope === 'shell' ? 'bolt' : 'tool'} size={17}/><span className="toolcard__title">{tr(t.verb ?? t.name)}</span><span className="toolcard__scope xs muted">{tr(scope.label)}</span><Icon name="chevron" className="toolcard__chevron" size={15}/></summary>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { api } from '../lib/client';
|
||||
import { useStore } from '../lib/store';
|
||||
import { blankTask as blank, type Task } from '../lib/tasks';
|
||||
import { TaskEditor } from '../components/TaskEditor';
|
||||
import { TaskDetail, taskState } from '../components/TaskDetail';
|
||||
import { useNative } from '../lib/native';
|
||||
const templates = [
|
||||
{ title: 'Daily briefing', icon: 'sun' as const, prompt: 'Write a short briefing from the notes below. Highlight decisions, open questions and next actions.\n\n[Add your notes here]', frequency: 'weekdays', at: '08:00', detail: 'Turn your notes into a focused morning plan.' },
|
||||
{ title: 'Weekly review', icon: 'check' as const, prompt: 'Review the updates below. Summarize progress, risks and priorities for next week.\n\n[Add your updates here]', frequency: 'weekly', at: '16:00', detail: 'Reflect on progress and plan the next week.' },
|
||||
@@ -21,7 +23,7 @@ export function TasksScreen() {
|
||||
const library = useStore(s => s.library);
|
||||
const loaded = library?.variants.find(v => v.id === library.loaded);
|
||||
const [tasks, setTasks] = useState<Task[]>([]), [query, setQuery] = useState(''), [sort, setSort] = useState('next'), [archived, setArchived] = useState(false);
|
||||
const [editor, setEditor] = useState<Task | null>(null), [assistant, setAssistant] = useState(false), [menu, setMenu] = useState(false), [pending, setPending] = useState(false), [error, setError] = useState(''), [ready, setReady] = useState(false), [result, setResult] = useState<string | null>(null);
|
||||
const [editor, setEditor] = useState<Task | null>(null), [assistant, setAssistant] = useState(false), [menu, setMenu] = useState(false), [pending, setPending] = useState(false), [error, setError] = useState(''), [ready, setReady] = useState(false), [result, setResult] = useState<string | null>(null), [removeTask, setRemoveTask] = useState(false);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
async function refresh() {
|
||||
const data = await api.get<{
|
||||
@@ -74,18 +76,26 @@ export function TasksScreen() {
|
||||
const currentTask = tasks.find(t => t.id === location.param);
|
||||
const visible = tasks.filter(t => (!location.param || t.id === location.param) && !!t.archived === archived && `${t.title} ${t.prompt}`.toLowerCase().includes(query.toLowerCase())).sort((a, b) => sort === 'name' ? a.title.localeCompare(b.title) : (a.nextRunAt ?? 'z').localeCompare(b.nextRunAt ?? 'z'));
|
||||
const selected = tasks.find(t => t.id === result);
|
||||
return <Screen title={tr(currentTask ? <><button className="text-action" onClick={() => navigate('tasks')}>{tr("Tasks")}</button> › {currentTask.title}</> : "Tasks")} sub={currentTask ? undefined : "Run a prompt on a schedule, or whenever you need it."} actions={currentTask ? undefined : <div className="tasks-actions"><input className="st-input tasks-search" type="search" aria-label={tr("Search tasks")} placeholder={tr("Search tasks…")} value={query} onChange={e => setQuery(e.target.value)}/><Select ariaLabel={tr("Sort tasks")} value={sort} options={[{ value: 'next', label: 'Next run' }, { value: 'name', label: 'Name' }]} onChange={setSort}/><button ref={trigger} className="st-btn st-btn--primary" aria-haspopup="menu" aria-expanded={menu} onClick={() => setMenu(v => !v)}>{tr("New task")}<Icon name="chevron" size={16}/></button></div>}>
|
||||
const nativeModel = useNative()?.instances.find(i => i.status === 'ready');
|
||||
const taskHeader = currentTask ? <div className="task-header-actions">
|
||||
<IconBtn name={currentTask.enabled ? 'pause' : 'play'} title={tr(currentTask.enabled ? "Pause {0}" : "Resume {0}", [currentTask.title])} disabled={pending || currentTask.status === 'running' || !!currentTask.archived} onClick={() => void action(() => api.patch(`/api/tasks/${currentTask.id}`, { enabled: !currentTask.enabled }))}/>
|
||||
<IconBtn name="trash" title={tr("Delete {0}", [currentTask.title])} disabled={pending || !!currentTask.archived} onClick={() => setRemoveTask(true)}/>
|
||||
<Btn icon="pencil" disabled={pending || currentTask.status === 'running'} onClick={() => open(currentTask)}>{tr("Edit")}</Btn>
|
||||
<Btn variant="primary" icon={currentTask.status === 'running' ? 'stop' : 'bolt'} disabled={pending || !loaded && currentTask.status !== 'running'} onClick={() => void action(() => api.post(`/api/tasks/${currentTask.id}/${currentTask.status === 'running' ? 'stop' : 'run'}`))}>{tr(currentTask.status === 'running' ? 'Stop' : 'Run now')}</Btn>
|
||||
</div> : null;
|
||||
return <Screen title={currentTask ? currentTask.title : "Tasks"} back={currentTask ? { label: 'Tasks', onClick: () => navigate('tasks') } : undefined} titleDetail={currentTask ? <span className="task-state" data-state={taskState(currentTask)}>{tr(({ running: 'Running…', active: 'Active', paused: 'Paused' } as const)[taskState(currentTask)])}</span> : undefined} sub={currentTask ? undefined : "Run a prompt on a schedule, or whenever you need it."} actions={currentTask ? taskHeader : <div className="tasks-actions"><input className="st-input tasks-search" type="search" aria-label={tr("Search tasks")} placeholder={tr("Search tasks…")} value={query} onChange={e => setQuery(e.target.value)}/><Select ariaLabel={tr("Sort tasks")} value={sort} options={[{ value: 'next', label: 'Next run' }, { value: 'name', label: 'Name' }]} onChange={setSort}/><button ref={trigger} className="st-btn st-btn--primary" aria-haspopup="menu" aria-expanded={menu} onClick={() => setMenu(v => !v)}>{tr("New task")}<Icon name="chevron" size={16}/></button></div>}>
|
||||
<div className="tasks-page">
|
||||
|
||||
{tr(!currentTask && <p className="task-runtime small muted"><Icon name="clock" size={16}/>{tr(library?.mode === 'simulated' ? 'Example model · sample answers. ' : '')}{tr("Runs while Strata\u2019s host is open. Runs saved instructions using the loaded model.")}</p>)}
|
||||
{tr(!currentTask && <label className="row small muted"><input type="checkbox" checked={archived} onChange={e => setArchived(e.target.checked)}/>{tr("Deleted tasks")}</label>)}
|
||||
{tr(error && !editor ? <ErrorNotice message={error} onRetry={() => void refresh()}/> : null)}
|
||||
{tr(!ready ? <p className="muted">{tr("Loading tasks\u2026")}</p> : !visible.length ? <div className="tasks-empty"><Icon name="clock" size={72}/><h2 className="h3">{tr(query ? 'No matching tasks' : archived ? 'No deleted tasks' : 'No scheduled tasks yet')}</h2><p className="muted">{tr(query ? 'Try another search.' : 'Start with an idea below, or create your own.')}</p></div> : <div className="task-list">{tr(visible.map(t => <article key={t.id} className="task-row"><span className="task-icon"><Icon name="clock" size={24}/></span><div className="task-copy"><h2 className="h4">{t.title}</h2><p className="small muted">{tr(t.status === 'running' ? 'Running…' : t.enabled ? (t.nextRunAt ? `Next: ${new Date(t.nextRunAt).toLocaleString(localeTag(), { dateStyle: 'medium', timeStyle: 'short' })}` : 'Scheduled · calculating next run') : 'Paused')} · {tr(t.timeZone)}</p>{tr(t.error ? <button className="task-failed text-action" onClick={() => setResult(t.id!)}>{tr("Last run failed \u00B7 View")}</button> : null)}{tr(t.output ? <button className="text-action" onClick={() => setResult(t.id!)}>{tr("View last result")}{tr(t.demo ? ' · Example' : '')}</button> : null)}</div><div className="row">{tr(t.archived ? <Btn size="sm" onClick={() => void action(() => api.patch(`/api/tasks/${t.id}`, { archived: false, enabled: false }))}>{tr("Restore")}</Btn> : <><IconBtn name="pencil" title={tr("Edit {0}", [t.title])} disabled={pending || t.status === 'running'} onClick={() => open(t)}/><Btn size="sm" icon={t.status === 'running' ? 'stop' : 'bolt'} title={tr(t.status === 'running' ? `Stop ${t.title}` : `Run ${t.title} now`)} disabled={pending || !loaded} onClick={() => void action(() => api.post(`/api/tasks/${t.id}/${t.status === 'running' ? 'stop' : 'run'}`))}>{tr(t.status === 'running' ? 'Stop' : 'Run now')}</Btn><label className="row small"><span>{tr("On schedule")}</span><button className="st-toggle" role="switch" aria-label={tr("Schedule {0}", [t.title])} aria-checked={t.enabled} disabled={pending || t.status === 'running'} onClick={() => void action(() => api.patch(`/api/tasks/${t.id}`, { enabled: !t.enabled }))}/></label></>)}</div></article>))}</div>)}
|
||||
{tr(currentTask ? <section className="st-card"><h2 className="h4">{tr("Instructions")}</h2><p className="task-instructions">{currentTask.prompt}</p>{tr(currentTask.output ? <><h2 className="h4">{tr("Last result")}{tr(currentTask.demo ? ' · Example' : '')}</h2><Markdown text={currentTask.output}/></> : <p className="small muted">{tr("Run this task to see its first result here.")}</p>)}</section> : null)}
|
||||
{tr(currentTask ? null : !ready ? <p className="muted">{tr("Loading tasks\u2026")}</p> : !visible.length ? <div className="tasks-empty"><Icon name="clock" size={72}/><h2 className="h3">{tr(query ? 'No matching tasks' : archived ? 'No deleted tasks' : 'No scheduled tasks yet')}</h2><p className="muted">{tr(query ? 'Try another search.' : 'Start with an idea below, or create your own.')}</p></div> : <div className="task-list">{tr(visible.map(t => <article key={t.id} className="task-row"><span className="task-icon"><Icon name="clock" size={24}/></span><div className="task-copy"><h2 className="h4">{t.title}</h2><p className="small muted">{tr(t.status === 'running' ? 'Running…' : t.enabled ? (t.nextRunAt ? `Next: ${new Date(t.nextRunAt).toLocaleString(localeTag(), { dateStyle: 'medium', timeStyle: 'short' })}` : 'Scheduled · calculating next run') : 'Paused')} · {tr(t.timeZone)}</p>{tr(t.error ? <button className="task-failed text-action" onClick={() => setResult(t.id!)}>{tr("Last run failed \u00B7 View")}</button> : null)}{tr(t.output ? <button className="text-action" onClick={() => setResult(t.id!)}>{tr("View last result")}{tr(t.demo ? ' · Example' : '')}</button> : null)}</div><div className="row">{tr(t.archived ? <Btn size="sm" onClick={() => void action(() => api.patch(`/api/tasks/${t.id}`, { archived: false, enabled: false }))}>{tr("Restore")}</Btn> : <><IconBtn name="pencil" title={tr("Edit {0}", [t.title])} disabled={pending || t.status === 'running'} onClick={() => open(t)}/><Btn size="sm" icon={t.status === 'running' ? 'stop' : 'bolt'} title={tr(t.status === 'running' ? `Stop ${t.title}` : `Run ${t.title} now`)} disabled={pending || !loaded} onClick={() => void action(() => api.post(`/api/tasks/${t.id}/${t.status === 'running' ? 'stop' : 'run'}`))}>{tr(t.status === 'running' ? 'Stop' : 'Run now')}</Btn><label className="row small"><span>{tr("On schedule")}</span><button className="st-toggle" role="switch" aria-label={tr("Schedule {0}", [t.title])} aria-checked={t.enabled} disabled={pending || t.status === 'running'} onClick={() => void action(() => api.patch(`/api/tasks/${t.id}`, { enabled: !t.enabled }))}/></label></>)}</div></article>))}</div>)}
|
||||
{tr(currentTask ? <TaskDetail task={currentTask} modelName={nativeModel?.label ?? null}/> : null)}
|
||||
{tr(!tasks.some(t => !t.archived) && !query && !archived ? <section className="task-templates" aria-label={tr("Task ideas")}>{tr(templates.map(t => <button key={t.title} className="task-template" onClick={() => open({ ...blank(), ...t, title: tr(t.title), prompt: tr(t.prompt), weekday: 5 })}><span className="task-icon"><Icon name={t.icon} size={24}/></span><span><strong>{tr(t.title)}</strong><span className="small muted">{tr(t.detail)}</span><span className="xs muted"><Icon name="clock" size={13}/>{tr(t.frequency === 'weekly' ? 'Fridays' : t.frequency === 'weekdays' ? 'Weekdays' : 'Daily')} · {tr(t.at)}</span></span></button>))}</section> : null)}
|
||||
</div>
|
||||
{tr(menu ? <FloatingMenu anchor={trigger.current} title={tr("New task")} onClose={() => setMenu(false)}><button role="menuitem" className="menu__item" disabled={!loaded} onClick={() => open(blank(), true)}><Icon name="thinking"/>{tr("Create with AI")}</button><button role="menuitem" className="menu__item" onClick={() => open(blank())}><Icon name="sliders"/>{tr("Set up manually")}</button></FloatingMenu> : null)}
|
||||
{tr(editor ? <TaskEditor task={editor} assistant={assistant} onClose={closeEditor}/> : null)}
|
||||
<Dialog open={!!result} title={selected ? <span>{selected.title}</span> : tr('Task result')} onClose={() => setResult(null)} sheet>{tr(selected ? <><p className="small muted">{tr(selected.lastRunAt ? new Date(selected.lastRunAt).toLocaleString(localeTag()) : '')}{tr(selected.demo ? ' · Example result' : '')}</p><div className="document-markdown">{tr(selected.error ? <ErrorNotice message={selected.error} next={tr("Try running this task again.")}/> : <Markdown text={selected.output ?? tr('No result yet.')}/>)}</div></> : null)}</Dialog>
|
||||
<Dialog open={removeTask && !!currentTask} compact title={tr("Delete this task?")} onClose={() => !pending && setRemoveTask(false)} actions={<><Btn disabled={pending} onClick={() => setRemoveTask(false)}>{tr("Keep task")}</Btn><Btn variant="danger" disabled={pending} onClick={() => { const id = currentTask?.id; void action(() => api.patch(`/api/tasks/${id}`, { archived: true, enabled: false })).then(() => { setRemoveTask(false); navigate('tasks'); }); }}>{tr("Delete task")}</Btn></>}><div className="stack stack--sm"><p><strong>“{currentTask?.title}”</strong>{tr(" will stop running and be removed from your active tasks.")}</p><p className="small muted">{tr("Your instructions and last result are kept in Deleted tasks. You can restore the task there; its schedule will stay paused.")}</p></div></Dialog>
|
||||
</Screen>;
|
||||
}
|
||||
@@ -7013,3 +7013,123 @@ textarea {
|
||||
/* What an approval is about, shown without opening the card: the file or the command. */
|
||||
.toolcard__what { display: block; margin: 4px 0 2px; padding: 6px 10px; background: var(--st-surface-2); color: var(--st-ink); border: 1px solid var(--st-line); border-radius: var(--st-r-sm); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 6.5em; overflow: auto; font-size: 12px; }
|
||||
.tool-summary__target { min-width: 0; max-width: 28em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--st-mono, monospace); font-size: 12px; }
|
||||
|
||||
/* ---- Models: the version list and "This computer" ---- */
|
||||
.version-trigger { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 0 14px; border: 1.5px solid var(--st-line); border-radius: var(--st-r-md); background: var(--st-surface); color: var(--st-ink); font: inherit; cursor: pointer; text-align: left; }
|
||||
.version-trigger__need { margin-left: auto; font-size: var(--st-fs-sm); white-space: nowrap; }
|
||||
.version-trigger svg:last-child { flex: none; }
|
||||
.version-menu { display: flex; flex-direction: column; max-height: min(70vh, 640px); overflow: auto; padding: 4px 0; min-width: min(620px, calc(100vw - 24px)); }
|
||||
.version-head { display: grid; grid-template-columns: 1fr 84px 84px; gap: 10px; padding: 8px 18px 4px; font-size: var(--st-fs-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--st-ink-muted); }
|
||||
.version-head span:nth-child(n+2) { text-align: right; }
|
||||
.version-row { display: grid; grid-template-columns: 1fr 84px 84px; gap: 10px; align-items: baseline; width: calc(100% - 12px); margin: 0 6px; padding: 8px 12px; border: 0; border-radius: var(--st-r-sm); background: transparent; color: var(--st-ink); font: inherit; text-align: left; cursor: pointer; }
|
||||
.version-row:hover, .version-row:focus-visible { background: var(--st-surface-2); }
|
||||
.version-row.is-on { background: var(--st-surface-2); }
|
||||
.version-row__name { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.version-row__name svg { color: var(--st-accent-text); margin-right: 6px; vertical-align: -2px; }
|
||||
.version-row__ssd { margin-left: 10px; font-size: var(--st-fs-xs); color: var(--st-warn-text); }
|
||||
.version-row__ram { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.version-row__dl { text-align: right; font-size: var(--st-fs-sm); color: var(--st-ink-muted); font-variant-numeric: tabular-nums; }
|
||||
.version-group { border-top: 1px solid var(--st-line-soft); margin-top: 4px; }
|
||||
.version-group[data-open] { background: var(--st-surface-2); padding-bottom: 6px; }
|
||||
.version-group[data-open] .version-row:hover { background: var(--st-surface); }
|
||||
.version-group__head { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 12px 18px; border: 0; background: transparent; color: var(--st-ink); font: inherit; font-weight: 700; cursor: pointer; text-align: left; }
|
||||
.version-group__head svg.is-open { transform: rotate(180deg); }
|
||||
.version-own { border-top: 1px solid var(--st-line-soft); margin-top: 4px; }
|
||||
.pc-card { display: flex; flex-direction: column; gap: 18px; }
|
||||
.pc-card p { margin: 2px 0 0; }
|
||||
.pc-meter { display: flex; flex-direction: column; gap: 7px; }
|
||||
.pc-meter__top { display: flex; align-items: baseline; gap: 8px; font-size: var(--st-fs-sm); }
|
||||
.pc-meter__top span:first-child { font-weight: 700; margin-right: auto; }
|
||||
.pc-meter__top b { font-size: var(--st-fs-lg); letter-spacing: -.01em; font-variant-numeric: tabular-nums; }
|
||||
.pc-meter__track { height: 10px; border-radius: 9px; background: var(--st-surface-2); border: 1px solid var(--st-line); overflow: hidden; }
|
||||
.pc-meter__track i { display: block; height: 100%; background: var(--st-accent); border-radius: 9px; }
|
||||
.pc-note { font-size: var(--st-fs-sm); color: var(--st-ink-soft); }
|
||||
|
||||
/* ---- approval card: calm, compact, one question ---- */
|
||||
.askcard { border: 1px solid var(--st-line); border-radius: var(--st-r-md); background: var(--st-surface); margin: 6px 0; }
|
||||
.askcard__top { display: flex; align-items: center; gap: 12px; padding: 10px 12px 10px 14px; }
|
||||
.askcard__icon { flex: none; display: grid; place-items: center; color: var(--st-ink-muted); }
|
||||
.askcard__text { flex: 1; min-width: 0; }
|
||||
.askcard__q { margin: 0; font-size: var(--st-fs-md); font-weight: 700; line-height: 1.3; }
|
||||
.askcard__why { margin: 0; font-size: var(--st-fs-sm); color: var(--st-ink-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.askcard__acts { display: flex; gap: 8px; flex: none; }
|
||||
.askcard__details { border-top: 1px solid var(--st-line-soft); }
|
||||
.askcard__details summary { list-style: none; cursor: pointer; padding: 6px 14px; font-size: var(--st-fs-xs); font-weight: 500; color: var(--st-ink-muted); display: flex; align-items: center; gap: 6px; }
|
||||
.askcard__details summary::-webkit-details-marker { display: none; }
|
||||
.askcard__details[open] summary svg { transform: rotate(180deg); }
|
||||
.askcard__details pre { margin: 0 14px 10px; padding: 8px 10px; background: var(--st-code-bg); color: var(--st-code-ink); border-radius: var(--st-r-sm); font: 12px/1.5 var(--st-font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
@media (max-width: 560px) { .askcard__top { flex-wrap: wrap; } .askcard__acts { margin-left: 32px; } }
|
||||
|
||||
/* ---- an opened task ---- */
|
||||
.main__back { display: inline-flex; align-items: center; gap: 2px; margin-right: 10px; padding: 0; border: 0; background: transparent; color: var(--st-ink-muted); font: inherit; font-size: var(--st-fs-sm); font-weight: 500; cursor: pointer; }
|
||||
.main__back svg { transform: rotate(90deg); }
|
||||
.main__back:hover { color: var(--st-ink); }
|
||||
.task-header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.task-state { font-size: var(--st-fs-sm); font-weight: 700; color: var(--st-ink-muted); margin-left: 10px; }
|
||||
.task-state[data-state="active"], .task-state[data-state="running"] { color: var(--st-accent-text); }
|
||||
.task-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||
.task-detail .label { display: block; font-size: var(--st-fs-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--st-ink-muted); margin: 0 0 6px; }
|
||||
.task-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0; padding: 18px 4px; }
|
||||
.task-facts > div { display: flex; flex-direction: column; gap: 4px; padding: 2px 20px; border-left: 1px solid var(--st-line-soft); min-width: 0; }
|
||||
.task-facts > div:first-child { border-left: 0; }
|
||||
.task-facts strong { display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||
.task-facts strong svg { flex: none; color: var(--st-icon); }
|
||||
.task-facts .is-good { color: var(--st-accent-text); }
|
||||
.task-facts .is-good svg { color: currentColor; }
|
||||
.task-facts .is-bad { color: var(--st-danger-text); }
|
||||
|
||||
/* ---- Tune model: the steps and the live speed ---- */
|
||||
.tune-run { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 0; margin: 0 -4px; }
|
||||
.tune-steps2 { list-style: none; margin: 0; padding: 0 20px 0 4px; display: flex; flex-direction: column; }
|
||||
.tune-step { display: grid; grid-template-columns: 44px 1fr auto; gap: 14px; align-items: center; padding: 12px 0; position: relative; }
|
||||
.tune-step:not(:last-child)::after { content: ""; position: absolute; left: 21px; top: 56px; bottom: -8px; width: 2px; background: var(--st-line); }
|
||||
.tune-step[data-s="done"]:not(:last-child)::after { background: var(--st-accent); }
|
||||
.tune-step__badge { width: 44px; height: 44px; border-radius: 14px; display: grid; place-items: center; background: var(--st-surface-2); border: 1px solid var(--st-line); color: var(--st-ink-muted); position: relative; }
|
||||
.tune-step[data-s="run"] .tune-step__badge { background: var(--st-accent-tint); color: var(--st-accent-text); border-color: var(--st-accent); }
|
||||
.tune-step[data-s="run"] .tune-step__badge::before { content: ""; position: absolute; inset: -5px; border-radius: 18px; border: 2px solid transparent; border-top-color: var(--st-accent); animation: tune-spin 1.1s linear infinite; }
|
||||
.tune-step[data-s="done"] .tune-step__badge { background: var(--st-accent); color: var(--st-accent-ink); border-color: transparent; animation: tune-pop 260ms var(--st-ease); }
|
||||
.tune-step__text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.tune-step__text small { color: var(--st-ink-muted); font-size: var(--st-fs-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tune-step[data-s="pending"] .tune-step__text strong { color: var(--st-ink-muted); }
|
||||
.tune-step__state { font-size: var(--st-fs-xs); font-weight: 700; color: var(--st-ink-muted); }
|
||||
.tune-step[data-s="run"] .tune-step__state, .tune-step[data-s="done"] .tune-step__state { color: var(--st-accent-text); }
|
||||
.tune-step[data-s="run"][data-step="gauge"] .tune-step__badge svg { animation: tune-sweep 1.4s ease-in-out infinite alternate; transform-origin: 50% 60%; }
|
||||
.tune-step[data-s="run"][data-step="sliders"] .tune-step__badge svg { animation: tune-slide 1.2s ease-in-out infinite alternate; }
|
||||
.tune-step[data-s="run"][data-step="cpu"] .tune-step__badge svg, .tune-step[data-s="run"][data-step="thinking"] .tune-step__badge svg, .tune-step[data-s="run"][data-step="disk"] .tune-step__badge svg { animation: tune-breathe 1.2s ease-in-out infinite; }
|
||||
.tune-live { border-left: 1px solid var(--st-line); background: var(--st-surface-2); padding: 20px; display: flex; flex-direction: column; gap: 12px; border-radius: 0 var(--st-r-md) var(--st-r-md) 0; }
|
||||
.tune-live .label, .tune-hero .label { display: block; font-size: var(--st-fs-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--st-ink-muted); }
|
||||
.tune-live__big { font-size: var(--st-fs-display); font-weight: 900; letter-spacing: -.03em; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.tune-live__big small { display: block; margin-top: 4px; font-size: var(--st-fs-sm); font-weight: 500; color: var(--st-ink-muted); letter-spacing: 0; }
|
||||
.tune-spark { width: 100%; height: 64px; }
|
||||
.tune-spark--empty { border-bottom: 1px dashed var(--st-line); }
|
||||
.tune-spark__fill { fill: var(--st-accent-tint); }
|
||||
.tune-spark__line { fill: none; stroke: var(--st-accent); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
||||
.tune-spark circle { fill: var(--st-accent); }
|
||||
.tune-live__bar { height: 8px; border-radius: 9px; background: var(--st-line); overflow: hidden; }
|
||||
.tune-live__bar i { display: block; height: 100%; background: var(--st-accent); border-radius: 9px; transition: width 600ms var(--st-ease); }
|
||||
.tune-live__list { list-style: none; margin: 0; padding: 0; font-size: var(--st-fs-sm); }
|
||||
.tune-live__list li { display: flex; justify-content: space-between; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--st-line-soft); }
|
||||
.tune-live__list span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--st-ink-soft); }
|
||||
.tune-live__list b { font-variant-numeric: tabular-nums; color: var(--st-ink-muted); }
|
||||
.tune-live__list b[data-up] { color: var(--st-accent-text); }
|
||||
.tune-result { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: center; }
|
||||
.tune-hero { border-radius: var(--st-r-lg); padding: 22px; background: linear-gradient(135deg, var(--st-accent-tint), transparent); border: 1px solid var(--st-accent); }
|
||||
.tune-hero__big { font-size: 56px; font-weight: 900; letter-spacing: -.03em; line-height: 1.1; color: var(--st-accent-text); animation: tune-pop 400ms var(--st-ease); }
|
||||
.tune-hero p { margin: 8px 0 0; }
|
||||
.tune-compare { display: flex; flex-direction: column; gap: 10px; }
|
||||
.tune-compare > div { display: grid; grid-template-columns: 64px 1fr 56px; gap: 10px; align-items: center; }
|
||||
.tune-compare b { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.tune-compare__bar { height: 12px; border-radius: 9px; background: var(--st-line); overflow: hidden; }
|
||||
.tune-compare__bar i { display: block; height: 100%; border-radius: 9px; background: var(--st-ink-muted); transition: width 700ms var(--st-ease); }
|
||||
.tune-compare > div[data-new] .tune-compare__bar i { background: var(--st-accent); }
|
||||
.tune-compare p { margin: 4px 0 0; grid-column: 1 / -1; }
|
||||
@keyframes tune-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes tune-pop { 0% { transform: scale(.7); opacity: .4; } 100% { transform: scale(1); opacity: 1; } }
|
||||
@keyframes tune-sweep { from { transform: rotate(-16deg); } to { transform: rotate(16deg); } }
|
||||
@keyframes tune-slide { from { transform: translateX(-2px); } to { transform: translateX(2px); } }
|
||||
@keyframes tune-breathe { 50% { transform: scale(1.12); } }
|
||||
@media (max-width: 720px) { .tune-run, .tune-result { grid-template-columns: 1fr; } .tune-live { border-left: 0; border-top: 1px solid var(--st-line); border-radius: 0 0 var(--st-r-md) var(--st-r-md); margin-top: 8px; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tune-step[data-s="run"] .tune-step__badge::before, .tune-step[data-s="run"] .tune-step__badge svg, .tune-step[data-s="done"] .tune-step__badge, .tune-hero__big { animation: none !important; }
|
||||
.tune-live__bar i, .tune-compare__bar i { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Item 2: an approval card asks in plain words; the exact command stays in the card's Details. */
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { askFor } from '../server/agent/ask.mjs';
|
||||
import { TurnProjector } from '../server/agent/events.mjs';
|
||||
|
||||
const sh = command => askFor('pwsh', { command });
|
||||
const ev = (type, data, seq = 1, time = 1000) => ({ type: 'event', event: { type, seq, time, data } });
|
||||
|
||||
test('opening a folder is asked as opening that folder, with the common ones by name', () => {
|
||||
assert.deepEqual(sh('Start-Process explorer.exe -ArgumentList "C:\\Users\\Ana\\Documents"').id, 'open-known-folder');
|
||||
assert.deepEqual(sh('Start-Process explorer.exe -ArgumentList "C:\\Users\\Ana\\Documents"').params, ['Documents']);
|
||||
assert.deepEqual(sh('explorer.exe C:\\Users\\Ana\\Downloads').params, ['Downloads']);
|
||||
assert.deepEqual(sh('Start-Process explorer.exe C:\\work\\Reports'), { id: 'open-folder', params: ['Reports'], why: 'opens-window', detail: 'C:\\work\\Reports' });
|
||||
assert.equal(sh('Invoke-Item C:\\work\\plan.pdf').id, 'open-file');
|
||||
assert.equal(sh('explorer.exe').id, 'open-explorer');
|
||||
});
|
||||
|
||||
test('running a program, deleting, creating and copying are named for what they are', () => {
|
||||
assert.deepEqual(sh('Start-Process "C:\\Users\\Ana\\Downloads\\setup.exe" -ArgumentList /S').params, ['setup.exe']);
|
||||
assert.equal(sh('Start-Process "C:\\Users\\Ana\\Downloads\\setup.exe" -ArgumentList /S').id, 'run-program');
|
||||
assert.deepEqual(sh('Remove-Item C:\\tmp\\old.txt -Force'), { id: 'delete', params: ['old.txt'], why: 'delete-why', detail: 'C:\\tmp\\old.txt' });
|
||||
assert.equal(sh('rm -rf build').id, 'delete');
|
||||
assert.equal(sh('New-Item -ItemType File -Path C:\\Users\\Ana\\Desktop\\notes.txt').id, 'create-file');
|
||||
assert.equal(sh('mkdir C:\\work\\out').id, 'create-folder');
|
||||
assert.equal(sh('Copy-Item a.txt b.txt').id, 'copy');
|
||||
assert.equal(sh('Get-Date | Out-File C:\\x\\a.txt').id, 'write-file');
|
||||
assert.equal(sh('echo hi > notes.txt').params[0], 'notes.txt');
|
||||
assert.deepEqual(sh('git push origin main'), { id: 'git', params: ['push'], why: 'changes-repo' });
|
||||
assert.equal(sh('npm install left-pad').id, 'install');
|
||||
assert.equal(sh('python build.py').id, 'run-script');
|
||||
});
|
||||
|
||||
test('files the agent writes or edits, and the tools that are not commands', () => {
|
||||
assert.deepEqual(askFor('write', { file_path: 'C:/Users/Ana/Desktop/notes.txt' }), { id: 'create-file', params: ['notes.txt'], why: 'replaces-if-exists', detail: 'C:/Users/Ana/Desktop/notes.txt' });
|
||||
assert.equal(askFor('edit', { file_path: 'C:/a/b.md' }).id, 'change-file');
|
||||
assert.equal(askFor('job_kill', {}).id, 'stop-task');
|
||||
assert.deepEqual(askFor('web_fetch', { url: 'https://example.com/a?b=1' }).params, ['example.com']);
|
||||
assert.deepEqual(askFor('mcp__github__create_issue', {}).params, ['github / create_issue']);
|
||||
});
|
||||
|
||||
test('what it cannot read it does not pretend to: a generic question, with the model\'s own description when there is one', () => {
|
||||
assert.deepEqual(sh('foo --bar'), { id: 'run-command', params: [], why: 'run-command-why' });
|
||||
assert.equal(askFor('pwsh', { command: 'foo --bar' }, 'Rebuild the index').detail, 'Rebuild the index');
|
||||
assert.equal(sh('Get-Date; Remove-Item x.txt').id, 'run-steps', 'two different steps are said to be steps');
|
||||
});
|
||||
|
||||
test('the card carries the question, and the exact command stays in its arguments for Details', () => {
|
||||
const p = new TurnProjector({ startedAt: 0 });
|
||||
const card = p.consume(ev('tool/call', { callId: 'a1', name: 'pwsh', arguments: JSON.stringify({ command: 'Start-Process explorer.exe -ArgumentList "C:\\Users\\Ana\\Documents"', description: 'Open Documents' }) }))[0].tool;
|
||||
assert.equal(card.ask.id, 'open-known-folder'); assert.deepEqual(card.ask.params, ['Documents']);
|
||||
assert.match(card.preview, /Start-Process explorer\.exe/, 'the command is still sent for the Details disclosure');
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Items 10 and 11: what a version needs of RAM + VRAM (without the n-gram table) and how much would be read from the SSD. */
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { transform } from 'esbuild';
|
||||
|
||||
const { code } = await transform(await readFile(new URL('../src/lib/native-fit.ts', import.meta.url), 'utf8'), { loader: 'ts', format: 'esm' });
|
||||
const { fitOn, memoryNeeded, RECOMMENDED, VERSION_GROUPS } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
|
||||
|
||||
const swift = { ram_needed_gb: 48, experts_gb: 35.5 };
|
||||
const gyroS = { ram_needed_gb: 27.6, experts_gb: 25.8, mem_total: true };
|
||||
const q4 = { ram_needed_gb: 48, experts_gb: 77 };
|
||||
|
||||
test('the memory a version needs is RAM + VRAM without the n-gram table', () => {
|
||||
assert.equal(memoryNeeded(swift), 36.5, 'experts plus about 1 GB for the dense part');
|
||||
assert.equal(memoryNeeded(gyroS), 27.6, 'Gyro states its total');
|
||||
assert.equal(memoryNeeded({ ...swift, memory_gb: 36.5 }), 36.5, 'the catalog figure wins when it is sent');
|
||||
});
|
||||
|
||||
test('a PC with room reads nothing from the SSD', () => {
|
||||
const f = fitOn(swift, { ram_gb: 64, vram_gb: 12 });
|
||||
assert.equal(f.known, true); assert.equal(f.spillGb, 0);
|
||||
assert.ok(f.vramUseGb > 0 && f.vramUseGb <= 12 && f.ramUseGb > 0 && f.ramUseGb <= 54);
|
||||
});
|
||||
|
||||
test('a PC without room reads the rest from the SSD, and the amount is real', () => {
|
||||
const f = fitOn(q4, { ram_gb: 64, vram_gb: 12 }); // 77 GB of experts: 7 on the card, 54 in RAM, 16 from the SSD
|
||||
assert.equal(f.spillGb, 16);
|
||||
assert.equal(fitOn(q4, { ram_gb: 192, vram_gb: 24 }).spillGb, 0);
|
||||
assert.ok(fitOn(gyroS, { ram_gb: 16, vram_gb: 8 }).spillGb > 10, 'a small PC spills a lot');
|
||||
});
|
||||
|
||||
test('unknown hardware gives no verdict instead of a made-up one', () => {
|
||||
const f = fitOn(swift, null);
|
||||
assert.equal(f.known, false); assert.equal(f.spillGb, 0); assert.equal(f.memoryGb, 36.5);
|
||||
assert.equal(fitOn(swift, { ram_gb: 0 }).known, false);
|
||||
});
|
||||
|
||||
test('Recommended is the fixed four, in the owner\'s order; the groups are Swift, GSQ-RCO, NVFP4, Gyro (Unsloth for the rest)', () => {
|
||||
assert.deepEqual(RECOMMENDED.map(r => `${r.family}:${r.model}`), ['gyro:Gyro-S', 'swift:IQ2_XS', 'qwen:IQ3_S', 'unsloth:UD-Q4_K_XL']);
|
||||
assert.deepEqual(VERSION_GROUPS.map(g => g.title), ['Swift models', 'GSQ-RCO models', 'NVFP4 models', 'Gyro models', 'Unsloth models']);
|
||||
assert.ok(RECOMMENDED.every(r => r.note.length > 0 && r.note.length < 40), 'one short note each');
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Item 7: the tuner's stage names map onto the five steps shown, and the speeds it reports become the live readout. */
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { transform } from 'esbuild';
|
||||
|
||||
const { code } = await transform(await readFile(new URL('../src/lib/tune-phase.ts', import.meta.url), 'utf8'), { loader: 'ts', format: 'esm' });
|
||||
const { tunePhase, tuneSpeeds } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
|
||||
|
||||
test('every stage the tuner reports lands on one of the five steps, in order', () => {
|
||||
assert.deepEqual(['start', 'waiting', undefined, null].map(tunePhase), [0, 0, 0, 0], 'checking the PC, also while it waits for a quiet PC');
|
||||
assert.equal(tunePhase('base'), 1);
|
||||
assert.deepEqual(['plan', 'rounds', 'drift', 'per_request'].map(tunePhase), [2, 2, 2, 2]);
|
||||
assert.equal(tunePhase('confirm'), 3);
|
||||
assert.equal(tunePhase('done'), 4);
|
||||
assert.equal(tunePhase('something new'), 0, 'an unknown stage never breaks the page');
|
||||
});
|
||||
|
||||
test('the speeds are what the tuner measured, base first, and nothing invented', () => {
|
||||
assert.deepEqual(tuneSpeeds(undefined), []);
|
||||
assert.deepEqual(tuneSpeeds({ stage: 'rounds' }), []);
|
||||
const speeds = tuneSpeeds({ stage: 'rounds', speeds: [{ label: 'base', tok_s: 41.2 }, { label: 'More experts in memory', tok_s: 46.8 }, { label: 'bad', tok_s: 0 }, { label: 'worse', tok_s: 'x' }] });
|
||||
assert.deepEqual(speeds, [{ label: 'base', tok_s: 41.2 }, { label: 'More experts in memory', tok_s: 46.8 }]);
|
||||
});
|
||||
@@ -196,6 +196,7 @@ class Tuner:
|
||||
self.total_vram = total_vram_mib
|
||||
self.quiet = quiet
|
||||
self.storm = False
|
||||
self.speeds: list[dict[str, Any]] = []
|
||||
self.waited_s = 0.0 # time spent waiting for the box lock, the PC to be quiet or memory: not the user's budget
|
||||
|
||||
# ---- bookkeeping
|
||||
@@ -208,7 +209,9 @@ class Tuner:
|
||||
remaining = max(0, self.planned_runs - self.done_runs)
|
||||
eta = min(max(0.0, self.o.budget_s - elapsed), remaining * avg) if self.planned_runs else None
|
||||
self.progress({"stage": stage, "message": message, "elapsed_s": round(elapsed), "runs_done": self.done_runs,
|
||||
"runs_planned": self.planned_runs, "eta_s": None if eta is None else round(eta), **extra})
|
||||
"runs_planned": self.planned_runs, "eta_s": None if eta is None else round(eta),
|
||||
# what each measurement so far came to, for the live readout (label, decode tok/s as the goal judges it)
|
||||
"speeds": list(self.speeds), **extra})
|
||||
|
||||
def _check_cancel(self) -> None:
|
||||
if self.cancel.is_set():
|
||||
@@ -347,6 +350,9 @@ class Tuner:
|
||||
self._say("base", "Measuring your current settings")
|
||||
base1 = self._run(self.base_args, {}, spec, "base", points=per_request_points)
|
||||
self.base_runs.append(base1)
|
||||
if base1.status == "PASS" and metric(base1, o.use):
|
||||
self.speeds.append({"label": "base", "tok_s": round(float(metric(base1, o.use)), 1)})
|
||||
self._say("base", "Measured your current settings")
|
||||
if base1.status != "PASS" or not metric(base1, o.use):
|
||||
r.outcome = "base_failed"
|
||||
r.message = "Your current settings could not be measured: " + (base1.reason or base1.status)
|
||||
@@ -435,6 +441,8 @@ class Tuner:
|
||||
r.notes.append("an engine ran out of memory; the search stopped (nothing was retried)")
|
||||
break
|
||||
continue
|
||||
if metric(res, o.use):
|
||||
self.speeds.append({"label": p.arm.label, "tok_s": round(float(metric(res, o.use)), 1)})
|
||||
over = self._memory_overrun(res, base1)
|
||||
if over:
|
||||
rep.status, rep.reason = "failed", over
|
||||
|
||||
@@ -240,6 +240,15 @@ class TunerTest(unittest.TestCase):
|
||||
self.assertLessEqual(etas[-1], etas[0])
|
||||
self.assertEqual(seen[-1]["stage"], "done")
|
||||
|
||||
def test_progress_carries_the_speed_of_every_measurement_so_far(self):
|
||||
seen = []
|
||||
t, s, r = run_tuner(speed_with({("--spec", "6"): 1.2}), progress=seen.append)
|
||||
last = [p for p in seen if p.get("speeds")][-1]["speeds"]
|
||||
self.assertEqual(last[0]["label"], "base")
|
||||
self.assertAlmostEqual(last[0]["tok_s"], 60.0, places=1)
|
||||
self.assertTrue(all(isinstance(x["tok_s"], float) and x["tok_s"] > 0 for x in last))
|
||||
self.assertGreater(len(last), 1, "the candidates' speeds follow the base")
|
||||
|
||||
def test_per_request_value_needs_every_round(self):
|
||||
wins = {"n": 0}
|
||||
|
||||
|
||||
@@ -846,6 +846,10 @@ class Strata:
|
||||
"experimental": bool(d.get("experimental")),
|
||||
"images": d.get("vision", fd.get("vision", True)) is not False,
|
||||
"ram_needed_gb": d["ram_gb"], "experts_gb": d.get("arena_gb"),
|
||||
# RAM + VRAM the weights need together, WITHOUT the n-gram table (that stays on the SSD): Gyro states it
|
||||
# (mem_total), for the rest the experts plus ~1 GB for the dense part (Swift IQ2_XS: 35.5 + 1 = 36.5)
|
||||
"mem_total": bool(d.get("mem_total")),
|
||||
"memory_gb": round(float(d["ram_gb"]) if d.get("mem_total") else float(d.get("arena_gb") or d["ram_gb"]) + 1.0, 1),
|
||||
"on_this_pc": verdict, "installed": tag in have, "id": tag})
|
||||
out.append({"family": f, "title": fd["title"], "about": fd.get("about"),
|
||||
"experimental": bool(fd.get("experimental")),
|
||||
|
||||
@@ -75,6 +75,17 @@ class GyroCatalog(unittest.TestCase):
|
||||
self.assertEqual(by["Gyro-M"]["on_this_pc"], "tight")
|
||||
self.assertFalse(by["Gyro-S"]["images"])
|
||||
|
||||
def test_mcp_table_gives_ram_plus_vram_without_the_ngram_table(self):
|
||||
"""The Models page shows what a version needs of RAM + VRAM together, the n-gram table (on the SSD) not counted."""
|
||||
s = M.Strata(ROOT)
|
||||
table = s.model_table(hardware(64.0, 12.0))
|
||||
gyro = {x["model"]: x for x in next(f for f in table if f["family"] == "gyro")["sizes"]}
|
||||
self.assertEqual(gyro["Gyro-S"]["memory_gb"], 27.6) # stated in total (mem_total), not the 58.5 GB download
|
||||
self.assertTrue(gyro["Gyro-S"]["mem_total"])
|
||||
swift = {x["model"]: x for x in next(f for f in table if f["family"] == "swift")["sizes"]}
|
||||
self.assertEqual(swift["IQ2_XS"]["memory_gb"], 36.5) # 35.5 GB of experts + ~1 GB dense
|
||||
self.assertFalse(swift["IQ2_XS"]["mem_total"])
|
||||
|
||||
|
||||
class GyroEngineVersionTest(unittest.TestCase):
|
||||
def test_gyro_needs_the_split_release_engine(self):
|
||||
|
||||
Reference in new issue
Block a user