diff --git a/apps/desktop/scripts/ui-flows.mjs b/apps/desktop/scripts/ui-flows.mjs index 2b0fb0dc3..4d8961422 100644 --- a/apps/desktop/scripts/ui-flows.mjs +++ b/apps/desktop/scripts/ui-flows.mjs @@ -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. ' + '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.', '', '', '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); diff --git a/apps/host/public/icons/sprite.svg b/apps/host/public/icons/sprite.svg index 7cab5ac05..bfdc72658 100644 --- a/apps/host/public/icons/sprite.svg +++ b/apps/host/public/icons/sprite.svg @@ -192,6 +192,14 @@ + + + + + + + + diff --git a/apps/host/scripts/approval-flow.mjs b/apps/host/scripts/approval-flow.mjs new file mode 100644 index 000000000..5c62ba66d --- /dev/null +++ b/apps/host/scripts/approval-flow.mjs @@ -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); diff --git a/apps/host/server/agent/ask.mjs b/apps/host/server/agent/ask.mjs new file mode 100644 index 000000000..3f5ddf237 --- /dev/null +++ b/apps/host/server/agent/ask.mjs @@ -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)] }; + } +} diff --git a/apps/host/server/agent/events.mjs b/apps/host/server/agent/events.mjs index 4b7728a90..384ef99c6 100644 --- a/apps/host/server/agent/events.mjs +++ b/apps/host/server/agent/events.mjs @@ -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. */ diff --git a/apps/host/server/native-autotune.mjs b/apps/host/server/native-autotune.mjs index f62285360..51a7d886c 100644 --- a/apps/host/server/native-autotune.mjs +++ b/apps/host/server/native-autotune.mjs @@ -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); diff --git a/apps/host/src/components/NativeModels.tsx b/apps/host/src/components/NativeModels.tsx index fae557a0d..e4fd04bb8 100644 --- a/apps/host/src/components/NativeModels.tsx +++ b/apps/host/src/components/NativeModels.tsx @@ -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('operations', { kind: 'install', args: downloadValues }); setJobId(result.id); setPlan(null); }); } - return ({ value: f.family, label: f.title }))} onChange={id => { setFamily(id); setSize(''); setVision('no'); setError(''); }}/> : undefined} actions={ { setExpert(v => !v); setMultiple(false); setGpus([]); setDeviceChoice('auto'); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}}> + return { setExpert(v => !v); setMultiple(false); setGpus([]); setDeviceChoice('auto'); }}>{tr(expert ? 'Close expert mode' : 'Expert mode')}}> {tr(selectedFamily ?
{tr(selectedFamily.title)}{tr(selectedSize ? catalogVersionLabel(selectedSize) : '')} · {tr(context / 1024)}{tr("K")}{tr(vision !== 'no' ? ' · Reads pictures' : '')}{tr(native?.instances.some(i => i.model === selectedSize?.id && i.status === 'ready') ? {tr("Ready")} : null)}
{ 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'); }}/> setManage(true)}>{tr("Manage models")}
: null)}
{tr(native?.instances.length && expert ?

{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}

{tr(native.instances.map(i =>
{tr(i.label)}{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' : '')}{tr(i.error ? {tr(plainFailureText(i.error).message)} : null)}{tr(expert && multiple && native.defaultId !== i.id ? void perform(() => nativeAction(`instances/${i.id}/default`))}>{tr("Use in chat")} : null)} void perform(() => nativeAction(`instances/${i.id}/unload`))}>{tr("Unload")}
))}
: null)}
{tr(selectedFamily && !selectedSize ? <>

{tr("This model needs a newer engine.")}

{tr("Update your engine or import a local GGUF for this model.")}

{ setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}{tr("Use a local GGUF\u2026")} : null)}{tr(!selectedFamily ? <>

{tr("Set up Strata")}

{tr("Connect your local installation to choose an optimized model.")}

{ setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}{tr(native?.error ?

{tr(native.error)}

: null)}{tr("Other local models")} : null)} - {tr(selectedSize ? <>{tr(expert && vision !== 'no' ? setQuery(e.target.value)}/> setQuery(e.target.value)}/> setArchived(e.target.checked)}/>{tr("Deleted tasks")})} {tr(error && !editor ? void refresh()}/> : null)} - {tr(!ready ?

{tr("Loading tasks\u2026")}

: !visible.length ?

{tr(query ? 'No matching tasks' : archived ? 'No deleted tasks' : 'No scheduled tasks yet')}

{tr(query ? 'Try another search.' : 'Start with an idea below, or create your own.')}

:
{tr(visible.map(t =>

{t.title}

{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)}

{tr(t.error ? : null)}{tr(t.output ? : null)}
{tr(t.archived ? void action(() => api.patch(`/api/tasks/${t.id}`, { archived: false, enabled: false }))}>{tr("Restore")} : <> open(t)}/> void action(() => api.post(`/api/tasks/${t.id}/${t.status === 'running' ? 'stop' : 'run'}`))}>{tr(t.status === 'running' ? 'Stop' : 'Run now')}
))}
)} - {tr(currentTask ?

{tr("Instructions")}

{currentTask.prompt}

{tr(currentTask.output ? <>

{tr("Last result")}{tr(currentTask.demo ? ' · Example' : '')}

:

{tr("Run this task to see its first result here.")}

)}
: null)} + {tr(currentTask ? null : !ready ?

{tr("Loading tasks\u2026")}

: !visible.length ?

{tr(query ? 'No matching tasks' : archived ? 'No deleted tasks' : 'No scheduled tasks yet')}

{tr(query ? 'Try another search.' : 'Start with an idea below, or create your own.')}

:
{tr(visible.map(t =>

{t.title}

{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)}

{tr(t.error ? : null)}{tr(t.output ? : null)}
{tr(t.archived ? void action(() => api.patch(`/api/tasks/${t.id}`, { archived: false, enabled: false }))}>{tr("Restore")} : <> open(t)}/> void action(() => api.post(`/api/tasks/${t.id}/${t.status === 'running' ? 'stop' : 'run'}`))}>{tr(t.status === 'running' ? 'Stop' : 'Run now')}
))}
)} + {tr(currentTask ? : null)} {tr(!tasks.some(t => !t.archived) && !query && !archived ?
{tr(templates.map(t => ))}
: null)}
{tr(menu ? setMenu(false)}> : null)} {tr(editor ? : null)} {selected.title} : tr('Task result')} onClose={() => setResult(null)} sheet>{tr(selected ? <>

{tr(selected.lastRunAt ? new Date(selected.lastRunAt).toLocaleString(localeTag()) : '')}{tr(selected.demo ? ' · Example result' : '')}

{tr(selected.error ? : )}
: null)}
+ !pending && setRemoveTask(false)} actions={<> setRemoveTask(false)}>{tr("Keep task")} { const id = currentTask?.id; void action(() => api.patch(`/api/tasks/${id}`, { archived: true, enabled: false })).then(() => { setRemoveTask(false); navigate('tasks'); }); }}>{tr("Delete task")}}>

“{currentTask?.title}”{tr(" will stop running and be removed from your active tasks.")}

{tr("Your instructions and last result are kept in Deleted tasks. You can restore the task there; its schedule will stay paused.")}

; } diff --git a/apps/host/src/styles/app.css b/apps/host/src/styles/app.css index cc8715d53..6e33e216c 100644 --- a/apps/host/src/styles/app.css +++ b/apps/host/src/styles/app.css @@ -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; } +} diff --git a/apps/host/tests/ask.test.mjs b/apps/host/tests/ask.test.mjs new file mode 100644 index 000000000..9759fc07d --- /dev/null +++ b/apps/host/tests/ask.test.mjs @@ -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'); +}); diff --git a/apps/host/tests/native-fit.test.mjs b/apps/host/tests/native-fit.test.mjs new file mode 100644 index 000000000..67f47110a --- /dev/null +++ b/apps/host/tests/native-fit.test.mjs @@ -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'); +}); diff --git a/apps/host/tests/tune-phase.test.mjs b/apps/host/tests/tune-phase.test.mjs new file mode 100644 index 000000000..8b93dbb77 --- /dev/null +++ b/apps/host/tests/tune-phase.test.mjs @@ -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 }]); +}); diff --git a/strata_control/autotune.py b/strata_control/autotune.py index c95280743..30ba5c116 100644 --- a/strata_control/autotune.py +++ b/strata_control/autotune.py @@ -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 diff --git a/tests/tuning/test_autotune.py b/tests/tuning/test_autotune.py index 8addab956..89b9869d5 100644 --- a/tests/tuning/test_autotune.py +++ b/tests/tuning/test_autotune.py @@ -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} diff --git a/tools/strata_mcp.py b/tools/strata_mcp.py index f3a8ae618..f6a70f338 100644 --- a/tools/strata_mcp.py +++ b/tools/strata_mcp.py @@ -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")), diff --git a/tools/test_setup_gyro.py b/tools/test_setup_gyro.py index afb2a8d7e..5e9869a4a 100644 --- a/tools/test_setup_gyro.py +++ b/tools/test_setup_gyro.py @@ -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):