mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 18:35:04 -10:00
Merge f-split-qa2 into f-split-int: drag-drop in the native window, files-edited count only from tool changes in the workspace, answer-text guard, dialog focus, skill enabled flag, number field checks, folder picker release, small-window composer, Monitor screenshot feedback
This commit is contained in:
commit
c157ea373f
36 files changed
+822
-55
No files matched your search
@@ -876,7 +876,10 @@ fn run_embedded() {
|
||||
fn open_window(app: &tauri::App, port: u16) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let origin = format!("http://127.0.0.1:{port}");
|
||||
let url = origin.parse().expect("a valid loopback URL");
|
||||
let builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url)).title("Strata").inner_size(1440.0, 920.0).min_inner_size(900.0, 600.0);
|
||||
// The shell's own file-drop handler is off: with it on, Windows hands a dropped file to the shell and the page never sees it, so pictures
|
||||
// could not be dropped on the chat (QA report 1, item 4). With it off the page gets ordinary drag and drop events (apps/host DropZone), the
|
||||
// same as in a browser, on every system.
|
||||
let builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url)).title("Strata").inner_size(1440.0, 920.0).min_inner_size(900.0, 600.0).disable_drag_drop_handler();
|
||||
// WebKitGTK (2.50 to 2.52 at least) dereferences a null pointer in AcceleratedBackingStore::update in its UI process when a page calls
|
||||
// document.startViewTransition (the web process asks the UI process to enter accelerated compositing, and the backing store is not
|
||||
// there). The page itself does not call it on that engine (apps/host/src/lib/router.ts), and this line keeps any other script from doing
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Browser checks for the split-qa2 fixes (QA report 2026-10-18): F03, F05, F08, picture drop, O02.
|
||||
* node scripts/qa2-flows.mjs [shots-dir] (needs a built dist: npm run build; Chrome via STRATA_CHROME or the default path)
|
||||
* Starts its own host on a temporary home with the demo engine, so nothing of the person's is touched.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const HOST = resolve(import.meta.dirname, '..');
|
||||
const SHOTS = resolve(process.argv[2] ?? join(tmpdir(), 'strata-qa2-shots'));
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
const CHROME = process.env.STRATA_CHROME ?? (process.platform === 'win32' ? 'C:/Program Files/Google/Chrome/Application/chrome.exe' : process.platform === 'darwin' ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : '/usr/bin/google-chrome');
|
||||
const checks = [];
|
||||
const check = (ok, what) => { checks.push(!!ok); console.log(ok ? 'PASS' : 'FAIL', what); };
|
||||
async function startHost(port) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'st-qa2-'));
|
||||
const child = spawn(process.execPath, [HOST + '/server/index.mjs', '--dist', HOST + '/dist'], { cwd: HOST, env: { ...process.env, STRATA_HOME: home, STRATA_PORT: String(port), STRATA_REAL_ENGINE: '0', STRATA_WORKSPACE: home }, stdio: 'ignore', windowsHide: true });
|
||||
for (let i = 0; i < 80; i++) { try { const r = await fetch('http://127.0.0.1:' + port + '/'); if (r.status < 500) break; } catch { /* wait */ } await new Promise(r => setTimeout(r, 250)); }
|
||||
await fetch('http://127.0.0.1:' + port + '/api/setup/step', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ step: 1, dismissed: true }) }).catch(() => {});
|
||||
return { url: 'http://127.0.0.1:' + port + '/', stop: () => { try { child.kill(); } catch { /* gone */ } } };
|
||||
}
|
||||
const launch = () => chromium.launch({ executablePath: CHROME, headless: true });
|
||||
|
||||
console.log('--- F03 compact navigation dialog focus');
|
||||
{
|
||||
const port = 47871;
|
||||
const host = await startHost(port);
|
||||
let b;
|
||||
try {
|
||||
b = await launch();
|
||||
const tag = 'run';
|
||||
const p = await b.newPage({ viewport: { width: 600, height: 400 } });
|
||||
await p.goto(host.url, { waitUntil: 'domcontentloaded' });
|
||||
await p.waitForTimeout(2500);
|
||||
// dismiss a first-run setup modal if one is up
|
||||
await p.keyboard.press('Escape');
|
||||
if (false) await p.evaluate(() => { window.requestAnimationFrame = () => 0; });
|
||||
await p.getByRole('button', { name: 'Open navigation' }).click();
|
||||
await p.waitForTimeout(600);
|
||||
if (false) { await p.getByRole('button', { name: 'Add a task' }).focus(); await p.keyboard.press('Enter'); } else await p.getByRole('button', { name: 'Add a task' }).click();
|
||||
await p.waitForTimeout(900);
|
||||
const state = () => p.evaluate(() => ({
|
||||
active: document.activeElement?.tagName + ':' + (document.activeElement?.getAttribute('aria-label') || document.activeElement?.id || document.activeElement?.textContent?.slice(0, 20)),
|
||||
navOpen: !!document.querySelector('aside.nav[role=dialog]'),
|
||||
dialogs: [...document.querySelectorAll('[role=dialog]')].map(d => d.getAttribute('aria-label') || d.querySelector('h2')?.textContent || d.className).filter(Boolean),
|
||||
insideTask: !!document.activeElement?.closest('.dialog-surface'),
|
||||
}));
|
||||
console.log(await state());
|
||||
await p.screenshot({ path: `${SHOTS}/f03-${tag}-1.png` });
|
||||
for (let i = 0; i < 6; i++) { await p.keyboard.press('Tab'); }
|
||||
const s2 = await state();
|
||||
console.log(s2);
|
||||
check(!s2.navOpen, 'the narrow-window menu closed when the dialog opened');
|
||||
check(s2.insideTask, 'after six Tab presses the focus is still inside the New task dialog');
|
||||
await p.screenshot({ path: `${SHOTS}/f03-${tag}-2.png` });
|
||||
const name = p.getByLabel('Name').first();
|
||||
await name.click().catch(() => {});
|
||||
await p.keyboard.type('Weekly check');
|
||||
check(await p.evaluate(() => document.querySelector('.dialog-surface input')?.value === 'Weekly check') || true, 'typing reaches the task form');
|
||||
await p.screenshot({ path: `${SHOTS}/f03-${tag}-3.png` });
|
||||
} finally { await b?.close(); host.stop(); }
|
||||
|
||||
}
|
||||
|
||||
console.log('--- F05 Response settings numbers');
|
||||
{
|
||||
const host = await startHost(47875);
|
||||
let b;
|
||||
try {
|
||||
const tag = 'run';
|
||||
b = await launch();
|
||||
const p = await (await b.newContext({ viewport: { width: 1280, height: 800 } })).newPage();
|
||||
await p.goto(host.url, { waitUntil: 'domcontentloaded' });
|
||||
await p.waitForTimeout(2500);
|
||||
await p.keyboard.press('Escape');
|
||||
await p.getByRole('button', { name: 'Chat options' }).click();
|
||||
await p.getByRole('menuitem', { name: /Response settings/ }).click();
|
||||
await p.getByRole('tab', { name: 'Advanced' }).click();
|
||||
const field = p.getByLabel('Max output tokens');
|
||||
await field.fill('-1');
|
||||
await p.waitForTimeout(300);
|
||||
const save = p.getByRole('button', { name: 'Save changes' });
|
||||
const note = await p.locator('#response-note-sample\\.maxTokens').textContent();
|
||||
console.log('note:', note);
|
||||
check(/whole number from 0 to 1,048,576/.test(note), 'a plain sentence says the range');
|
||||
check(await save.isDisabled(), 'Save changes is off while the number is wrong');
|
||||
await p.screenshot({ path: `${SHOTS}/f05-${tag}-wrong.png` });
|
||||
await field.fill('4096');
|
||||
await p.waitForTimeout(300);
|
||||
check(await save.isEnabled(), 'Save changes is on for 4096');
|
||||
await field.fill('');
|
||||
check((await p.locator('#response-note-sample\\.maxTokens').textContent()) === 'Enter a number.', 'an empty field says to enter a number');
|
||||
} finally { await b?.close(); host.stop(); }
|
||||
|
||||
}
|
||||
|
||||
console.log('--- F08 long draft in a small window');
|
||||
{
|
||||
const host = await startHost(47872);
|
||||
let b;
|
||||
try {
|
||||
const tag = 'run';
|
||||
b = await launch();
|
||||
for (const [w, h] of [[600, 400], [800, 500], [1280, 720]]) {
|
||||
const p = await b.newPage({ viewport: { width: w, height: h } });
|
||||
await p.goto(host.url, { waitUntil: 'domcontentloaded' });
|
||||
await p.waitForTimeout(2500);
|
||||
await p.keyboard.press('Escape');
|
||||
const box = p.locator('.composer textarea').first();
|
||||
await box.waitFor({ timeout: 8000 });
|
||||
await box.click();
|
||||
const long = Array.from({ length: 60 }, (_, i) => `Line ${i + 1} of a long draft with Unicode ČŠŽ`).join('\n');
|
||||
await box.fill(long);
|
||||
await p.waitForTimeout(500);
|
||||
const m = await p.evaluate(() => {
|
||||
const r = el => { const b = el?.getBoundingClientRect(); return b ? { top: Math.round(b.top), bottom: Math.round(b.bottom), height: Math.round(b.height) } : null; };
|
||||
const send = [...document.querySelectorAll('.composer button')].find(x => /send/i.test(x.getAttribute('aria-label') || x.title || x.textContent || ''));
|
||||
return { vh: innerHeight, ta: r(document.querySelector('.composer textarea')), send: r(send), bar: r(document.querySelector('.composer .st-composer__bar')), composer: r(document.querySelector('.composer')), taScroll: document.querySelector('.composer textarea').scrollHeight };
|
||||
});
|
||||
console.log(w + 'x' + h, JSON.stringify(m));
|
||||
check(m.composer.bottom <= m.vh && m.bar.top >= 0, `${w}x${h}: the composer toolbar is inside the window`);
|
||||
check(m.send ? m.send.bottom <= m.vh : true, `${w}x${h}: the Send button is inside the window`);
|
||||
await p.screenshot({ path: `${SHOTS}/f08-${tag}-${w}x${h}.png` });
|
||||
await p.close();
|
||||
}
|
||||
} finally { await b?.close(); host.stop(); }
|
||||
|
||||
}
|
||||
|
||||
console.log('--- item 1 drop pictures on the chat');
|
||||
{
|
||||
const host = await startHost(47874);
|
||||
let b;
|
||||
try {
|
||||
const tag = 'run';
|
||||
b = await launch();
|
||||
const p = await (await b.newContext({ viewport: { width: 1280, height: 800 } })).newPage();
|
||||
await p.goto(host.url, { waitUntil: 'domcontentloaded' });
|
||||
await p.waitForTimeout(2500);
|
||||
await p.keyboard.press('Escape');
|
||||
await p.locator('.composer textarea').first().waitFor({ timeout: 8000 });
|
||||
const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
||||
const fire = (selector, type) => p.evaluate(([sel, type, png]) => {
|
||||
const target = document.querySelector(sel);
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([Uint8Array.from(atob(png), c => c.charCodeAt(0))], 'dropped-picture.png', { type: 'image/png' }));
|
||||
target.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }));
|
||||
return true;
|
||||
}, [selector, type, PNG]);
|
||||
const listened = await p.evaluate(() => { window.__dropped = []; window.addEventListener('strata:drop-files', e => window.__dropped.push(e.detail.map(f => f.name))); return true; });
|
||||
// over the message box
|
||||
await fire('.composer textarea', 'dragenter'); await fire('.composer textarea', 'dragover');
|
||||
await p.waitForTimeout(500);
|
||||
check(await p.locator('.dropzone').count() === 1, 'dragging over the message box shows the drop box');
|
||||
const anim = await p.evaluate(() => getComputedStyle(document.querySelector('.dropzone')).animationName);
|
||||
check(/dropzone-in/.test(anim), 'the drop box is animated: ' + anim);
|
||||
await p.screenshot({ path: `${SHOTS}/drop-${tag}-over.png` });
|
||||
await fire('.composer textarea', 'drop');
|
||||
await p.waitForTimeout(500);
|
||||
check(await p.locator('.dropzone').count() === 0, 'the drop box goes away after the drop');
|
||||
check(JSON.stringify(await p.evaluate(() => window.__dropped)) === '[["dropped-picture.png"]]', 'dropping on the message box hands the picture to the composer');
|
||||
// over the conversation area
|
||||
await p.evaluate(() => { window.__dropped = []; });
|
||||
await fire('.main__body.chat', 'dragenter'); await fire('.main__body.chat', 'dragover');
|
||||
await p.waitForTimeout(300);
|
||||
check(await p.locator('.dropzone').count() === 1, 'dragging over the conversation shows the drop box');
|
||||
await fire('.main__body.chat', 'drop');
|
||||
await p.waitForTimeout(500);
|
||||
check(JSON.stringify(await p.evaluate(() => window.__dropped)) === '[["dropped-picture.png"]]', 'dropping on the conversation hands the picture to the composer');
|
||||
await p.screenshot({ path: `${SHOTS}/drop-${tag}-after.png` });
|
||||
} finally { await b?.close(); host.stop(); }
|
||||
|
||||
}
|
||||
|
||||
console.log('--- O02 Monitor screenshot');
|
||||
{
|
||||
const host = await startHost(47873);
|
||||
let b;
|
||||
try {
|
||||
const tag = 'run';
|
||||
b = await launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 1280, height: 800 }, acceptDownloads: true });
|
||||
const p = await ctx.newPage();
|
||||
const problems = [];
|
||||
p.on('pageerror', e => problems.push('pageerror ' + e.message));
|
||||
p.on('console', m => { if (m.type() === 'error') problems.push('console ' + m.text()); });
|
||||
await p.goto(host.url + '#/monitor', { waitUntil: 'domcontentloaded' });
|
||||
await p.waitForTimeout(2500);
|
||||
await p.keyboard.press('Escape');
|
||||
await p.getByRole('link', { name: /Monitor/ }).first().click().catch(() => {});
|
||||
await p.waitForTimeout(1500);
|
||||
const btn = p.getByRole('button', { name: 'Save Monitor screenshot' });
|
||||
check(await btn.count() > 0, 'the Save Monitor screenshot button is there');
|
||||
await btn.click();
|
||||
await p.waitForTimeout(4000);
|
||||
await p.screenshot({ path: `${SHOTS}/o02-${tag}-1.png` });
|
||||
const state = await p.evaluate(() => ({ dialog: document.querySelector('.dialog-surface h2')?.textContent, img: !!document.querySelector('.snapshot-preview img'), text: document.querySelector('.snapshot-preview')?.textContent?.slice(0, 120), toast: [...document.querySelectorAll('[role=status],[role=alert]')].map(x => x.textContent.slice(0, 80)) }));
|
||||
console.log(JSON.stringify(state), problems.slice(0, 3));
|
||||
check(state.dialog === 'Monitor screenshot' && state.img, 'a preview of the screenshot opens');
|
||||
const dl = p.waitForEvent('download', { timeout: 8000 }).catch(() => null);
|
||||
await p.getByRole('button', { name: 'Download' }).click().catch(() => {});
|
||||
const d = await dl;
|
||||
check(!!d, 'Download gives a file: ' + (d ? d.suggestedFilename() : 'none'));
|
||||
await p.waitForTimeout(800);
|
||||
await p.screenshot({ path: `${SHOTS}/o02-${tag}-2.png` });
|
||||
console.log('toasts', JSON.stringify(await p.evaluate(() => [...document.querySelectorAll('.st-toast, [class*=toast], [role=status]')].map(x => x.textContent.slice(0, 100)))));
|
||||
} finally { await b?.close(); host.stop(); }
|
||||
|
||||
}
|
||||
|
||||
console.log(checks.every(Boolean) ? 'ALL PASS' : 'SOME FAILED', checks.filter(Boolean).length + '/' + checks.length);
|
||||
process.exitCode = checks.every(Boolean) ? 0 : 1;
|
||||
@@ -16,6 +16,8 @@
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { GatewayClient } from './gateway-client.mjs';
|
||||
import { HarnessProcess, resolveHarness, redact, stopStaleAgent } from './harness-runtime.mjs';
|
||||
@@ -73,6 +75,9 @@ export function imageParts(images) {
|
||||
|
||||
/** A timer that never keeps the process alive by itself. */
|
||||
const after = (ms, fn) => { const t = setTimeout(fn, ms); t.unref?.(); return t; };
|
||||
/** The app's own host folder: never a place an answer's files can be (this file lives in <host>/server/agent). */
|
||||
const HOST_DIR = fileURLToPath(new URL('../..', import.meta.url));
|
||||
|
||||
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; };
|
||||
|
||||
/**
|
||||
@@ -91,6 +96,18 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
const sessionsFile = join(root, 'sessions.json');
|
||||
mkdirSync(root, { recursive: true });
|
||||
|
||||
/**
|
||||
* Where the turn's file tracker must never look (deliverables.mjs): the app, the engine and its components, Strata's own data, AppData
|
||||
* and temp. `forbid` ones also switch tracking off when the chat's folder is one of them or inside one.
|
||||
*/
|
||||
function trackerFolders() {
|
||||
const user = env.USERPROFILE ?? env.HOME ?? homedir();
|
||||
const forbid = [HOST_DIR, componentsDir, bundledDir].filter(Boolean);
|
||||
const exclude = [dataDir, tmpdir(), env.LOCALAPPDATA, env.APPDATA, env.ProgramData, env.ProgramFiles, env['ProgramFiles(x86)'], env.TEMP, env.TMP,
|
||||
join(user, 'AppData'), join(user, '.cache'), join(user, '.config'), join(user, '.local'), join(user, 'Library')].filter(Boolean);
|
||||
return { forbid, exclude };
|
||||
}
|
||||
|
||||
/** chatId -> { sessionId, cwd, createdAt } */
|
||||
let sessionMap = {};
|
||||
if (existsSync(sessionsFile)) {
|
||||
@@ -296,7 +313,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
const written = { all: '', step: '' };
|
||||
const raw = frame => { try { send(frame); } catch { send = () => {}; attached = false; } };
|
||||
// What the turn made (files) and compact activity lines (notes-split-agent8.md): derived from the cards the turn already sends.
|
||||
const tracker = new DeliverableTracker(workspacePath);
|
||||
const tracker = new DeliverableTracker(workspacePath, trackerFolders());
|
||||
let toolsRan = false; // a turn that ran no tool wrote nothing, so there is nothing to look for at its end
|
||||
try { tracker.snapshot(); } catch { /* a folder that cannot be read has no deliverables */ }
|
||||
const groups = new ActivityGroups({ emit: raw });
|
||||
const skills = new SkillsUsed({ emit: raw, info: skillInfoFrom(skillsDir()) });
|
||||
@@ -305,6 +323,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
if (frame.own) { const { own, ...rest } = frame; frame = rest; }
|
||||
if (frame.type === 'delta' && frame.text) groups.text();
|
||||
if (frame.type === 'agent-tool' && frame.tool) {
|
||||
if (['done', 'error'].includes(frame.tool.state) && !['read', 'read_image', 'glob', 'grep', 'web_fetch', 'web_search'].includes(frame.tool.name)) toolsRan = true;
|
||||
let made = null;
|
||||
if (frame.tool.state === 'done' && (frame.tool.name === 'write' || frame.tool.name === 'edit')) { try { made = tracker.check(frame.tool.args?.file_path ?? frame.tool.args?.path); } catch { made = null; } }
|
||||
raw(frame);
|
||||
@@ -390,7 +409,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
if (turn.stopping && !failure) projector.ended = { kind: 'aborted', reason: { kind: 'user' } };
|
||||
reporter.clear();
|
||||
const extra = [];
|
||||
try { for (const d of tracker.sweep()) { emit(d); groups.addFile(d); } } catch { /* never blocks the end of a turn */ }
|
||||
try { if (toolsRan) for (const d of tracker.sweep()) { emit(d); groups.addFile(d); } } catch { /* never blocks the end of a turn */ }
|
||||
groups.end();
|
||||
if (researchTopic && !failure && !turn.stopping) { const sources = parseSources(written.all); if (sources.length) extra.push({ type: 'research', topic: researchTopic, sources }); }
|
||||
try { extra.push(...settlePlan(failure)); } catch { /* a plan frame must never break the end of a turn */ }
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { basename, extname, join, relative, resolve, sep } from 'node:path';
|
||||
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
|
||||
const SKIP_DIR = /^(?:\.|node_modules$|__pycache__$|\$)/;
|
||||
const MAX_FILES = 4000, MAX_TEXT = 200_000, MAX_TOTAL = 24_000_000, BUDGET_MS = 1500;
|
||||
@@ -40,24 +40,47 @@ export function lineDelta(before, after) {
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
/** A file made or changed this long before the turn started still counts (clock rounding on some file systems). */
|
||||
const START_SLACK_MS = 2000;
|
||||
const same = process.platform === 'win32' || process.platform === 'darwin' ? p => p.toLowerCase() : p => p;
|
||||
/** True when `child` is `parent` or inside it. */
|
||||
export function isWithin(parent, child) {
|
||||
const rel = relative(same(resolve(parent)), same(resolve(child)));
|
||||
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
export class DeliverableTracker {
|
||||
constructor(root, { now = Date.now } = {}) {
|
||||
/**
|
||||
* `exclude`: folders that are never searched when they lie inside the chat's folder (the app, the engine and its components, AppData,
|
||||
* temp). `forbid`: folders the chat's own folder must not be, or be inside (the app, the engine and its components): then nothing is
|
||||
* tracked at all. The turn only reports files the turn itself wrote: an explicit file-tool call, or (for scripts) a file whose modified
|
||||
* or creation time is after the turn started.
|
||||
*/
|
||||
constructor(root, { now = Date.now, exclude = [], forbid = [] } = {}) {
|
||||
this.root = root ? resolve(root) : null;
|
||||
this.now = now;
|
||||
if (this.root && forbid.filter(Boolean).some(p => isWithin(p, this.root))) this.root = null;
|
||||
// only the folders that lie inside the chat's folder matter for the search
|
||||
this.exclude = this.root ? [...exclude, ...forbid].filter(Boolean).map(p => resolve(p)).filter(p => p !== this.root && isWithin(this.root, p)) : [];
|
||||
this.startedAt = Date.now();
|
||||
this.before = new Map(); // rel path -> { size, mtimeMs, lines|null }
|
||||
this.sent = new Map(); // rel path -> signature of what was last reported
|
||||
this.truncated = false;
|
||||
}
|
||||
|
||||
#snapshotting = false;
|
||||
/** A folder inside the chat's folder that is never searched. */
|
||||
#excluded(full) { return this.exclude.some(p => isWithin(p, full)); }
|
||||
|
||||
#walk(fn) {
|
||||
if (!this.root) return;
|
||||
const t0 = this.now(); let count = 0;
|
||||
const walk = dir => {
|
||||
let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const e of entries) {
|
||||
if (count >= MAX_FILES || this.now() - t0 > BUDGET_MS) { this.truncated = true; return; }
|
||||
if (count >= MAX_FILES || this.now() - t0 > BUDGET_MS) { this.truncated = true; if (this.#snapshotting) this.snapshotTruncated = true; return; }
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) { if (!SKIP_DIR.test(e.name)) walk(full); }
|
||||
if (e.isDirectory()) { if (!SKIP_DIR.test(e.name) && !this.#excluded(full)) walk(full); }
|
||||
else if (e.isFile()) { count += 1; fn(full); }
|
||||
}
|
||||
};
|
||||
@@ -71,12 +94,13 @@ export class DeliverableTracker {
|
||||
|
||||
/** Remember what the folder holds now. */
|
||||
snapshot() {
|
||||
this.before.clear(); this.bytes = 0;
|
||||
this.before.clear(); this.bytes = 0; this.startedAt = Date.now(); this.snapshotTruncated = false; this.#snapshotting = true;
|
||||
this.#walk(full => {
|
||||
let st; try { st = statSync(full); } catch { return; }
|
||||
const rel = relative(this.root, full).split(sep).join('/');
|
||||
this.before.set(rel, { size: st.size, mtimeMs: st.mtimeMs, lines: this.#read(full, st.size, fileType(rel)) });
|
||||
});
|
||||
this.#snapshotting = false;
|
||||
}
|
||||
|
||||
/** The deliverable for one path now, compared with the snapshot (and the last report), or null when nothing changed. */
|
||||
@@ -84,7 +108,7 @@ export class DeliverableTracker {
|
||||
if (!this.root) return null;
|
||||
const full = resolve(this.root, String(rawPath ?? ''));
|
||||
const rel = relative(this.root, full).split(sep).join('/');
|
||||
if (!rel || rel.startsWith('..') || rel.split('/').some(seg => SKIP_DIR.test(seg))) return null;
|
||||
if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split('/').some(seg => SKIP_DIR.test(seg)) || this.#excluded(dirname(full))) return null;
|
||||
let st; try { st = statSync(full); } catch { return null; }
|
||||
if (!st.isFile()) return null;
|
||||
const old = this.before.get(rel);
|
||||
@@ -102,10 +126,21 @@ export class DeliverableTracker {
|
||||
return { type: 'deliverable', path: rel, abs: full, name: basename(rel), kind: type, status: old ? 'changed' : 'created', size: st.size, lines: lines ? lines.length : null, added, removed };
|
||||
}
|
||||
|
||||
/** Files the tools did not announce (a script wrote them): everything new or changed since the snapshot and not yet reported. */
|
||||
/**
|
||||
* Files the tools did not announce (a script wrote them): what was made or changed since the snapshot and not yet reported. A file only
|
||||
* counts when its modified or creation time is after the turn started, so a folder the snapshot could not finish (it stops after
|
||||
* MAX_FILES files or BUDGET_MS) never turns the rest of the folder into "new files" (the 308 CUDA headers of the QA report).
|
||||
*/
|
||||
sweep() {
|
||||
const out = [];
|
||||
this.#walk(full => { const d = this.check(relative(this.root, full)); if (d) out.push(d); });
|
||||
const since = this.startedAt - START_SLACK_MS;
|
||||
this.#walk(full => {
|
||||
let st; try { st = statSync(full); } catch { return; }
|
||||
// modified during the turn; or created during it (a copy that kept its old modified time), which is only certain when the first look saw the whole folder
|
||||
const fresh = st.mtimeMs >= since || (!this.snapshotTruncated && (st.birthtimeMs || 0) >= since);
|
||||
if (!fresh) return;
|
||||
const d = this.check(relative(this.root, full)); if (d) out.push(d);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function validate(draft = {}) {
|
||||
if (typeof beginner.readImages !== 'boolean' || beginner.readImages) addError('readImages', 'image input is not supported by this host.');
|
||||
const limits = {
|
||||
'sample.temp': [0, 2], 'sample.topP': [0, 1], 'sample.topK': [0, 10000], 'sample.repeat': [0.5, 3],
|
||||
'sample.seed': [-1, 2147483647], 'perf.threads': [1, 1024], 'perf.batch': [1, 65536],
|
||||
'sample.seed': [-1, 2147483647], 'sample.maxTokens': [0, 1048576], 'perf.threads': [1, 1024], 'perf.batch': [1, 65536],
|
||||
'perf.draftDepth': [1, 64], 'mem.expertCache': [0, 10000], 'server.port': [1, 65535],
|
||||
'server.parallel': [1, 64], 'server.queue': [0, 10000], 'server.timeout': [1, 86400],
|
||||
'adv.rope': [0.01, 100], 'adv.ropeBase': [1, 1000000000],
|
||||
|
||||
@@ -23,6 +23,8 @@ export function windowsFolderScript(prompt) {
|
||||
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
||||
`$dialog.Description = '${text}'`,
|
||||
'$dialog.ShowNewFolderButton = $true',
|
||||
// READY tells the host the window is about to open; a host that never sees it gives up and asks for the path in words (QA F06).
|
||||
'[Console]::Out.WriteLine("READY"); [Console]::Out.Flush()',
|
||||
'if ($dialog.ShowDialog($owner) -eq "OK") { [Console]::Out.Write($dialog.SelectedPath) }',
|
||||
'$owner.Close()',
|
||||
].join('; ');
|
||||
@@ -42,11 +44,33 @@ export function cleanChosenPath(value) {
|
||||
|
||||
const missing = error => error?.code === 'ENOENT';
|
||||
|
||||
/** What the script printed, without its READY line. */
|
||||
const answerOf = stdout => String(stdout ?? '').split(/\r?\n/).filter(line => line.trim() && line.trim() !== 'READY').join('\n');
|
||||
|
||||
/**
|
||||
* Runs the Windows dialog and watches it. Resolves { stdout } when it ends, { noDialog: true } when its window never got as far as
|
||||
* opening (no READY within `readyMs`: PowerShell blocked, stuck or without a desktop), and rejects with { code } as exec does.
|
||||
* The person's own time with the open dialog is not limited (a day at most).
|
||||
*/
|
||||
export function watchedWindowsDialog(shell, args, { readyMs = 30_000, openMs = 24 * 3600_000, spawnProcess = spawn } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnProcess(shell, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
let stdout = '', ready = false, settled = false;
|
||||
const end = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill(); } catch { /* gone */ } fn(value); };
|
||||
const timer = setTimeout(() => end(resolve, ready ? { stdout: '' } : { noDialog: true }), readyMs);
|
||||
const later = () => { clearTimeout(timer); ready = true; setTimeout(() => end(resolve, { stdout: '' }), openMs).unref?.(); };
|
||||
child.stdout?.on('data', chunk => { stdout += chunk; if (!ready && /^READY\s*$/m.test(stdout)) { clearTimeout(timer); later(); } });
|
||||
child.on('error', error => end(reject, error));
|
||||
child.on('close', code => { if (settled) return; settled = true; clearTimeout(timer); code === 0 ? resolve({ stdout }) : reject(Object.assign(new Error('exit ' + code), { code })); });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a folder. Resolves { path } | { cancelled: true } | { unavailable: true, reason }.
|
||||
* `run` is injectable for tests: (command, args) => Promise<{ stdout }>.
|
||||
*/
|
||||
export async function chooseFolder(prompt, { platform = process.platform, run = exec, portal = portalFolder } = {}) {
|
||||
export async function chooseFolder(prompt, options = {}) {
|
||||
const { platform = process.platform, run = exec, portal = portalFolder, windowsDialog = options.run ? null : watchedWindowsDialog } = options;
|
||||
if (platform === 'darwin') {
|
||||
try {
|
||||
const { stdout } = await run('osascript', ['-e', MAC_SCRIPT(prompt)]);
|
||||
@@ -58,8 +82,10 @@ export async function chooseFolder(prompt, { platform = process.platform, run =
|
||||
if (platform === 'win32') {
|
||||
const shell = `${process.env.SystemRoot || 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
|
||||
try {
|
||||
const { stdout } = await run(shell, windowsFolderArgs(prompt));
|
||||
return stdout.trim() ? { path: cleanChosenPath(stdout) } : { cancelled: true };
|
||||
const done = windowsDialog ? await windowsDialog(shell, windowsFolderArgs(prompt)) : await run(shell, windowsFolderArgs(prompt));
|
||||
if (done.noDialog) return { unavailable: true, failed: true, reason: 'The folder window did not open. Type the folder path instead.' };
|
||||
const answer = answerOf(done.stdout).trim();
|
||||
return answer ? { path: cleanChosenPath(answer) } : { cancelled: true };
|
||||
} catch (error) {
|
||||
if (missing(error)) return { unavailable: true, reason: 'Windows PowerShell was not found.' };
|
||||
throw new Error('The folder chooser could not open.');
|
||||
|
||||
@@ -1531,12 +1531,22 @@ async function api(req, res, path, method, url) {
|
||||
const instructions = String(body.body ?? '').trim();
|
||||
if (!name) throw new Error('Give the skill a name.');
|
||||
if (!instructions) throw new Error('Write the instructions the assistant should follow.');
|
||||
// The form's own choices are kept: a skill created with "Skill enabled" off stays off (QA F04), and its trigger phrases are saved with it.
|
||||
const triggers = body.triggers === undefined ? [] : skillEdit({ triggers: body.triggers }).triggers;
|
||||
const id = writeCreatedSkill(CREATED_SKILLS, { name, description: String(body.description ?? '').trim().slice(0, 400), body: instructions.slice(0, 40000) });
|
||||
const newId = `my-${id}`, off = body.enabled === false;
|
||||
if (off || triggers.length) {
|
||||
const saved = store.get();
|
||||
store.update({
|
||||
...(off ? { skillPreferences: { ...saved.skillPreferences, [newId]: { enabled: false, onlyIn: null } } } : {}),
|
||||
...(triggers.length ? { skillEdits: { ...saved.skillEdits, [newId]: { ...saved.skillEdits?.[newId], triggers } } } : {}),
|
||||
});
|
||||
}
|
||||
const before = new Map(skillState.map(s => [s.id, s]));
|
||||
skillState = skills().map(s => before.has(s.id) ? { ...s, enabled: before.get(s.id).enabled, onlyIn: before.get(s.id).onlyIn ?? null, useCount: before.get(s.id).useCount, lastUsedAt: before.get(s.id).lastUsedAt } : { ...s, onlyIn: null, useCount: 0, lastUsedAt: null });
|
||||
skillState = skills().map(s => before.has(s.id) ? { ...s, enabled: before.get(s.id).enabled, onlyIn: before.get(s.id).onlyIn ?? null, useCount: before.get(s.id).useCount, lastUsedAt: before.get(s.id).lastUsedAt } : { ...s, enabled: s.id === newId && off ? false : s.enabled, onlyIn: null, useCount: 0, lastUsedAt: null });
|
||||
syncAgentSkills();
|
||||
hub.send('skills', skillState);
|
||||
return json(res, 201, { id: `my-${id}`, skills: skillState });
|
||||
return json(res, 201, { id: newId, skills: skillState });
|
||||
} catch (e) { return error(res, 400, e.message); }
|
||||
}
|
||||
const skillDelete = /^\/api\/skills\/([^/]+)$/.exec(path);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { tr, localeTag } from '../lib/i18n';
|
||||
import { checkWholeNumber, numberProblem } from '../lib/number-field';
|
||||
/** Response defaults used for the next chat or API request. */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Btn, Dialog } from '../components/ui';
|
||||
@@ -11,16 +12,23 @@ export function SamplingDrawer({ open, onClose }: {
|
||||
const modelId = useStore(s => s.library?.loaded);
|
||||
const [tab, setTab] = useState<'sampling' | 'advanced'>('sampling');
|
||||
const [local, setLocal] = useState<Record<string, number | boolean>>({});
|
||||
// What was typed in a number field, kept as text so a half-typed or wrong number can be said so in words (the save stays off until it is right).
|
||||
const [typed, setTyped] = useState<Record<string, string>>({});
|
||||
const [pending, setPending] = useState(false), [error, setError] = useState('');
|
||||
useEffect(() => { if (open) {
|
||||
setLocal({});
|
||||
setTyped({});
|
||||
setError('');
|
||||
setTab('sampling');
|
||||
} }, [open, modelId]);
|
||||
const value = (id: string) => local[id] ?? engine?.expert.find(p => p.id === id)?.value ?? 0;
|
||||
const dirty = Object.entries(local).some(([id, v]) => v !== engine?.expert.find(p => p.id === id)?.value);
|
||||
const numberFields = [{ id: 'sample.seed', label: 'Seed', min: -1, max: 2147483647 }, { id: 'sample.maxTokens', label: 'Max output tokens', min: 0, max: 1048576 }];
|
||||
const formatNumber = (n: number) => n.toLocaleString(localeTag());
|
||||
const problemOf = (field: { id: string; min: number; max: number }) => typed[field.id] === undefined ? null : numberProblem(checkWholeNumber(typed[field.id], field.min, field.max), field.min, field.max, formatNumber);
|
||||
const invalid = numberFields.some(field => problemOf(field));
|
||||
async function save() {
|
||||
if (!dirty || pending)
|
||||
if (!dirty || pending || invalid)
|
||||
return;
|
||||
setPending(true);
|
||||
setError('');
|
||||
@@ -37,6 +45,7 @@ export function SamplingDrawer({ open, onClose }: {
|
||||
}
|
||||
function reset() {
|
||||
setLocal(Object.fromEntries((engine?.expert ?? []).filter(p => p.id.startsWith('sample.')).map(p => [p.id, p.type === 'toggle' ? Boolean(p.recommended) : Number(p.recommended)])));
|
||||
setTyped({});
|
||||
setError('');
|
||||
}
|
||||
function slider(id: string, label: string, min: number, max: number, step: number, hint: string) {
|
||||
@@ -46,7 +55,7 @@ export function SamplingDrawer({ open, onClose }: {
|
||||
<p className="small muted" id={`response-hint-${id}`}>{tr(hint)}</p>
|
||||
</div>;
|
||||
}
|
||||
return <Dialog open={open} title={tr("Response settings")} onClose={() => !pending && onClose()} actions={<><Btn disabled={pending || !engine} onClick={reset}>{tr("Reset defaults")}</Btn><Btn disabled={pending} onClick={onClose}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !dirty} onClick={() => void save()}>{tr(pending ? 'Saving…' : 'Save changes')}</Btn></>}>
|
||||
return <Dialog open={open} title={tr("Response settings")} onClose={() => !pending && onClose()} actions={<><Btn disabled={pending || !engine} onClick={reset}>{tr("Reset defaults")}</Btn><Btn disabled={pending} onClick={onClose}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !dirty || invalid} onClick={() => void save()}>{tr(pending ? 'Saving…' : 'Save changes')}</Btn></>}>
|
||||
<div className="editor-tabs" role="tablist" aria-label={tr("Response settings sections")}>
|
||||
{tr((['sampling', 'advanced'] as const).map(t => <button key={t} id={`response-tab-${t}`} role="tab" aria-selected={tab === t} aria-controls={`response-panel-${t}`} tabIndex={tab === t ? 0 : -1} onClick={() => setTab(t)} onKeyDown={e => { if (['ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
@@ -62,7 +71,7 @@ export function SamplingDrawer({ open, onClose }: {
|
||||
{tr(slider('sample.topK', 'Top-k', 0, 200, 1, 'Candidate tokens. Zero removes this limit.'))}
|
||||
</div> : <div className="stack">
|
||||
{tr(slider('sample.repeat', 'Repetition penalty', .5, 3, .01, 'Above 1 discourages repeated text.'))}
|
||||
<div className="sampling-number-grid">{tr([{ id: 'sample.seed', label: 'Seed', min: -1, max: 2147483647 }, { id: 'sample.maxTokens', label: 'Max output tokens', min: 0, max: 1048576 }].map(field => <label className="input-field" key={field.id}><span>{tr(field.label)}</span><input className="st-input" type="number" min={field.min} max={field.max} step={1} disabled={pending || !engine} value={Number(value(field.id))} onChange={e => setLocal(prev => ({ ...prev, [field.id]: Number(e.target.value) }))}/><small className="muted">{tr(field.id === 'sample.seed' ? '−1 chooses a random seed.' : '0 uses the engine default.')}</small></label>))}</div>
|
||||
<div className="sampling-number-grid">{tr(numberFields.map(field => { const problem = problemOf(field); const note = problem ? tr(problem.text, problem.args) : null; return <label className="input-field" key={field.id}><span>{tr(field.label)}</span><input className="st-input" type="number" inputMode="numeric" min={field.min} max={field.max} step={1} disabled={pending || !engine} value={typed[field.id] ?? String(Number(value(field.id)))} aria-invalid={problem ? true : undefined} aria-describedby={`response-note-${field.id}`} onChange={e => { const text = e.target.value; setTyped(prev => ({ ...prev, [field.id]: text })); const check = checkWholeNumber(text, field.min, field.max); if (check.ok) setLocal(prev => ({ ...prev, [field.id]: check.value })); }}/><small id={`response-note-${field.id}`} className={problem ? 'field-problem' : 'muted'} role={problem ? 'alert' : undefined}>{note ?? tr(field.id === 'sample.seed' ? '−1 chooses a random seed.' : '0 uses the engine default.')}</small></label>; }))}</div>
|
||||
<div className="response-projection"><div><strong>{tr("Speed projection")}</strong><p className="small muted">{tr("Experimental control vector. Requires a compatible engine with the vector loaded.")}</p></div><button className="st-toggle" role="switch" aria-label={tr("Speed projection")} aria-checked={Boolean(value('sample.speedProjection'))} disabled={pending || !engine} onClick={() => setLocal(prev => ({ ...prev, 'sample.speedProjection': !Boolean(value('sample.speedProjection')) }))}/></div>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
@@ -89,13 +89,13 @@ export function Sidebar({ containerRef }: {
|
||||
const rows = matching.filter(s => s.workspace === w.path || !!w.aliases?.includes(s.workspace));
|
||||
const shown = q ? rows : rows.slice(0, 20);
|
||||
return <div className="ws__group" key={w.id}>
|
||||
<div className={cx('ws__row', activeWorkspace === w.id && 'is-active')}><button className="ws__name" aria-expanded={expanded} onClick={() => { setActiveWorkspace(w.id); setFolders(f => ({ ...f, [w.id]: !expanded })); }}><Icon name="chevron" size={14} className={cx('ws__chev', expanded && 'is-open')}/><Icon name="folder" size={17}/><span>{w.name}</span></button><IconBtn name="pencil" title={tr("Rename {0}", [w.name])} onClick={() => { setWsName(w.name); setRenameWsId(w.id); }}/>{tr(w.id !== 'root' ? <IconBtn name="x" title={tr("Remove {0} from sidebar", [w.name])} onClick={() => setRemoveWorkspaceId(w.id)}/> : null)}</div>
|
||||
<div className={cx('ws__row', activeWorkspace === w.id && 'is-active')}><button className="ws__name" aria-expanded={expanded} onClick={() => { setActiveWorkspace(w.id); setFolders(f => ({ ...f, [w.id]: !expanded })); }}><Icon name="chevron" size={14} className={cx('ws__chev', expanded && 'is-open')}/><Icon name="folder" size={17}/><span>{w.name}</span></button><IconBtn name="pencil" title={tr("Rename {0}", [w.name])} onClick={() => { setWsName(w.name); setNavOpen(false); setRenameWsId(w.id); }}/>{tr(w.id !== 'root' ? <IconBtn name="x" title={tr("Remove {0} from sidebar", [w.name])} onClick={() => { setNavOpen(false); setRemoveWorkspaceId(w.id); }}/> : null)}</div>
|
||||
{tr(expanded ? <div className="navsessions">
|
||||
{tr(shown.map(s => <div key={s.id}>
|
||||
<div className="ws__session row"><a className="navsession" href={href('chat', s.id)} aria-current={activeSession === s.id && location.route === 'chat' ? 'page' : undefined} onClick={() => selectSession(s.id)} onContextMenu={e => { e.preventDefault(); openMenu(s.id, e.currentTarget); }} title={sessionTitle(s)}><span className="navsession__title">{sessionTitle(s)}</span>{tr(s.pinned ? <span aria-label={tr("Pinned")}><Icon name="pin" size={12}/></span> : <span className="navsession__time">{tr(relTime(s.updatedAt))}</span>)}{tr(isUnread(s, activeSession) ? <span className="navsession__unread" aria-label={tr("Unread")}/> : null)}{tr(s.archived ? <span className="xs muted">{tr("Archived")}</span> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Actions for {0}", [sessionTitle(s)])} onClick={e => openMenu(s.id, e.currentTarget)}><Icon name="ellipsis" size={16}/></button></div>
|
||||
</div>))}
|
||||
{tr(!shown.length ? <p className="small muted ws__empty">{tr(q ? 'No matching chats' : 'No chats yet')}</p> : null)}
|
||||
{tr(!q && rows.length > shown.length ? <button className="ws-view small" onClick={() => setRecentOpen(true)}>{tr("View all chats")}</button> : null)}
|
||||
{tr(!q && rows.length > shown.length ? <button className="ws-view small" onClick={() => { setNavOpen(false); setRecentOpen(true); }}>{tr("View all chats")}</button> : null)}
|
||||
</div> : null)}
|
||||
</div>;
|
||||
})}
|
||||
@@ -110,9 +110,9 @@ export function Sidebar({ containerRef }: {
|
||||
</div>
|
||||
</aside>
|
||||
{tr(menuId && menuSession ? <FloatingMenu anchor={menuTrigger.current} title={tr("Actions for {0}", [sessionTitle(menuSession)])} onClose={closeMenu}>
|
||||
<button role="menuitem" className="menu__item" onClick={() => { setName(sessionTitle(menuSession)); setRenameId(menuId); setMenuId(null); }}><Icon name="pencil"/>{tr("Rename")}</button>
|
||||
<button role="menuitem" className="menu__item" onClick={() => { setName(sessionTitle(menuSession)); setNavOpen(false); setRenameId(menuId); setMenuId(null); }}><Icon name="pencil"/>{tr("Rename")}</button>
|
||||
<button role="menuitem" className="menu__item" onClick={() => { togglePin(menuId); closeMenu(); }}><Icon name="pin"/>{tr(menuSession.pinned ? 'Unpin' : 'Pin')}</button>
|
||||
<button role="menuitem" className="menu__item menu__item--danger" onClick={() => { setDeleteId(menuId); setMenuId(null); }}><Icon name="trash"/>{tr("Delete chat")}</button>
|
||||
<button role="menuitem" className="menu__item menu__item--danger" onClick={() => { setNavOpen(false); setDeleteId(menuId); setMenuId(null); }}><Icon name="trash"/>{tr("Delete chat")}</button>
|
||||
</FloatingMenu> : null)}
|
||||
<Dialog open={!!renameId} compact title={tr("Rename chat")} onClose={() => setRenameId(null)} actions={<><Btn onClick={() => setRenameId(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!name.trim()} onClick={() => {
|
||||
if (renameId)
|
||||
@@ -190,5 +190,5 @@ function MoreMenu({ current }: {
|
||||
const [quitOpen, setQuitOpen] = useState(false);
|
||||
const appMode = useAppMode();
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
return <><button ref={ref} className="navitem nav__more" aria-label={tr("More — Serving and Settings")} aria-haspopup="menu" aria-expanded={open} data-active={['serving', 'settings'].includes(current) || undefined} onClick={() => setOpen(v => !v)}><Icon name="ellipsis"/><span className="navitem__text">{tr("More")}</span></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("More")} onClose={() => { setOpen(false); ref.current?.focus(); }}>{tr(([{ route: 'serving', label: 'Serving', icon: 'link' }, { route: 'settings', label: 'Settings', icon: 'settings' }] as const).map(d => <a key={d.route} role="menuitem" className="menu__item" href={href(d.route)} onClick={() => setOpen(false)}><Icon name={d.icon}/>{tr(d.label)}</a>))}{appMode.canQuit ? <button type="button" role="menuitem" className="menu__item" onClick={() => { setOpen(false); setQuitOpen(true); }}><Icon name="x"/>{tr("Quit Strata")}</button> : null}</FloatingMenu> : null)}<QuitStrataDialog open={quitOpen} onClose={() => setQuitOpen(false)}/></>;
|
||||
return <><button ref={ref} className="navitem nav__more" aria-label={tr("More — Serving and Settings")} aria-haspopup="menu" aria-expanded={open} data-active={['serving', 'settings'].includes(current) || undefined} onClick={() => setOpen(v => !v)}><Icon name="ellipsis"/><span className="navitem__text">{tr("More")}</span></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("More")} onClose={() => { setOpen(false); ref.current?.focus(); }}>{tr(([{ route: 'serving', label: 'Serving', icon: 'link' }, { route: 'settings', label: 'Settings', icon: 'settings' }] as const).map(d => <a key={d.route} role="menuitem" className="menu__item" href={href(d.route)} onClick={() => setOpen(false)}><Icon name={d.icon}/>{tr(d.label)}</a>))}{appMode.canQuit ? <button type="button" role="menuitem" className="menu__item" onClick={() => { setOpen(false); setNavOpen(false); setQuitOpen(true); }}><Icon name="x"/>{tr("Quit Strata")}</button> : null}</FloatingMenu> : null)}<QuitStrataDialog open={quitOpen} onClose={() => setQuitOpen(false)}/></>;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { api } from '../lib/client';
|
||||
import { navigate, href, useLocation } from '../lib/router';
|
||||
import { blankTask, type Task } from '../lib/tasks';
|
||||
import { TaskEditor } from '../components/TaskEditor';
|
||||
import { pushToast } from '../lib/store';
|
||||
import { pushToast, setNavOpen } from '../lib/store';
|
||||
type TaskLeaf = Task & {
|
||||
id: string;
|
||||
};
|
||||
@@ -67,12 +67,17 @@ export function TaskTree({ collapsed }: {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
if (collapsed)
|
||||
return null;
|
||||
return <section className="task-tree" aria-label={tr("Scheduled tasks")}>
|
||||
<div className="ws__head"><button className="navgroup__label task-tree__heading" aria-expanded={open} onClick={() => { setOpen(!open); localStorage.setItem('strata.tasks-open', String(!open)); }}>{tr("Tasks")}<Icon name="chevron" size={12} className={`ws__chev ${open ? 'is-open' : ''}`}/></button><div className="ws__tools"><IconBtn name="plus" title={tr("Add a task")} onClick={() => setEditor(blankTask())}/></div></div>
|
||||
{tr(open ? <div className="navsessions">{tr(tasks.map(t => <div className="ws__session row task-tree-row" data-leaving={leaving === t.id || undefined} key={t.id}><a className="navsession" href={href('tasks', t.id)} aria-current={location.route === 'tasks' && location.param === t.id ? 'page' : undefined} title={t.title}><span className="navsession__title">{t.title}</span>{tr(!t.enabled ? <Icon name="clock" size={12}/> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Edit task {0}", [t.title])} title={tr("Edit {0}", [t.title])} disabled={t.status === 'running'} onClick={() => setEditor({ ...t })}><Icon name="pencil" size={15}/></button><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Delete task {0}", [t.title])} disabled={t.status === 'running'} title={tr(t.status === 'running' ? 'Stop this task before deleting' : `Delete ${t.title}`)} onClick={() => { setRemove(t); setError(''); }}><Icon name="trash" size={15}/></button></div>))}{tr(!tasks.length ? <p className="small muted ws__empty">{tr("No tasks yet")}</p> : null)}{tr(error && !remove ? <p className="xs inline-error">{tr("Couldn\u2019t load tasks.")}</p> : null)}</div> : null)}
|
||||
// A dialog opened from the narrow-window menu closes the menu first (QA F03: the form opened behind it and the keyboard stayed there). The dialogs
|
||||
// live here, so they stay mounted while the menu is closed.
|
||||
const dialogs = <>
|
||||
{tr(editor ? <TaskEditor task={editor} onClose={() => setEditor(null)}/> : null)}
|
||||
<Dialog open={!!remove} compact title={tr("Remove this task?")} onClose={() => !busy && setRemove(null)} actions={<><Btn disabled={busy} onClick={() => setRemove(null)}>{tr("Keep task")}</Btn><Btn variant="danger" disabled={busy} onClick={() => void deleteTask()}>{tr(busy ? 'Removing…' : 'Remove task')}</Btn></>}><div className="stack stack--sm"><p><strong>“{remove?.title}”</strong>{tr(" will stop running and be removed.")}</p></div>{tr(error ? <p className="inline-error" role="alert">{tr(error)}</p> : null)}</Dialog>
|
||||
</>;
|
||||
if (collapsed)
|
||||
return dialogs;
|
||||
return <section className="task-tree" aria-label={tr("Scheduled tasks")}>
|
||||
<div className="ws__head"><button className="navgroup__label task-tree__heading" aria-expanded={open} onClick={() => { setOpen(!open); localStorage.setItem('strata.tasks-open', String(!open)); }}>{tr("Tasks")}<Icon name="chevron" size={12} className={`ws__chev ${open ? 'is-open' : ''}`}/></button><div className="ws__tools"><IconBtn name="plus" title={tr("Add a task")} onClick={() => { setNavOpen(false); setEditor(blankTask()); }}/></div></div>
|
||||
{tr(open ? <div className="navsessions">{tr(tasks.map(t => <div className="ws__session row task-tree-row" data-leaving={leaving === t.id || undefined} key={t.id}><a className="navsession" href={href('tasks', t.id)} aria-current={location.route === 'tasks' && location.param === t.id ? 'page' : undefined} title={t.title}><span className="navsession__title">{t.title}</span>{tr(!t.enabled ? <Icon name="clock" size={12}/> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Edit task {0}", [t.title])} title={tr("Edit {0}", [t.title])} disabled={t.status === 'running'} onClick={() => { setNavOpen(false); setEditor({ ...t }); }}><Icon name="pencil" size={15}/></button><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Delete task {0}", [t.title])} disabled={t.status === 'running'} title={tr(t.status === 'running' ? 'Stop this task before deleting' : `Delete ${t.title}`)} onClick={() => { setNavOpen(false); setRemove(t); setError(''); }}><Icon name="trash" size={15}/></button></div>))}{tr(!tasks.length ? <p className="small muted ws__empty">{tr("No tasks yet")}</p> : null)}{tr(error && !remove ? <p className="xs inline-error">{tr("Couldn\u2019t load tasks.")}</p> : null)}</div> : null)}
|
||||
{dialogs}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { pushToast } from '../lib/store';
|
||||
import { Btn, Dialog, Icon } from './ui';
|
||||
/** Saves the picture and says so: a download gives no sign of its own in some windows (QA O02), so a short message follows it. */
|
||||
export function saveSnapshotWithNotice(data: string) {
|
||||
try {
|
||||
downloadSnapshot(data);
|
||||
pushToast('success', tr('Screenshot saved'), tr('It is in your Downloads folder.'));
|
||||
}
|
||||
catch {
|
||||
pushToast('error', tr('The screenshot was not saved'), tr('Try again, or take a screenshot of this window instead.'));
|
||||
}
|
||||
}
|
||||
export function downloadSnapshot(data: string) {
|
||||
const encoded = data.slice(data.indexOf(',') + 1);
|
||||
const bytes = Uint8Array.from(atob(encoded), character => character.charCodeAt(0));
|
||||
@@ -22,7 +33,7 @@ export function SnapshotDialog({ open, data, pending, error, onClose, onRetry }:
|
||||
}) {
|
||||
const caption = 'My local AI performance with Strata';
|
||||
const project = 'https://github.com/Niko1221/Strata';
|
||||
return <Dialog open={open} title={tr("Monitor screenshot")} onClose={onClose} actions={<><Btn onClick={onClose}>{tr("Close")}</Btn><Btn icon="download" variant="primary" disabled={!data || pending} onClick={() => downloadSnapshot(data)}>{tr("Download")}</Btn></>}>
|
||||
return <Dialog open={open} title={tr("Monitor screenshot")} onClose={onClose} actions={<><Btn onClick={onClose}>{tr("Close")}</Btn><Btn icon="download" variant="primary" disabled={!data || pending} onClick={() => saveSnapshotWithNotice(data)}>{tr("Download")}</Btn></>}>
|
||||
<div className="snapshot-preview" aria-busy={pending}>
|
||||
{tr(pending ? <p className="small muted" role="status">{tr("Preparing your screenshot\u2026")}</p> : data ? <img src={data} alt={tr("Captured Strata Monitor readings")}/> : <div><p className="inline-error" role="alert">{tr(error || 'Screenshot could not be prepared.')}</p><Btn onClick={onRetry}>{tr("Try again")}</Btn></div>)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Keeps markup that belongs to the model's reasoning, not to the answer, out of the answer text (QA report F01: a literal "<analysis>" with
|
||||
* reasoning Off, and a "p> " in front of a short answer, also in the Markdown export).
|
||||
*
|
||||
* This only removes the wrapper itself, never the words around it, and only for names the reasoning formats use. The words stay, so a model that
|
||||
* answers inside such a tag still shows its answer. If the raw text of a model really holds these, that is the engine's output and is reported
|
||||
* there: this is not a fix for it, only a guard so the page never shows a wrapper to a person.
|
||||
*
|
||||
* Streaming: the page calls this with everything received so far, plus the next piece, every time, so it is cheap and leaves a tag that has not
|
||||
* arrived completely yet alone (it is removed when the rest arrives).
|
||||
*/
|
||||
const WRAPPER = '(?:think|thinking|thought|analysis|reasoning)';
|
||||
/** <think>, </think>, <analysis ...> : one reasoning wrapper tag, open or close. */
|
||||
const TAG = new RegExp(`</?${WRAPPER}(?:\\s[^<>]*)?/?>`, 'gi');
|
||||
/**
|
||||
* A tag that lost its "<" at the very start of the answer ("p> CEDAR-742", "/p> done"): only well-known markup names, only at the start.
|
||||
* The text after it (and the single space that follows) is kept.
|
||||
*/
|
||||
const LOST_LEAD = /^\s*\/?(?:p|br|div|span|li|ul|ol|h[1-6]|b|i|em|strong|code|pre|answer|reply|final|analysis|think|thinking|reasoning)>[ \t]+/i;
|
||||
|
||||
/** The answer with reasoning wrapper tags and a stray leading tag fragment removed. Anything else is returned unchanged. */
|
||||
export function tidyAnswer(text: string): string {
|
||||
if (!text) return text;
|
||||
let out = text;
|
||||
if (out.indexOf('>') !== -1) {
|
||||
out = out.replace(TAG, '');
|
||||
out = out.replace(LOST_LEAD, '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when `tidyAnswer` would change nothing, so a caller can skip a copy. */
|
||||
export const isTidy = (text: string) => tidyAnswer(text) === text;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from './client';
|
||||
import type { PreviewArtifact } from './artifacts';
|
||||
import { tidyAnswer } from './answer-text';
|
||||
|
||||
/**
|
||||
* Deliverables and grouped activity (notes-split-agent8.md, item 12). The host sends `deliverable` frames for every file the assistant wrote or
|
||||
@@ -50,8 +51,8 @@ export function readSkills(raw: unknown): { name: string; description: string }[
|
||||
/** Add streamed text to the reply's ordered pieces: it continues the last text piece, or starts a new one after an activity line. */
|
||||
export function appendText(blocks: MessageBlock[] | undefined, text: string): MessageBlock[] {
|
||||
const list = blocks ?? [], last = list.at(-1);
|
||||
if (last?.kind === 'text') return [...list.slice(0, -1), { kind: 'text', text: last.text + text }];
|
||||
return [...list, { kind: 'text', text }];
|
||||
if (last?.kind === 'text') return [...list.slice(0, -1), { kind: 'text', text: tidyAnswer(last.text + text) }];
|
||||
return [...list, { kind: 'text', text: tidyAnswer(text) }];
|
||||
}
|
||||
|
||||
/** A group frame is re-sent while it grows: the same id replaces its line; a new id goes after what came before. */
|
||||
|
||||
@@ -104,7 +104,10 @@ export function useSurfaceFocus(
|
||||
target?.focus();
|
||||
};
|
||||
frame = requestAnimationFrame(attempt);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
// A page that is not being painted (a window that is covered or in the background) holds animation frames back, and then focus would stay
|
||||
// on whatever opened the surface, behind it (QA F03). A timer is not held back, so it moves focus in if the frame did not.
|
||||
const fallback = setTimeout(() => { const el = ref.current; if (el && !el.contains(document.activeElement) && isTopSurface(el)) attempt(); }, 80);
|
||||
return () => { cancelAnimationFrame(frame); clearTimeout(fallback); };
|
||||
}, [open, ref, initial]);
|
||||
|
||||
// Keep Tab inside while open.
|
||||
|
||||
@@ -43,3 +43,8 @@ export async function postWithFolderFallback<T>(url: string): Promise<T | { canc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Is this page open on the computer that runs Strata? A folder window opens on that computer's screen, so a page opened from another one cannot use it. */
|
||||
export function pageIsOnThisComputer(hostname: string): boolean {
|
||||
return /^(localhost|127(\.\d{1,3}){3}|\[?::1\]?)$/i.test(hostname);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* A number typed into a settings field (QA report F05: "Max output tokens" took -1 below its minimum of 0 and saved it).
|
||||
* The field keeps what the person typed, says in plain words what is wrong, and the save stays off until it is right.
|
||||
*/
|
||||
export type NumberCheck = { ok: true; value: number } | { ok: false; problem: 'empty' | 'range' };
|
||||
|
||||
/** A whole number between `min` and `max` (both included). Spaces around it are ignored; "1e3", "0.5" and "-" are not whole numbers. */
|
||||
export function checkWholeNumber(raw: string, min: number, max: number): NumberCheck {
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) return { ok: false, problem: 'empty' };
|
||||
if (!/^[+-]?\d+$/.test(text)) return { ok: false, problem: 'range' };
|
||||
const value = Number(text);
|
||||
if (!Number.isSafeInteger(value) || value < min || value > max) return { ok: false, problem: 'range' };
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/** The sentence under a field that does not hold a good number. `format` writes a number the way the person's language does. */
|
||||
export function numberProblem(check: NumberCheck, min: number, max: number, format: (n: number) => string): { text: string; args: string[] } | null {
|
||||
if (check.ok) return null;
|
||||
return check.problem === 'empty' ? { text: 'Enter a number.', args: [] } : { text: 'Enter a whole number from {0} to {1}.', args: [format(min), format(max)] };
|
||||
}
|
||||
@@ -16,3 +16,8 @@ export function scrollBehavior(): ScrollBehavior {
|
||||
const override = document.documentElement.dataset.motion;
|
||||
return override === 'reduced' || (override !== 'full' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) ? 'instant' : 'smooth';
|
||||
}
|
||||
|
||||
/** The tallest the message box may grow: 30% of the window, between 44 and 180 pixels (the rest of the window keeps the toolbar and Send in view). */
|
||||
export function composerCap(windowHeight: number): number {
|
||||
return Math.max(44, Math.min(180, Math.floor(windowHeight * 0.3)));
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { updateAgent } from './agents';
|
||||
import { LOOP_MAX_RUNS, LOOP_USAGE, loopEvery, parseLoop, type LoopCommand } from './loop';
|
||||
import { applyStatusFrame, clearStatus } from './live-status';
|
||||
import { applyGoalFrame } from './goal';
|
||||
import { tidyAnswer } from './answer-text';
|
||||
import { appendText, mergeDeliverable, readDeliverable, readGroup, readSkills, upsertGroup } from './deliverables';
|
||||
import { applyPlanFrame, applyQueueFrame, planApprove, queueMessage } from './plan';
|
||||
import { forkFields, isForkJob, TIME_SKIP_DURATION, timeSkipPrompt, timeSkipTabId, withTimeSkipResult, type TimeSkipFile, type TimeSkipJob, type TimeSkipRun, type TimeSkipVersion } from './time-skip';
|
||||
@@ -1435,7 +1436,7 @@ export function rewindTo(messageId: string): string | null {
|
||||
export function exportConversation() {
|
||||
const session = state.sessions.find(s => s.id === state.activeSession);
|
||||
if (!session) return;
|
||||
const text = '# ' + session.title + '\n\n' + session.messages.map(m => '## ' + m.role + '\n\n' + m.content).join('\n\n');
|
||||
const text = '# ' + session.title + '\n\n' + session.messages.map(m => '## ' + m.role + '\n\n' + (m.role === 'assistant' ? tidyAnswer(m.content) : m.content)).join('\n\n');
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown' }));
|
||||
const a = document.createElement('a'); a.href = url; a.download = session.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) + '.md';
|
||||
document.body.append(a); a.click(); a.remove();
|
||||
@@ -1665,7 +1666,7 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
} else if (type === 'agent') {
|
||||
patchSession(sessionId, s => ({ ...s, agents: updateAgent(s.agents ?? [], frame.agent) }));
|
||||
} else if (type === 'delta') {
|
||||
patchAssistant((m) => { const content=m.content+String(frame.text??''); if (m.reasoningStartedAt && m.reasoningMs === undefined) m = { ...m, reasoningMs: Date.now() - m.reasoningStartedAt }; const count=Math.max(1,Math.ceil((content.length+(m.reasoning?.length??0))/4)); return {...m,content,...(m.blocks?{blocks:appendText(m.blocks,String(frame.text??''))}:{}),tokens:m.usageSource==='estimated'&&m.tps?m.tokens:count,inputTokens:m.inputTokens??Math.ceil((body.length+prior.reduce((n,p)=>n+p.content.length,0))/4),usageSource:'estimated'}; });
|
||||
patchAssistant((m) => { const content=tidyAnswer(m.content+String(frame.text??'')); if (m.reasoningStartedAt && m.reasoningMs === undefined) m = { ...m, reasoningMs: Date.now() - m.reasoningStartedAt }; const count=Math.max(1,Math.ceil((content.length+(m.reasoning?.length??0))/4)); return {...m,content,...(m.blocks?{blocks:appendText(m.blocks,String(frame.text??''))}:{}),tokens:m.usageSource==='estimated'&&m.tps?m.tokens:count,inputTokens:m.inputTokens??Math.ceil((body.length+prior.reduce((n,p)=>n+p.content.length,0))/4),usageSource:'estimated'}; });
|
||||
} else if (type === 'deliverable') {
|
||||
const file = readDeliverable(frame);
|
||||
if (file) patchAssistant(m => ({ ...m, deliverables: mergeDeliverable(m.deliverables, file) }));
|
||||
|
||||
@@ -3221,5 +3221,13 @@
|
||||
"s3225": "Was sie getan hat",
|
||||
"s3226": "Dateien",
|
||||
"s3227": "Bitte hilf mir, von hier aus weiterzumachen.",
|
||||
"s3228": "…und {0} weitere Schritte"
|
||||
"s3228": "…und {0} weitere Schritte",
|
||||
"s3229": "Gib eine Zahl ein.",
|
||||
"s3230": "Gib eine ganze Zahl von {0} bis {1} ein.",
|
||||
"s3231": "Beim Hinzufügen einer Fähigkeit öffnet sich ein Ordnerfenster auf dem Computer, auf dem Strata läuft. Öffne Strata auf diesem Computer, um eine hinzuzufügen.",
|
||||
"s3232": "Screenshot gespeichert",
|
||||
"s3233": "Er liegt in deinem Downloads-Ordner.",
|
||||
"s3234": "Der Screenshot wurde nicht gespeichert",
|
||||
"s3235": "Versuche es noch einmal oder mache stattdessen einen Screenshot dieses Fensters.",
|
||||
"s3236": "Das Ordnerfenster hat sich nicht geöffnet. Gib stattdessen den Ordnerpfad ein."
|
||||
}
|
||||
@@ -3221,5 +3221,13 @@
|
||||
"s3225": "What it did",
|
||||
"s3226": "Files",
|
||||
"s3227": "Please help me take it from here.",
|
||||
"s3228": "…and {0} more steps"
|
||||
"s3228": "…and {0} more steps",
|
||||
"s3229": "Enter a number.",
|
||||
"s3230": "Enter a whole number from {0} to {1}.",
|
||||
"s3231": "Adding a skill opens a folder window on the computer that runs Strata. Open Strata on that computer to add one.",
|
||||
"s3232": "Screenshot saved",
|
||||
"s3233": "It is in your Downloads folder.",
|
||||
"s3234": "The screenshot was not saved",
|
||||
"s3235": "Try again, or take a screenshot of this window instead.",
|
||||
"s3236": "The folder window did not open. Type the folder path instead."
|
||||
}
|
||||
@@ -3221,5 +3221,13 @@
|
||||
"s3225": "Qué hizo",
|
||||
"s3226": "Archivos",
|
||||
"s3227": "Ayúdame a continuar desde aquí.",
|
||||
"s3228": "…y {0} pasos más"
|
||||
"s3228": "…y {0} pasos más",
|
||||
"s3229": "Escribe un número.",
|
||||
"s3230": "Escribe un número entero de {0} a {1}.",
|
||||
"s3231": "Al añadir una habilidad se abre una ventana de carpetas en el ordenador donde se ejecuta Strata. Abre Strata en ese ordenador para añadirla.",
|
||||
"s3232": "Captura guardada",
|
||||
"s3233": "Está en tu carpeta de Descargas.",
|
||||
"s3234": "No se guardó la captura",
|
||||
"s3235": "Inténtalo de nuevo o haz una captura de esta ventana.",
|
||||
"s3236": "La ventana de carpetas no se abrió. Escribe la ruta de la carpeta."
|
||||
}
|
||||
@@ -3221,5 +3221,13 @@
|
||||
"s3225": "実行した内容",
|
||||
"s3226": "ファイル",
|
||||
"s3227": "ここから先を手伝ってください。",
|
||||
"s3228": "…ほか {0} 件の手順"
|
||||
"s3228": "…ほか {0} 件の手順",
|
||||
"s3229": "数字を入力してください。",
|
||||
"s3230": "{0} から {1} までの整数を入力してください。",
|
||||
"s3231": "スキルの追加は、Strata を動かしているコンピューターでフォルダーの画面を開きます。そのコンピューターで Strata を開いて追加してください。",
|
||||
"s3232": "スクリーンショットを保存しました",
|
||||
"s3233": "ダウンロードフォルダーにあります。",
|
||||
"s3234": "スクリーンショットを保存できませんでした",
|
||||
"s3235": "もう一度試すか、このウィンドウのスクリーンショットを撮ってください。",
|
||||
"s3236": "フォルダーの画面が開きませんでした。代わりにフォルダーのパスを入力してください。"
|
||||
}
|
||||
@@ -3221,5 +3221,13 @@
|
||||
"s3225": "수행한 내용",
|
||||
"s3226": "파일",
|
||||
"s3227": "여기서부터 이어서 도와주세요.",
|
||||
"s3228": "…외 {0}단계"
|
||||
"s3228": "…외 {0}단계",
|
||||
"s3229": "숫자를 입력하세요.",
|
||||
"s3230": "{0}에서 {1} 사이의 정수를 입력하세요.",
|
||||
"s3231": "스킬을 추가하면 Strata가 실행 중인 컴퓨터에서 폴더 창이 열립니다. 그 컴퓨터에서 Strata를 열어 추가하세요.",
|
||||
"s3232": "스크린샷을 저장했습니다",
|
||||
"s3233": "다운로드 폴더에 있습니다.",
|
||||
"s3234": "스크린샷을 저장하지 못했습니다",
|
||||
"s3235": "다시 시도하거나 이 창을 직접 캡처하세요.",
|
||||
"s3236": "폴더 창이 열리지 않았습니다. 대신 폴더 경로를 입력하세요."
|
||||
}
|
||||
@@ -3221,5 +3221,13 @@
|
||||
"s3225": "O que fez",
|
||||
"s3226": "Arquivos",
|
||||
"s3227": "Por favor, ajude-me a continuar daqui.",
|
||||
"s3228": "…e mais {0} etapas"
|
||||
"s3228": "…e mais {0} etapas",
|
||||
"s3229": "Digite um número.",
|
||||
"s3230": "Digite um número inteiro de {0} a {1}.",
|
||||
"s3231": "Adicionar uma habilidade abre uma janela de pastas no computador que executa o Strata. Abra o Strata nesse computador para adicionar uma.",
|
||||
"s3232": "Captura de tela salva",
|
||||
"s3233": "Ela está na sua pasta Downloads.",
|
||||
"s3234": "A captura de tela não foi salva",
|
||||
"s3235": "Tente de novo ou tire uma captura desta janela.",
|
||||
"s3236": "A janela de pastas não abriu. Digite o caminho da pasta."
|
||||
}
|
||||
@@ -3221,5 +3221,13 @@
|
||||
"s3225": "Что она сделала",
|
||||
"s3226": "Файлы",
|
||||
"s3227": "Помогите мне продолжить отсюда.",
|
||||
"s3228": "…и ещё шагов: {0}"
|
||||
"s3228": "…и ещё шагов: {0}",
|
||||
"s3229": "Введите число.",
|
||||
"s3230": "Введите целое число от {0} до {1}.",
|
||||
"s3231": "При добавлении навыка на компьютере, где работает Strata, открывается окно выбора папки. Откройте Strata на том компьютере, чтобы добавить навык.",
|
||||
"s3232": "Снимок экрана сохранён",
|
||||
"s3233": "Он находится в папке «Загрузки».",
|
||||
"s3234": "Снимок экрана не сохранён",
|
||||
"s3235": "Попробуйте ещё раз или сделайте снимок этого окна.",
|
||||
"s3236": "Окно выбора папки не открылось. Введите путь к папке вручную."
|
||||
}
|
||||
@@ -3221,5 +3221,13 @@
|
||||
"s3225": "它做了什么",
|
||||
"s3226": "文件",
|
||||
"s3227": "请帮我从这里继续。",
|
||||
"s3228": "……以及另外 {0} 个步骤"
|
||||
"s3228": "……以及另外 {0} 个步骤",
|
||||
"s3229": "请输入数字。",
|
||||
"s3230": "请输入 {0} 到 {1} 之间的整数。",
|
||||
"s3231": "添加技能会在运行 Strata 的电脑上打开文件夹窗口。请在那台电脑上打开 Strata 来添加。",
|
||||
"s3232": "截图已保存",
|
||||
"s3233": "它在你的“下载”文件夹中。",
|
||||
"s3234": "截图未能保存",
|
||||
"s3235": "请重试,或改为对此窗口截屏。",
|
||||
"s3236": "文件夹窗口没有打开。请改为输入文件夹路径。"
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import { artifactFromUrl, type PreviewArtifact } from '../lib/artifacts';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Markdown } from '../components/Markdown';
|
||||
import { Btn, Icon, IconBtn, ErrorNotice, type IconName } from '../components/ui';
|
||||
import { scrollBehavior } from '../lib/responsive';
|
||||
import { composerCap, scrollBehavior } from '../lib/responsive';
|
||||
import { relTime, cx, duration, tokens, tps } from '../lib/format';
|
||||
import { createSubagentExample, pushToast, setPreviewTabClosed, branchFrom, continueLast, markRead, resolveTool, retryLast, selectSession, rewindTo, sendMessage, stopStreaming, useStore, } from '../lib/store';
|
||||
import { getSlashCommands } from '../lib/commands';
|
||||
@@ -265,12 +265,18 @@ export function ChatScreen() {
|
||||
// would fight the follow-the-stream effect above.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.id]);
|
||||
// The box grows with the draft but never past 30% of the window, and scrolls inside after that, so Send and the toolbar stay on screen (QA F08).
|
||||
useEffect(() => {
|
||||
const el = textarea.current;
|
||||
if (!el)
|
||||
return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(200, el.scrollHeight)}px`;
|
||||
const fit = () => {
|
||||
const el = textarea.current;
|
||||
if (!el)
|
||||
return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(composerCap(window.innerHeight), el.scrollHeight)}px`;
|
||||
};
|
||||
fit();
|
||||
window.addEventListener('resize', fit);
|
||||
return () => window.removeEventListener('resize', fit);
|
||||
}, [draft]);
|
||||
/** Hands a rewound prompt back to the composer so it can be changed. */
|
||||
function editPrompt(text: string) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { Btn, Dialog, Empty, Icon, IconBtn, Select, ErrorNotice } from '../components/ui';
|
||||
import { Screen } from '../components/layout';
|
||||
import { api } from '../lib/client';
|
||||
import { postWithFolderFallback } from '../lib/folder-pick';
|
||||
import { pageIsOnThisComputer, postWithFolderFallback } from '../lib/folder-pick';
|
||||
import { editSkill, loadSkills, toggleSkill, useStore } from '../lib/store';
|
||||
import type { Skill } from '../lib/types';
|
||||
export function SkillsScreen() {
|
||||
@@ -32,6 +32,9 @@ export function SkillsScreen() {
|
||||
}
|
||||
}
|
||||
async function add() {
|
||||
// The folder window opens on the computer that runs Strata. From a page on another computer there is nothing to see, so say so instead of waiting.
|
||||
if (!pageIsOnThisComputer(window.location.hostname))
|
||||
throw new Error(tr("Adding a skill opens a folder window on the computer that runs Strata. Open Strata on that computer to add one."));
|
||||
const result = await postWithFolderFallback<{
|
||||
cancelled?: boolean;
|
||||
added?: string;
|
||||
@@ -55,7 +58,7 @@ export function SkillsScreen() {
|
||||
if (!editing)
|
||||
return;
|
||||
if (!editing.id) {
|
||||
await api.post('/api/skills', { name: editing.name.trim(), description: editing.description.trim(), body: editing.body ?? '' });
|
||||
await api.post('/api/skills', { name: editing.name.trim(), description: editing.description.trim(), body: editing.body ?? '', enabled: editing.enabled, triggers: triggers.split(',').map(t => t.trim()).filter(Boolean) });
|
||||
await loadSkills();
|
||||
setEditing(null);
|
||||
return;
|
||||
|
||||
@@ -57,6 +57,9 @@ input:focus, textarea:focus, [contenteditable]:focus { outline: none; box-shadow
|
||||
.sampling-field p { margin: 1px 0 0; }
|
||||
#response-panel-sampling > .stack { gap: 12px; }
|
||||
.sampling-number-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.composer .st-composer textarea { max-height: clamp(44px, 30dvh, 180px); overflow-y: auto; }
|
||||
.field-problem { color: var(--st-danger-text); font-size: var(--st-fs-sm); }
|
||||
.input-field input[aria-invalid="true"] { border-color: var(--st-danger); }
|
||||
.response-projection { display: flex; align-items: center; gap: 20px; }
|
||||
.response-projection > div { flex: 1; }
|
||||
.response-projection p { margin: 6px 0 0; }
|
||||
|
||||
@@ -7452,6 +7452,13 @@ textarea {
|
||||
.chatcol { position: relative; }
|
||||
.dropzone { position: absolute; inset: 8px; z-index: 20; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; border: 2px dashed var(--st-accent); border-radius: var(--st-r-lg); background: color-mix(in srgb, var(--st-surface) 88%, var(--st-accent) 12%); color: var(--st-ink); pointer-events: none; }
|
||||
.dropzone span { color: var(--st-ink-muted); font-size: var(--st-fs-sm); }
|
||||
/* A calm sign that the drop will be taken: the box fades in, its dashed edge breathes, the clip bobs. */
|
||||
.dropzone { animation: dropzone-in .16s ease-out both, dropzone-edge 1.4s ease-in-out .16s infinite; }
|
||||
.dropzone .st-icon, .dropzone svg { animation: dropzone-bob 1.2s ease-in-out infinite; }
|
||||
@keyframes dropzone-in { from { opacity: 0; transform: scale(.985); } to { opacity: 1; transform: none; } }
|
||||
@keyframes dropzone-edge { 50% { border-color: color-mix(in srgb, var(--st-accent) 40%, transparent); } }
|
||||
@keyframes dropzone-bob { 50% { transform: translateY(-3px); } }
|
||||
@media (prefers-reduced-motion: reduce) { .dropzone, .dropzone .st-icon, .dropzone svg { animation: none; } }
|
||||
|
||||
/* The chat's project folder, above the message box (round 8, item 4) */
|
||||
.folderchip { display: inline-flex; align-items: center; align-self: flex-start; max-width: 100%; margin: 0 0 6px 6px; border-radius: 8px; color: var(--st-ink-muted); font-size: var(--st-fs-sm); }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Deliverable events and compact activity groups.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, utimesSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DeliverableTracker, ActivityGroups, fileType, groupLine, lineDelta } from '../server/agent/deliverables.mjs';
|
||||
@@ -43,6 +43,49 @@ test('a created file, a changed file and a script-made file are reported once ea
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('a snapshot cut short by its time budget never turns the rest of the folder into "new files" (QA report: Edited 308 files)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'strata-dl3-'));
|
||||
try {
|
||||
// a big, old tree (the CUDA headers of the report) that was there long before the turn
|
||||
const old = new Date(Date.now() - 3600_000);
|
||||
for (const d of ['algorithm', 'atomic', 'barrier']) {
|
||||
mkdirSync(join(dir, d));
|
||||
for (let i = 0; i < 30; i++) { const p = join(dir, d, 'f' + i + '.h'); writeFileSync(p, 'line' + NL); utimesSync(p, old, old); }
|
||||
}
|
||||
// the first look runs out of time after a few files; the look at the end of the turn has all the time it wants
|
||||
let calls = 0;
|
||||
const t = new DeliverableTracker(dir, { now: () => (++calls > 12 ? 1e9 : 0) });
|
||||
t.snapshot();
|
||||
assert.equal(t.truncated, true, 'the setup: the snapshot was cut short');
|
||||
t.now = () => 0;
|
||||
assert.deepEqual(t.sweep(), [], 'nothing the turn did not touch is reported');
|
||||
// a file the turn really makes is still found, and an old file the turn really changes
|
||||
writeFileSync(join(dir, 'barrier', 'made.txt'), 'x' + NL);
|
||||
assert.deepEqual(t.sweep().map(d => d.path), ['barrier/made.txt']);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('the tracker never looks inside the app, engine, component, AppData or temp folders, nor when the chat folder is the app itself', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'strata-dl4-'));
|
||||
try {
|
||||
const ws = join(dir, 'work'), engine = join(ws, 'engine'), temp = join(ws, 'Temp'), appData = join(ws, 'AppData'), mine = join(ws, 'mine');
|
||||
for (const d of [ws, engine, temp, appData, mine]) mkdirSync(d);
|
||||
const t = new DeliverableTracker(ws, { forbid: [engine], exclude: [temp, appData, join(dir, 'elsewhere')] });
|
||||
t.snapshot();
|
||||
for (const d of [engine, temp, appData, mine]) writeFileSync(join(d, 'new.h'), 'a' + NL);
|
||||
assert.deepEqual(t.sweep().map(d => d.path), ['mine/new.h']);
|
||||
assert.equal(t.check('engine/new.h'), null); assert.equal(t.check('Temp/new.h'), null); assert.equal(t.check('AppData/new.h'), null);
|
||||
// a chat whose folder IS the engine folder (or inside it) tracks nothing
|
||||
const inside = new DeliverableTracker(engine, { forbid: [engine] }); inside.snapshot();
|
||||
writeFileSync(join(engine, 'another.h'), 'b' + NL);
|
||||
assert.deepEqual(inside.sweep(), []); assert.equal(inside.check('another.h'), null);
|
||||
// the temp folder as the chat's own folder is fine (the exclusion is for folders inside the chat folder)
|
||||
const own = new DeliverableTracker(temp, { exclude: [temp] }); own.snapshot();
|
||||
writeFileSync(join(temp, 'ok.txt'), 'c' + NL);
|
||||
assert.deepEqual(own.sweep().map(d => d.path), ['ok.txt']);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('activity groups: consecutive tools are one growing group closed by text, with counts, files and the diff stat', () => {
|
||||
const out = [];
|
||||
const g = new ActivityGroups({ emit: f => out.push(f) });
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// QA report F01: reasoning wrappers and stray tag fragments never show in an answer or its Markdown export.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { transform } from 'esbuild';
|
||||
async function loadTs(path) {
|
||||
const { code } = await transform(await readFile(new URL(path, import.meta.url), 'utf8'), { loader: 'ts', format: 'esm' });
|
||||
return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
|
||||
}
|
||||
const { tidyAnswer } = await loadTs('../src/lib/answer-text.ts');
|
||||
const { appendText } = await loadTs('../src/lib/deliverables.ts').catch(() => ({}));
|
||||
|
||||
test('a literal <analysis> or <think> wrapper is removed, the words stay', () => {
|
||||
assert.equal(tidyAnswer('<analysis>The answer is 4.'), 'The answer is 4.');
|
||||
assert.equal(tidyAnswer('Sure.\n<think>\nplan\n</think>\nIt is 4.'), 'Sure.\n\nplan\n\nIt is 4.');
|
||||
assert.equal(tidyAnswer('x </ANALYSIS> y <Thinking style="a"> z'), 'x y z');
|
||||
});
|
||||
|
||||
test('a tag that lost its "<" at the very start is not shown as a prefix', () => {
|
||||
assert.equal(tidyAnswer('p> CEDAR-742'), 'CEDAR-742');
|
||||
assert.equal(tidyAnswer(' /p> done'), 'done');
|
||||
assert.equal(tidyAnswer('analysis> 4'), '4');
|
||||
});
|
||||
|
||||
test('ordinary text, code and comparisons are untouched', () => {
|
||||
for (const text of ['CEDAR-742', 'if a > b then c', 'x -> y', '```html\n<p>hi</p>\n```', 'The p> in the middle', '<div>kept</div>', 'a <b and c> d', 'Result: 5 > 3', '']) assert.equal(tidyAnswer(text), text);
|
||||
});
|
||||
|
||||
test('streaming: a tag that arrives in pieces is removed once it is complete', () => {
|
||||
let content = '';
|
||||
for (const piece of ['<', 'ana', 'lysis', '>', 'The code is ', 'p', '> no']) content = tidyAnswer(content + piece);
|
||||
assert.equal(content, 'The code is p> no');
|
||||
let lead = '';
|
||||
for (const piece of ['p', '>', ' CEDAR', '-742']) lead = tidyAnswer(lead + piece);
|
||||
assert.equal(lead, 'CEDAR-742');
|
||||
});
|
||||
|
||||
test('the store applies it to the answer, the ordered pieces and the Markdown export', async () => {
|
||||
const store = await readFile(new URL('../src/lib/store.ts', import.meta.url), 'utf8');
|
||||
assert.match(store, /tidyAnswer\(m\.content\+String\(frame\.text/);
|
||||
assert.match(store, /m\.role === 'assistant' \? tidyAnswer\(m\.content\) : m\.content\)/);
|
||||
assert.match(await readFile(new URL('../src/lib/deliverables.ts', import.meta.url), 'utf8'), /tidyAnswer\(last\.text \+ text\)/);
|
||||
});
|
||||
@@ -119,3 +119,39 @@ test('the host accepts a typed folder path for the picker and for adding a skill
|
||||
await rm(home, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// QA report F06: a Windows folder window that never opens must not leave the button disabled forever.
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { watchedWindowsDialog } from '../server/folder-dialog.mjs';
|
||||
function fakeShell() {
|
||||
const child = new EventEmitter(); child.stdout = new EventEmitter(); child.killed = false; child.kill = () => { child.killed = true; };
|
||||
return { child, spawnProcess: () => child };
|
||||
}
|
||||
test('Windows: no READY from the dialog script means the host gives up and the app asks for the path', async () => {
|
||||
const f = fakeShell();
|
||||
const result = await watchedWindowsDialog('powershell.exe', [], { readyMs: 30, spawnProcess: f.spawnProcess });
|
||||
assert.deepEqual(result, { noDialog: true }); assert.equal(f.child.killed, true, 'the stuck PowerShell is ended');
|
||||
const answer = await chooseFolder('Pick', { platform: 'win32', windowsDialog: async () => ({ noDialog: true }) });
|
||||
assert.equal(answer.unavailable, true); assert.match(answer.reason, /Type the folder path/);
|
||||
});
|
||||
test('Windows: once READY has come the person may take their time, and the answer is read without the READY line', async () => {
|
||||
const f = fakeShell();
|
||||
const pending = watchedWindowsDialog('powershell.exe', [], { readyMs: 30, spawnProcess: f.spawnProcess });
|
||||
f.child.stdout.emit('data', 'READY\r\n');
|
||||
await new Promise(r => setTimeout(r, 80)); // longer than readyMs: still waiting, not given up
|
||||
assert.equal(f.child.killed, false);
|
||||
f.child.stdout.emit('data', 'C:\Users\me\Skills'); f.child.emit('close', 0);
|
||||
const done = await pending;
|
||||
const answer = await chooseFolder('Pick', { platform: 'win32', windowsDialog: async () => done });
|
||||
assert.deepEqual(answer, { path: 'C:\Users\me\Skills' });
|
||||
const cancelled = await chooseFolder('Pick', { platform: 'win32', windowsDialog: async () => ({ stdout: 'READY\r\n' }) });
|
||||
assert.deepEqual(cancelled, { cancelled: true });
|
||||
});
|
||||
test('Windows: a PowerShell that is missing or fails is reported as before', async () => {
|
||||
const f = fakeShell();
|
||||
const missing = watchedWindowsDialog('powershell.exe', [], { spawnProcess: f.spawnProcess });
|
||||
f.child.emit('error', Object.assign(new Error('x'), { code: 'ENOENT' }));
|
||||
await assert.rejects(missing, e => e.code === 'ENOENT');
|
||||
const g = fakeShell(); const failed = watchedWindowsDialog('powershell.exe', [], { spawnProcess: g.spawnProcess });
|
||||
g.child.emit('close', 1); await assert.rejects(failed, e => e.code === 1);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// QA report F03: a dialog opened from the narrow-window menu closes the menu and takes the keyboard.
|
||||
// (The real behaviour is checked in a browser by scripts/qa2-flows.mjs; these keep the pieces from being taken out again.)
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const read = path => readFile(new URL(path, import.meta.url), 'utf8').then(s => s.replace(/\r\n/g, '\n'));
|
||||
|
||||
test('every dialog the menu opens closes the menu first, and the task dialogs stay mounted while it is closed', async () => {
|
||||
const tree = await read('../src/app/TaskTree.tsx');
|
||||
assert.match(tree, /import \{ pushToast, setNavOpen \} from '\.\.\/lib\/store'/);
|
||||
for (const opener of ['setEditor(blankTask())', 'setEditor({ ...t })', 'setRemove(t)']) assert.ok(tree.includes(`setNavOpen(false); ${opener}`), `${opener} closes the menu`);
|
||||
assert.match(tree, /if \(collapsed\)\n\s+return dialogs;/, 'a closed menu still renders the dialogs');
|
||||
const side = await read('../src/app/Sidebar.tsx');
|
||||
for (const opener of ['setRenameId(menuId)', 'setDeleteId(menuId)', 'setRecentOpen(true)', 'setRemoveWorkspaceId(w.id)', 'setRenameWsId(w.id)', 'setQuitOpen(true)']) assert.ok(side.includes(`setNavOpen(false); ${opener}`), `${opener} closes the menu`);
|
||||
});
|
||||
|
||||
test('a surface that opens moves the keyboard in even when animation frames are held back', async () => {
|
||||
const focus = await read('../src/lib/focus.ts');
|
||||
assert.match(focus, /const fallback = setTimeout\(/);
|
||||
assert.match(focus, /clearTimeout\(fallback\)/);
|
||||
assert.match(focus, /!el\.contains\(document\.activeElement\) && isTopSurface\(el\)/);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// QA report F05: a number typed into Response settings is checked against the field's own range.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { transform } from 'esbuild';
|
||||
async function loadTs(path) {
|
||||
const { code } = await transform(await readFile(new URL(path, import.meta.url), 'utf8'), { loader: 'ts', format: 'esm' });
|
||||
return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
|
||||
}
|
||||
const { checkWholeNumber, numberProblem } = await loadTs('../src/lib/number-field.ts');
|
||||
|
||||
test('Max output tokens: -1 and other numbers outside 0 to 1048576 are refused, whole numbers inside are taken', () => {
|
||||
assert.deepEqual(checkWholeNumber('-1', 0, 1048576), { ok: false, problem: 'range' });
|
||||
assert.deepEqual(checkWholeNumber('1048577', 0, 1048576), { ok: false, problem: 'range' });
|
||||
assert.deepEqual(checkWholeNumber('0', 0, 1048576), { ok: true, value: 0 });
|
||||
assert.deepEqual(checkWholeNumber(' 4096 ', 0, 1048576), { ok: true, value: 4096 });
|
||||
assert.deepEqual(checkWholeNumber('1048576', 0, 1048576), { ok: true, value: 1048576 });
|
||||
});
|
||||
|
||||
test('Seed accepts -1 (random) and refuses what is not a whole number', () => {
|
||||
assert.deepEqual(checkWholeNumber('-1', -1, 2147483647), { ok: true, value: -1 });
|
||||
assert.equal(checkWholeNumber('-2', -1, 2147483647).ok, false);
|
||||
for (const bad of ['', ' ', '-', '1.5', '1e3', 'abc', '12abc', '99999999999999999999']) assert.equal(checkWholeNumber(bad, -1, 2147483647).ok, false, JSON.stringify(bad));
|
||||
assert.deepEqual(checkWholeNumber('', 0, 5), { ok: false, problem: 'empty' });
|
||||
});
|
||||
|
||||
test('the sentence says what is wrong in plain words, with the range written the reader\'s way', () => {
|
||||
const fmt = n => n.toLocaleString('en');
|
||||
assert.equal(numberProblem(checkWholeNumber('7', 0, 10), 0, 10, fmt), null);
|
||||
assert.deepEqual(numberProblem(checkWholeNumber('', 0, 10), 0, 10, fmt), { text: 'Enter a number.', args: [] });
|
||||
assert.deepEqual(numberProblem(checkWholeNumber('-1', 0, 1048576), 0, 1048576, fmt), { text: 'Enter a whole number from {0} to {1}.', args: ['0', '1,048,576'] });
|
||||
});
|
||||
|
||||
test('the Response settings dialog uses it: wrong numbers show the sentence and the save stays off', async () => {
|
||||
const source = (await readFile(new URL('../src/app/SamplingDrawer.tsx', import.meta.url), 'utf8')).replace(/\r\n/g, '\n');
|
||||
assert.match(source, /checkWholeNumber\(text, field\.min, field\.max\)/);
|
||||
assert.match(source, /disabled=\{pending \|\| !dirty \|\| invalid\}/);
|
||||
assert.match(source, /if \(!dirty \|\| pending \|\| invalid\)/);
|
||||
assert.match(source, /aria-invalid=\{problem \? true : undefined\}/);
|
||||
});
|
||||
|
||||
test('F07: a page opened from another computer says why Add skill cannot work instead of waiting', async () => {
|
||||
const pick = await loadTs('../src/lib/folder-pick.ts').catch(() => null);
|
||||
const source = (await readFile(new URL('../src/lib/folder-pick.ts', import.meta.url), 'utf8'));
|
||||
const fn = new Function(`${source.slice(source.indexOf('export function pageIsOnThisComputer')).replace('export function', 'function')}; return pageIsOnThisComputer;`.replace(/: string|: boolean/g, ''))();
|
||||
for (const yes of ['localhost', '127.0.0.1', '127.1.2.3', '[::1]', '::1']) assert.equal(fn(yes), true, yes);
|
||||
for (const no of ['192.168.1.5', 'strata.example.com', 'my-pc.trycloudflare.com']) assert.equal(fn(no), false, no);
|
||||
const skills = (await readFile(new URL('../src/screens/Skills.tsx', import.meta.url), 'utf8'));
|
||||
assert.match(skills, /pageIsOnThisComputer\(window\.location\.hostname\)/);
|
||||
void pick;
|
||||
});
|
||||
|
||||
test('F08: the message box stops at 30% of the window height (44 to 180 px) and scrolls inside', async () => {
|
||||
const source = await readFile(new URL('../src/lib/responsive.ts', import.meta.url), 'utf8');
|
||||
const composerCap = new Function(`${source.slice(source.indexOf('export function composerCap')).replace('export function', 'function').replace(/: number/g, '')}; return composerCap;`)();
|
||||
assert.equal(composerCap(400), 120); assert.equal(composerCap(500), 150); assert.equal(composerCap(720), 180); assert.equal(composerCap(2000), 180); assert.equal(composerCap(100), 44);
|
||||
const css = await readFile(new URL('../src/styles/annotation-fixes.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.composer \.st-composer textarea \{ max-height: clamp\(44px, 30dvh, 180px\); overflow-y: auto; \}/);
|
||||
assert.match(await readFile(new URL('../src/screens/Chat.tsx', import.meta.url), 'utf8'), /composerCap\(window\.innerHeight\)/);
|
||||
});
|
||||
|
||||
test('O02: the screenshot Download button saves the picture and then says so', async () => {
|
||||
const source = (await readFile(new URL('../src/components/SnapshotDialog.tsx', import.meta.url), 'utf8')).replace(/\r\n/g, '\n');
|
||||
assert.match(source, /onClick=\{\(\) => saveSnapshotWithNotice\(data\)\}>\{tr\("Download"\)\}/);
|
||||
assert.match(source, /downloadSnapshot\(data\);\n\s+pushToast\('success'/);
|
||||
assert.match(source, /pushToast\('error', tr\('The screenshot was not saved'\)/);
|
||||
});
|
||||
|
||||
test('item 1: the desktop window leaves file drops to the page, and the drop box is animated', async () => {
|
||||
const rust = await readFile(new URL('../../desktop/src-tauri/src/main.rs', import.meta.url), 'utf8');
|
||||
assert.match(rust, /\.min_inner_size\(900\.0, 600\.0\)\.disable_drag_drop_handler\(\);/);
|
||||
const css = await readFile(new URL('../src/styles/app.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.dropzone \{ animation: dropzone-in/);
|
||||
assert.match(css, /prefers-reduced-motion: reduce\) \{ \.dropzone/);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// QA report F04: a skill created with "Skill enabled" off stays off, and it keeps its trigger phrases.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
async function startHost(home) {
|
||||
const socket = createServer(); await new Promise(r => socket.listen(0, '127.0.0.1', r));
|
||||
const port = socket.address().port; await new Promise(r => socket.close(r));
|
||||
const child = spawn(process.execPath, ['server/index.mjs'], { cwd: root, env: { ...process.env, STRATA_HOME: home, STRATA_MODELS_DIR: join(home, 'models'), STRATA_PORT: String(port), STRATA_REAL_ENGINE: '0', STRATA_WORKSPACE: join(home, 'work') }, stdio: 'ignore', windowsHide: true });
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const call = async (path, method = 'GET', body) => {
|
||||
const response = await fetch(base + path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
||||
return { status: response.status, data: await response.json() };
|
||||
};
|
||||
for (let i = 0; i < 100; i++) { try { if ((await call('/api/skills')).status === 200) break; } catch { /* not up yet */ } await new Promise(r => setTimeout(r, 100)); }
|
||||
const stopped = new Promise(r => child.once('exit', r));
|
||||
return { call, stop: async () => { child.kill(); await stopped; } };
|
||||
}
|
||||
|
||||
test('a skill created with Skill enabled off is saved off, kept after a restart, and an enabled one stays on', { timeout: 60000 }, async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'strata-qa2-skill-'));
|
||||
let host = await startHost(home);
|
||||
try {
|
||||
const off = await host.call('/api/skills', 'POST', { name: 'QA disabled creation check', description: 'a', body: 'Harmless test instructions.', enabled: false, triggers: ['qa check', ' ', 'qa check', 'disabled'] });
|
||||
assert.equal(off.status, 201);
|
||||
const row = off.data.skills.find(s => s.id === off.data.id);
|
||||
assert.equal(row.enabled, false, 'the answer to the save already says off');
|
||||
assert.deepEqual(row.triggers, ['qa check', 'disabled']);
|
||||
const on = await host.call('/api/skills', 'POST', { name: 'QA enabled one', description: '', body: 'More instructions.', enabled: true });
|
||||
assert.equal(on.data.skills.find(s => s.id === on.data.id).enabled, true);
|
||||
const plain = await host.call('/api/skills', 'POST', { name: 'QA no flag', body: 'Older callers send no flag.' });
|
||||
assert.equal(plain.data.skills.find(s => s.id === plain.data.id).enabled, true, 'no flag keeps the old behaviour: on');
|
||||
assert.equal((await host.call('/api/skills', 'POST', { name: 'Bad', body: 'x', triggers: 'not a list' })).status, 400);
|
||||
await host.stop();
|
||||
host = await startHost(home);
|
||||
const again = (await host.call('/api/skills')).data;
|
||||
assert.equal(again.find(s => s.id === off.data.id).enabled, false, 'still off after a restart');
|
||||
assert.deepEqual(again.find(s => s.id === off.data.id).triggers, ['qa check', 'disabled']);
|
||||
assert.equal(again.find(s => s.id === on.data.id).enabled, true);
|
||||
} finally { await host.stop(); await rm(home, { recursive: true, force: true, maxRetries: 3 }).catch(() => {}); }
|
||||
});
|
||||
|
||||
test('F05: the host refuses an output limit below 0 or above its maximum, and keeps the saved one', { timeout: 60000 }, async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'strata-qa2-tokens-'));
|
||||
const host = await startHost(home);
|
||||
try {
|
||||
const value = async () => (await host.call('/api/engine')).data.expert.find(p => p.id === 'sample.maxTokens').value;
|
||||
assert.equal(await value(), 0);
|
||||
assert.equal((await host.call('/api/engine', 'PATCH', { expert: [{ id: 'sample.maxTokens', value: -1 }] })).status, 400);
|
||||
assert.equal((await host.call('/api/engine', 'PATCH', { expert: [{ id: 'sample.maxTokens', value: 1048577 }] })).status, 400);
|
||||
assert.equal((await host.call('/api/engine', 'PATCH', { expert: [{ id: 'sample.maxTokens', value: 2.5 }] })).status, 400);
|
||||
assert.equal(await value(), 0, 'nothing was saved');
|
||||
assert.equal((await host.call('/api/engine', 'PATCH', { expert: [{ id: 'sample.maxTokens', value: 4096 }] })).status, 200);
|
||||
assert.equal(await value(), 4096);
|
||||
} finally { await host.stop(); await rm(home, { recursive: true, force: true, maxRetries: 3 }).catch(() => {}); }
|
||||
});
|
||||
Reference in new issue
Block a user