Merge f-split-int d6aa6504e into f-split-tuiqa: launch warns and asks (setup stops only for NEED_ENGINE unless launch said go on), the app warns; one picture-reader rule in strata_control/vision_reader.py (CPU reader under 24 GB, GPU reader from 24 GB, every make; pictures stay on) for setup, launch and the app catalog

This commit is contained in:
Niko1221 committed 2026-10-19 00:03:31 +02:00
commit 95f3807699
72 files changed
+2271 -209

No files matched your search

+2
View File
@@ -3526,6 +3526,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
name = "strata-desktop"
version = "0.2.0-beta"
dependencies = [
"gtk",
"serde",
"serde_json",
"tauri",
@@ -3533,6 +3534,7 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"webkit2gtk",
]
[[package]]
+5
View File
@@ -6,6 +6,11 @@ edition = "2021"
rust-version = "1.77"
license = "MIT"
# Linux: the drop of a file on the window is read straight from the GTK drag signals (see hook_linux_drops in main.rs); the versions are the ones Tauri itself uses.
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
webkit2gtk = "2.0"
[build-dependencies]
tauri-build = { version = "2", features = [] }
+118
View File
@@ -914,9 +914,118 @@ fn open_window(app: &tauri::App, port: u16, shell_token: &str) -> Result<(), Box
}
});
}
#[cfg(target_os = "linux")]
hook_linux_drops(&window, port, shell_token);
Ok(())
}
/// A file:// address from a drag, as a path (percent-escapes decoded); anything else (http, mailto, text) is not a dropped file.
#[cfg_attr(windows, allow(dead_code))]
fn file_uri_to_path(uri: &str) -> Option<String> {
let rest = uri.trim().strip_prefix("file://")?;
// file://host/path: only an empty or local host is a file on this computer
let rest = match rest.find('/') { Some(0) => rest, Some(i) if rest[..i].eq_ignore_ascii_case("localhost") => &rest[i..], _ => return None };
let bytes = rest.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Some(v) = std::str::from_utf8(&bytes[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok()) { out.push(v); i += 3; continue; }
}
out.push(bytes[i]);
i += 1;
}
let path = String::from_utf8(out).ok()?;
if path.contains('\0') { None } else { Some(path) }
}
/// Linux: WebKitGTK 2.52 gives the page a file drag without the files, and the window library's own drop handler never sees one, so the
/// shell reads the drop itself from the GTK drag signals of the page widget: the file:// addresses arrive with the drag data, the drop hands
/// them to the host exactly as the other systems do (hand_over_drop), and the page shows its drop box meanwhile (QA, drop on the chat).
#[cfg(target_os = "linux")]
fn hook_linux_drops(window: &tauri::WebviewWindow, port: u16, token: &str) {
use gtk::prelude::*;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
let target = window.clone();
let token = token.to_string();
let _ = window.with_webview(move |platform| {
let view = platform.inner();
let paths: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
let hovering = Rc::new(Cell::new(false));
let generation = Rc::new(Cell::new(0u32));
{
let (paths, hovering, generation, target) = (paths.clone(), hovering.clone(), generation.clone(), target.clone());
view.connect_drag_data_received(move |_, _, _, _, data, _, _| {
let list: Vec<String> = data.uris().iter().filter_map(|u| file_uri_to_path(u.as_str())).collect();
if list.is_empty() { return; }
*paths.borrow_mut() = list;
generation.set(generation.get() + 1);
if !hovering.replace(true) { let _ = target.eval(&drag_script("enter")); }
});
}
{
// WebKit only accepts a drag when the page says so, and the page is not given the files: once the addresses of a file drag are known,
// the shell accepts it (as a copy) itself, so GTK goes on to deliver the drop.
let paths = paths.clone();
view.connect_drag_motion(move |_, ctx, _, _, time| {
if paths.borrow().is_empty() { return false; }
ctx.drag_status(gtk::gdk::DragAction::COPY, time);
true
});
}
{
let (paths, hovering, generation, target) = (paths.clone(), hovering.clone(), generation.clone(), target.clone());
let token = token.clone();
view.connect_drag_drop(move |_, ctx, _, _, time| {
generation.set(generation.get() + 1);
let list = std::mem::take(&mut *paths.borrow_mut());
if list.is_empty() { return false; } // not a file: the page deals with it
hovering.set(false);
ctx.drop_finish(true, time);
let _ = target.eval(&drag_script("leave"));
hand_over_drop(target.clone(), port, token.clone(), list);
true
});
}
{
let (paths, hovering, target, token) = (paths.clone(), hovering.clone(), target.clone(), token.clone());
view.connect_drag_leave(move |widget, _, _| {
// WebKit's own handling of a drop never reaches this widget's drop signal, but GTK does say "leave" at the moment of the drop:
// a leave with every mouse button already up (and a file drag known) is the drop. A leave with the button still down is the
// pointer going out of the window.
if hovering.get() && !paths.borrow().is_empty() && pointer_buttons_up(widget) {
let list = std::mem::take(&mut *paths.borrow_mut());
hovering.set(false);
let _ = target.eval(&drag_script("leave"));
hand_over_drop(target.clone(), port, token.clone(), list);
}
});
}
view.connect_drag_leave(move |_, _, _| {
// only a leave that nothing follows ends the drag (a drop is handled above and clears the state)
let seen = generation.get();
let (paths, hovering, generation, target) = (paths.clone(), hovering.clone(), generation.clone(), target.clone());
gtk::glib::idle_add_local_once(move || {
if generation.get() == seen && hovering.replace(false) {
paths.borrow_mut().clear();
let _ = target.eval(&drag_script("leave"));
}
});
});
});
}
/// True when no mouse button is held down (GTK drag drop: the button is already up when the drop arrives).
#[cfg(target_os = "linux")]
fn pointer_buttons_up(widget: &webkit2gtk::WebView) -> bool {
use gtk::prelude::*;
let Some(window) = widget.window() else { return false };
let Some(pointer) = gtk::gdk::Display::default().and_then(|d| d.default_seat()).and_then(|s| s.pointer()) else { return false };
let (_, _, _, mask) = window.device_position(&pointer);
!mask.intersects(gtk::gdk::ModifierType::BUTTON1_MASK | gtk::gdk::ModifierType::BUTTON2_MASK | gtk::gdk::ModifierType::BUTTON3_MASK)
}
/// The script that tells the page a file drag is over the window (`state` is "enter" or "leave"); the page shows its drop box (apps/host DropZone).
#[cfg_attr(windows, allow(dead_code))]
fn drag_script(state: &str) -> String {
@@ -1013,6 +1122,15 @@ fn run_window_process() -> ! {
mod tests {
use super::*;
#[test]
fn dropped_file_addresses_become_paths_and_nothing_else_does() {
assert_eq!(file_uri_to_path("file:///home/me/My%20Pictures/a%2Bb.png").as_deref(), Some("/home/me/My Pictures/a+b.png"));
assert_eq!(file_uri_to_path("file://localhost/tmp/x.txt").as_deref(), Some("/tmp/x.txt"));
assert_eq!(file_uri_to_path(" file:///tmp/%C4%8C.txt\r\n").as_deref(), Some("/tmp/\u{10C}.txt"));
for bad in ["http://example.com/a.png", "file://otherhost/tmp/x", "mailto:[email protected]", "/tmp/plain", "file://", "file:///tmp/a%00b"] { assert_eq!(file_uri_to_path(bad), None, "{bad}"); }
assert_eq!(file_uri_to_path("file:///100%").as_deref(), Some("/100%"));
}
#[test]
fn the_scripts_the_shell_sends_the_page_for_a_file_drop() {
assert_eq!(drag_script("enter"), "window.dispatchEvent(new CustomEvent('strata:native-drag', { detail: 'enter' }));");
+6
View File
@@ -17,3 +17,9 @@ export function appVersionFrom({ env = process.env, here }) {
} catch { /* not a checkout */ }
return 'dev';
}
/** The version "Strata" means on screen and in the update check: the app's own, not Strata Control's (see src/lib/app-version.ts). */
export function appVersionToShow(app, control) {
const real = value => (typeof value === 'string' && value.trim() && value.trim().toLowerCase() !== 'dev' ? value.trim() : null);
return real(app) ?? real(control) ?? (typeof app === 'string' && app.trim() ? app.trim() : null);
}
+4 -1
View File
@@ -75,8 +75,11 @@ export function createEngineStep({ control, signal = null, onChange = () => {} }
const modelRemaining = Number.isFinite(model?.remaining_gb) ? model.remaining_gb : null;
const active = !['done', 'current', 'skipped', 'failed'].includes(part.state);
const engineRemaining = active || part.state === 'done' ? Math.max(0, part.total_gb - part.done_gb) : 0;
/* The install knows its files (strata_mcp files_progress): the model's parts and the picture reader, each counted. Without that, one
model part from the combined figures. The engine is added either way. */
const own = Array.isArray(install.parts) && install.parts.some(p => p?.kind === 'model') ? install.parts.filter(p => p?.kind !== 'engine') : null;
const parts = [
{ kind: 'model', name: model?.file ?? install.model ?? 'Model', state: install.running === false ? 'done' : 'downloading', done_gb: modelDone ?? 0, total_gb: round((modelDone ?? 0) + (modelRemaining ?? 0)) },
...(own ?? [{ kind: 'model', name: model?.file ?? install.model ?? 'Model', state: install.running === false ? 'done' : 'downloading', done_gb: modelDone ?? 0, total_gb: round((modelDone ?? 0) + (modelRemaining ?? 0)) }]),
{ ...part },
];
const merged = { ...install, engine: { ...part }, parts };
+6 -3
View File
@@ -59,10 +59,11 @@ import { createChatFolders, openFile, resolveChatFile, previewFile, rawFile, cre
import { createLlmProxy, samplingFromExpert } from './agent/llm-shape.mjs';
import { checkUpdates, checkComponentUpdates } from './updates.mjs';
import { createAppUi } from './app-ui.mjs';
import { appVersionFrom } from './app-version.mjs';
import { appVersionFrom, appVersionToShow } from './app-version.mjs';
import { createImageModels } from './image-models.mjs';
import { createRuntimeModels } from './runtime-models.mjs';
import { createNativeControl } from './native-control.mjs';
import { nativeUnloadPlan } from './storage-check.mjs';
import { installBundledEngine } from './bundled-engine.mjs';
import { findControl, createControl, componentsDirectory } from './control-runtime.mjs';
import { forbidden as acquisitionForbidden } from './acquisition.mjs';
@@ -1287,7 +1288,7 @@ async function api(req, res, path, method, url) {
if (path === '/api/setup' && method === 'GET') return json(res, 200, setupState());
if (path === '/api/updates/check' && method === 'GET' && control?.updateCheck) return json(res, 200, await checkComponentUpdates({ control, appUpdater, appVersion: process.env.STRATA_APP_VERSION || null }));
if (path === '/api/updates/check' && method === 'GET') return json(res, 200, await checkUpdates({ appVersion:native.state().version??setupState().app.version, engineVersion:native.state().available?(native.state().installed?.engine?.version??null):(await engine.config()).engine.version }));
if (path === '/api/updates/check' && method === 'GET') return json(res, 200, await checkUpdates({ appVersion:appVersionToShow(setupState().app.version,native.state().version), engineVersion:native.state().available?(native.state().installed?.engine?.version??null):(await engine.config()).engine.version }));
if (path === '/api/setup/step' && method === 'POST') {
const body = await readBody(req);
@@ -1349,7 +1350,9 @@ async function api(req, res, path, method, url) {
if (storageChanging || modelOperations || models.directoryBusy() || imageModels.busy()||native.state().jobs.some(j=>j.state==='running')) return error(res, 409, 'Finish or cancel model downloads and loading before changing the folder.');
if (serving.state().requests.some(r => ['queued', 'generating'].includes(r.state))) return error(res, 409, 'Finish or cancel model requests before changing the folder.');
if (changed && instances.length && body.unload !== true) return error(res, 409, 'Unload the current models before changing the folder.');
if(native.state().instances.length){if(!body.unload)return error(res,409,'Unload native models before changing the model folder.');for(const instance of native.state().instances)await native.unload(instance.id);}
const nativePlan=nativeUnloadPlan({changed,unload:body.unload,instances:native.state().instances});
if(nativePlan.refuse)return error(res,409,nativePlan.refuse);
for(const id of nativePlan.ids)await native.unload(id);
storageChanging = true;
try {
if (changed) {
+9 -7
View File
@@ -25,6 +25,8 @@ const safeId=value=>typeof value==='string'&&/^[a-z0-9_-]{1,80}$/i.test(value);
const busy=requests=>requests.some(r=>['queued','generating'].includes(r.state));
function readJson(path,fallback={}){try{return JSON.parse(readFileSync(path,'utf8').replace(/^\uFEFF/,''));}catch{return fallback;}}
/** The plain words for "no usable engine": the model still downloads, but loading needs the engine (owner decision 2026-10-18). */
export const ENGINE_NOT_RECOGNISED='Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → Engine.';
export function createNativeControl({directory,requests=()=>[],changed=()=>{},spawnProcess=spawn,fetcher=fetch,modelsDirectory,nativeDataDirectory=value=>value,beforeLoad=async()=>{},hardwareProbe=()=>[],control=null,ready=null,appUpdater=null,defaultRepo=process.env.STRATA_NATIVE_REPO??resolve(ROOT,'../../Strata')}={}){
mkdirSync(directory,{recursive:true});
const settingsPath=join(directory,'native-host.json');
@@ -67,7 +69,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
for(const name of names){const path=join(dir,name),config=readJson(path,null);if(!config||typeof config!=='object')continue;
const next={...config,...(config.cwd!==undefined?{cwd:fix(config.cwd)}:{}),...(Array.isArray(config.args)?{args:config.args.map(fix)}:{})};
if(JSON.stringify(next)!==JSON.stringify(config)){try{writeModelConfig(path,next);}catch{/* read-only folder: the load reports it */}}}}
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;return {...entry,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,dataFolder,visionPrepared:!!cfg.vision,visionEnabled:cfg.ui_vision_enabled??[...instances.values()].some(i=>i.model===entry.model&&i.status==='ready'&&i.vision),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
function installedInfo(info){if(!info)return info;return {...info,models:(info.models??[]).map(entry=>{const cfg=/^strata-[a-z0-9_-]+\.json$/i.test(entry.config??'')?readJson(entry.configPath??join(repo(),entry.config)):{};const at=cfg.args?.indexOf('--native')??-1,nativeFile=at>=0?cfg.args[at+1]:null;const dataFolder=typeof nativeFile==='string'&&basename(dirname(dirname(nativeFile)))==='models'?dirname(dirname(dirname(nativeFile))):info.data_folder;return {...entry,diskGb:entry.diskGb??entry.disk_gb,diskBytes:entry.diskBytes??entry.disk_bytes,sharedBytes:entry.sharedBytes??entry.shared_bytes,removableGb:entry.removableGb??entry.removable_gb,dataFolder,visionPrepared:!!cfg.vision,/* Picture reading is ON unless the person turned it off: no saved choice and not loaded is undefined, not false (owner rule: pictures on by default). */visionEnabled:cfg.ui_vision_enabled??(()=>{const running=[...instances.values()].find(i=>i.model===entry.model&&i.status==='ready');return running?!!running.vision:undefined;})(),visionDevice:cfg.ui_vision_device,resourceProfile:cfg.ui_resource_profile,lowRamMode:cfg.args?.includes('--resident-experts')?'resident':cfg.args?.includes('--mmap-experts')?'mmap':'auto'};})};}
function installRoot(){const found=[preferences.installDir].find(d=>d&&existsSync(d));if(found)return realpathSync(found);mkdirSync(defaultInstall(),{recursive:true});return realpathSync(defaultInstall());}
function repo(){if(packaged)return installRoot();const path=realpathSync(preferences.repo);if(!existsSync(join(path,'setup.py'))||!existsSync(join(path,'tools/strata_mcp.py'))||!existsSync(join(path,'serve/server.py')))throw new Error('Choose the Strata repository folder containing setup.py and tools/strata_mcp.py.');return path;}
function python(){return resolvePython({repo:preferences.repo});}
@@ -96,12 +98,12 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
packagedInstall=list.install_dir;
const engine=await control.engine();
const installer=await mcpCall('strata_status',{hardware:true}).catch(error=>({error:error.message}));
const disk=Object.fromEntries((installer.installed?.models??[]).map(m=>[m.model,{diskGb:m.disk_gb,diskBytes:m.disk_bytes}]));
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present',images:'off',port:undefined,last_used:null}));
const disk=Object.fromEntries((installer.installed?.models??[]).map(m=>[m.model,{diskGb:m.disk_gb,diskBytes:m.disk_bytes,sharedBytes:m.shared_bytes,removableGb:m.removable_gb,partsMissing:m.parts_missing??0}]));
const models=(list.models??[]).filter(m=>m.component_status!=='invalid'&&m.source_config).map(m=>({...(disk[m.id]??{}),model:m.id,config:basename(m.source_config),configPath:m.source_config,model_name:m.name,context:m.context_tokens??0,backend:m.backend,ready:m.component_status==='present'&&!(disk[m.id]?.partsMissing),images:'off',port:undefined,last_used:null}));
const cores=os.cpus().length;
return {summary:engine.usable?`Strata Control is ready; engine ${engine.engine.version} (${engine.engine.platform}-${engine.engine.backend}).`:(engine.notice??'No engine for this PC was found.'),
installDir:list.install_dir,engine:{usable:engine.usable,notice:engine.notice,version:engine.engine?.version??null,backend:engine.backend,componentsDir:engine.components_dir,previous:engine.previous??null,pinned:engine.pinned??null,updateNotice:engine.update_notice??null},
installed:{strata_folder:list.install_dir,data_folder:null,engine:engine.engine?{version:engine.engine.version,backend:engine.engine.backend,folder:engine.engine.root}:{folder:null,version:null},models},
installed:{strata_folder:list.install_dir,data_folder:null,engine:engine.engine?{version:engine.engine.version,backend:engine.engine.backend,folder:engine.engine.root}:{folder:null,version:null},models,usage:installer.installed?.usage??null},
hardware:installer.hardware?{...installer.hardware,cpu:{...(installer.hardware.cpu??{}),cores:installer.hardware.cpu?.cores??cores},gpus:installer.hardware.gpus??[]}:{cpu:{cores},ram_gb:Math.round(os.totalmem()/1024**3),gpus:[]},
recommendation:installer.recommendation??null,installerError:installer.error??null,install:installer.install??null,running:null};
}
@@ -204,7 +206,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
return {cfg,resources,visionPreferences,parallelChoice,parallelNote};
}
async function loadEngine(job,args){
await refresh(true);if(job.state!=='running')return;const {config,entry,path:configFile}=record(args.model);assertSettingsFile(configFile);if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(engine.notice??'No engine for this PC was found.');}if(!entry.ready)throw new Error('Some model files are missing. Use Download / repair to restore them.');
await refresh(true);if(job.state!=='running')return;const {config,entry,path:configFile}=record(args.model);assertSettingsFile(configFile);if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready)throw new Error('Some model files are missing. Use Download / repair to restore them.');
if(externalRunning().length)throw new Error('A Strata server is already running outside this UI. Stop it before loading here, or connect its API in Settings → Models.');
if(args.multiple&&instances.size>=8)throw new Error('Unload an instance before adding another. Up to eight native instances can run here.');
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote}=buildConfig(args,config);
@@ -345,7 +347,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
async function tuneOff(args){await refresh();const {path}=tuneConfig(args);return autotuner.off(path);}
async function autotune(job,args){
await refresh(true);if(job.state!=='running')return;
if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(engine.notice??'No engine for this PC was found.');}
if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}
const {path}=tuneConfig(args);
if(externalRunning().length)throw new Error('Stop the native server running outside this UI before tuning.');
if(busy(requests().filter(r=>instances.has(r.instanceId))))throw new Error('Finish requests before tuning.');
@@ -355,7 +357,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const room=args.resourceProfile==='work';
return autotuner.start(job,job.kind==='recheck'?'recheck':'tune',{configPath:path,presetId:args.presetId,mode:args.mode,use:args.use,goal:room?'room':'speed',leaveRam:room?20:0,allowSmallDifferences:args.allowSmallDifferences===true,name:args.name},{onProgress:p=>{job.summary=p.text??job.summary;emit();}});
}
function operation(kind,args={}){if(!['install','load','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','remove'].includes(kind))throw new Error('Choose a supported native operation.');if(!snapshot.available)throw new Error(snapshot.error??'Connect the native Strata installation first.');if(kind==='install'||kind==='load'){const refusal=engineRefusal(snapshot.catalog,args.model,snapshot.engineInfo);if(refusal)throw new Error(refusal);}return begin(kind,job=>kind==='install'?install(job,args):kind==='load'?loadEngine(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):program(job,args),{guided:args.guided===true,model:args.model});}
function operation(kind,args={}){if(!['install','load','benchmark','tune','autotune','recheck','calibrate','update-engine','update-strata','rollback','remove'].includes(kind))throw new Error('Choose a supported native operation.');if(!snapshot.available)throw new Error(snapshot.error??'Connect the native Strata installation first.');if(kind==='install'||kind==='load'){const refusal=engineRefusal(snapshot.catalog,args.model,snapshot.engineInfo);if(refusal)throw new Error(refusal);}return begin(kind,job=>kind==='install'?install(job,args):kind==='load'?loadEngine(job,args):kind==='benchmark'?benchmark(job,args):kind==='tune'?tune(job,args):kind==='autotune'||kind==='recheck'?autotune(job,args):kind==='remove'?removeModel(job,args):program(job,args),{guided:args.guided===true,model:args.model,...(args.family?{family:args.family}:{})});}
async function cancel(id){const job=jobs.get(id);if(!job||job.state!=='running')throw new Error('This operation is no longer running.');if(job.kind==='install'){job.engineAbort?.abort();await tool('strata_install',{cancel:true});}else if(job.abort)job.abort.abort();else if(job.process){if(!['tune','calibrate','autotune','recheck'].includes(job.kind))throw new Error('File updates cannot be cancelled safely while files are being replaced.');job.state='cancelled';await stopProcess(job.process);}job.state='cancelled';job.summary=job.kind==='install'?'Stopped. Completed downloads are kept.':'Stopped.';emit();return state();}
async function close(){closing=true;clearInterval(timer);const installing=packaged&&child&&[...jobs.values()].some(j=>j.kind==='install'&&j.state==='running');if(installing)await send('tools/call',{name:'strata_install',arguments:{cancel:true}},15000).catch(()=>{});const operations=[];for(const job of jobs.values()){job.abort?.abort();if(job.process)operations.push(stopProcess(job.process));if(job.state==='running')job.state='cancelled';}await Promise.all(operations);await Promise.all([...instances.keys()].map(id=>unload(id,true).catch(()=>{})));disconnect();}
// Remove nonserializable process references from public jobs.
+1
View File
@@ -5,5 +5,6 @@ export function runtimeVision(config,requested){
if(mode==='no')return undefined;
if(mode===undefined)return config.vision;
if(!config.vision)throw new Error('Prepare this model’s vision files before enabling picture reading.');
if(mode==='cpu'&&config.vision.cpu_exe){const {cpu_exe,...rest}=config.vision;return {...rest,exe:cpu_exe,gpu:false};} // AMD: the CPU reader program, not the HIP one with the GPU hidden
return {...config.vision,...(mode==='cpu'?{gpu:false}:mode==='gpu'?{gpu:true}:{})};
}
+13
View File
@@ -0,0 +1,13 @@
/**
* What changing (or only checking) the model folder does to the models that are loaded.
*
* "Check folder" reads the SAME folder again, so nothing that is loaded may be touched: it used to unload every optimized model
* and the status went to "No model loaded" (QA 2026-10-18, O01). Only an actual change of folder unloads them, and only when
* the person agreed (the screen says so first and offers "Unload and change directory").
*/
export function nativeUnloadPlan({ changed, unload, instances }) {
const running = Array.isArray(instances) ? instances : [];
if (!running.length || !changed) return { refuse: null, ids: [] };
if (unload !== true) return { refuse: 'Unload native models before changing the model folder.', ids: [] };
return { refuse: null, ids: running.map(i => i.id) };
}
@@ -0,0 +1,17 @@
import { tr } from '../lib/i18n';
export interface InstallPart { kind?: string; name?: string; state?: string; done_gb?: number; total_gb?: number; files_done?: number; files_total?: number }
/**
* The parts of a download, one line each: the model (with "2/4" when it comes in parts), the picture reader, the engine. A model in several
* parts is only downloaded when every line says Done, so a person can see which part is under way instead of one bar that restarts.
*/
export function DownloadParts({ parts }: { parts: InstallPart[] | undefined }) {
if (!parts || parts.length < 2) return null;
return <ul className="native-parts" aria-label={tr("Download parts")}>
{parts.map(p => <li key={`${p.kind}-${p.name}`} data-state={p.state}>
<span>{tr(p.name)}{(p.files_total ?? 0) > 1 ? ` ${p.files_done ?? 0}/${p.files_total}` : ''}</span>
<small className="muted">{p.state === 'done' || p.state === 'current' ? tr('Done') : p.state === 'waiting' || p.state === 'pending' ? tr('Waiting') : Number.isFinite(p.done_gb) && Number.isFinite(p.total_gb) ? `${p.done_gb!.toFixed(1)} / ${p.total_gb!.toFixed(1)} GB` : ''}</small>
</li>)}
</ul>;
}
@@ -0,0 +1,12 @@
import { tr } from '../lib/i18n';
import { Icon } from './ui';
import type { NativeState } from '../lib/native';
/** The app could not find or recognise an engine for this computer (none installed and none in the feed, another card family, a driver that is too old). It still downloads the model
* (owner decision 2026-10-18) and says so before and during the download; loading says the same with the next step. */
export const engineMissing = (native: NativeState | null | undefined) => !!native?.engine && native.engine.usable === false;
export function EngineWarning({ native }: { native: NativeState | null | undefined }) {
if (!engineMissing(native)) return null;
return <div className="engine-warning" role="status"><Icon name="info" size={18}/><div><p>{tr("Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → Engine).")}</p>{native?.engine?.notice ? <small className="muted">{native.engine.notice}</small> : null}</div></div>;
}
+15 -4
View File
@@ -12,7 +12,11 @@ import { nativeAction, useNative, type NativeJob } from '../lib/native';
import { completeSetup, completeSetupStep, go, newSession, openSetup, selectRemoteModel, setSettingsSection, useStore } from '../lib/store';
import type { LocalModelFolder, SetupState } from '../lib/types';
import { downloadCounter, modelFolderLabel, onboardingError } from '../lib/onboarding-download';
import { installProgress, useDownloadEta } from '../lib/download-eta';
import { installProgress, installStage, stageText, useDownloadEta } from '../lib/download-eta';
import { VersionPicker } from './VersionPicker';
import { DownloadParts } from './DownloadParts';
import { EngineWarning } from './EngineWarning';
import { recommendedVersions } from '../lib/native-fit';
import { nativeCatalogModels, catalogVersionLabel } from '../lib/native-catalog';
type Choices = NonNullable<NonNullable<SetupState['choices']>['onboarding']>;
function radioKeys(event: KeyboardEvent<HTMLDivElement>) { if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
@@ -161,6 +165,12 @@ export function GuidedSetup() {
} });
return () => { cancelled = true; };
}, [step, plan, preparing, native?.available, version?.id, context, directory]);
const recommendedCards = recommendedVersions(selectedFamily?.sizes ?? []).map(x => x.v);
// The recommended four first, then a list with every other version (the Models page's version list); a family without a recommended set shows them all.
const cards = recommendedCards.length ? recommendedCards : selectedFamily?.sizes ?? [];
const others = recommendedCards.length ? (selectedFamily?.sizes ?? []).filter(v => !recommendedCards.includes(v)) : [];
const readyIds = new Set((native?.installed?.models ?? []).filter(m => m.ready).map(m => m.model));
const versionCard = (v: NonNullable<typeof version>) => <button key={v.id} type="button" role="radio" tabIndex={version?.id === v.id ? 0 : -1} aria-checked={version?.id === v.id} data-selected={version?.id === v.id} onClick={() => { setSize(v.id); setPlan(null); }}><span className="onboard-version-top"><span className="onboard-version-copy"><strong>{tr(catalogVersionLabel(v))}</strong><small>{tr(v.model)}</small></span><span className="onboard-version-radio">{tr(version?.id === v.id ? <Icon name="check" size={12}/> : null)}</span></span><span className="onboard-version-bottom"><span className="onboard-version-size"><Icon name="download" size={15}/>{tr(v.download_gb.toFixed(1))} <small>{tr("GB")}</small></span><span className="onboard-version-fit">{tr(v.on_this_pc === 'does not fit' || v.on_this_pc === 'tight' || v.on_this_pc?.includes('slower') || v.on_this_pc?.startsWith('experimental:') ? <><Icon name="clock" size={13}/>{tr("May run slower")}</> : v.installed ? <><Icon name="check" size={13}/>{tr("On this computer")}</> : <>{tr("Download size")}</>)}</span></span></button>;
const counter = downloadCounter(plan?.plan?.download_gb ?? version?.download_gb, progress, preparing);
const eta = useDownloadEta(preparing ? progress : undefined, installJob?.id);
const title = step === 1 ? 'State of the art. At home.' : step === 2 ? 'Select the model you want to run.' : step === 3 ? 'Downloading the model...' : tuneJob?.state === 'running' ? 'A little tuning. A better fit.' : 'How you would like to work with Strata?';
@@ -179,13 +189,14 @@ export function GuidedSetup() {
<div className="onboard-model-top"><div className="onboard-model-picker"><span className="onboard-control-label">{tr("Model")}</span><div><span className="onboard-model-symbol"><Icon name="models" size={23}/></span><Select ariaLabel={tr("Setup model")} value={selectedFamily.family} options={families.map(f => ({ value: f.family, label: f.title }))} onChange={value => chooseNative(value, '')}/></div></div><div className="onboard-context"><span className="onboard-control-label">{tr("Conversation context")}</span><Select ariaLabel={tr("Setup conversation context")} value={String(context)} options={(native?.catalog?.contexts ?? [32768]).map(n => ({ value: String(n), label: `${n / 1024}K` }))} onChange={value => setContext(Number(value))}/></div></div>
<OnboardingMemory hardware={hardware} native={native?.hardware}/>{tr(!version || version.needsNewerEngine ? <p className="small muted">{tr("This model needs a newer engine. Update your engine or import a local GGUF for this model.")}</p> : null)}
<div className="onboard-version-heading"><strong>{tr("Choose a version")}</strong><span>{tr(version ? `${catalogVersionLabel(version)} selected` : '')}</span></div>
<div className="onboard-versions" role="radiogroup" aria-label={tr("Model version")} onKeyDown={radioKeys}>{tr(selectedFamily.sizes.map(v => <button key={v.id} type="button" role="radio" tabIndex={version?.id === v.id ? 0 : -1} aria-checked={version?.id === v.id} data-selected={version?.id === v.id} onClick={() => { setSize(v.id); setPlan(null); }}><span className="onboard-version-top"><span className="onboard-version-copy"><strong>{tr(catalogVersionLabel(v))}</strong><small>{tr(v.model)}</small></span><span className="onboard-version-radio">{tr(version?.id === v.id ? <Icon name="check" size={12}/> : null)}</span></span><span className="onboard-version-bottom"><span className="onboard-version-size"><Icon name="download" size={15}/>{tr(v.download_gb.toFixed(1))} <small>{tr("GB")}</small></span><span className="onboard-version-fit">{tr(v.on_this_pc === 'does not fit' || v.on_this_pc === 'tight' || v.on_this_pc?.includes('slower') || v.on_this_pc?.startsWith('experimental:') ? <><Icon name="clock" size={13}/>{tr("May run slower")}</> : v.installed ? <><Icon name="check" size={13}/>{tr("On this computer")}</> : <>{tr("Download size")}</>)}</span></span></button>))}</div>
<div className="onboard-versions" role="radiogroup" aria-label={tr("Model version")} onKeyDown={radioKeys}>{tr(cards.map(v => versionCard(v)))}</div>
{tr(others.length ? <div className="onboard-more-versions"><VersionPicker versions={others} selectedId={version?.id ?? ''} hardware={hardware ? { ram_gb: native?.hardware?.ram_gb ?? hardware.memory.totalGb, vram_gb: Math.max(0, ...(native?.hardware?.gpus ?? []).filter(g => g.usable !== false).map(g => g.vram_gb)) } : null} downloaded={readyIds} showRecommended={false} moreLabel="More versions" onSelect={id => { setSize(id); setPlan(null); }} onOwnGguf={() => setFolderOpen(true)}/></div> : null)}
<div className="onboard-folder"><Icon name="folder" size={20}/><div><strong>{tr(directory ? 'Your model folder' : 'Already downloaded the model?')}</strong><small>{tr(directory || "Choose the folder and we'll start your models with Strata.")}</small></div><Btn size="sm" onClick={() => setFolderOpen(v => !v)}>{tr(folderOpen ? 'Close' : directory ? 'Change' : 'Choose folder')}</Btn></div>
{tr(folderOpen ? <OnboardingFolder directory={folder} onChoose={path => void perform(async () => { await nativeAction('directory', { directory: path }); setDirectory(path); setFolderOpen(false); setPlan(null); })}/> : null)}
</> : <div className="onboard-engine-connect"><Icon name="models" size={24}/><strong>{tr("Connect your Strata installation")}</strong><Btn onClick={() => void skip(false, 'Engine')}>{tr("Connect engine")}</Btn></div>)}
</div> : step === 3 ? <div className="onboard-prepare-stage">
<section className="onboard-download"><div className="onboard-download-identity"><Icon name="models" size={19}/><strong>{tr(selectedFamily?.title)}</strong><span>{tr(version ? catalogVersionLabel(version) : '')}</span><small>{tr(version?.model)}</small></div><OnboardingArt kind="download" progress={progress?.percent ?? 0} counter={counter}/><div className="onboard-download-info">
{tr(preparing ? <div><strong className="onboard-download-status">{tr(progress ? 'Downloading model files…' : 'Preparing your model…')}</strong><div className="onboard-progress" data-indeterminate={!progress}>{tr(progress ? <progress max={100} value={progress.percent ?? 0} aria-label={tr("Current file download progress")}/> : <span />)}</div><div className="onboard-progress-meta"><span>{tr(progress ? `${progress.percent ?? 0}%${Number.isFinite(progress.done_gb) && Number.isFinite(progress.total_gb) ? ` · ${progress.done_gb!.toFixed(1)} / ${progress.total_gb!.toFixed(1)} GB` : ''}` : 'Getting files ready…')}</span><div className="onboard-progress-actions">{tr(eta ? <span aria-live="off">{eta}</span> : null)}<button disabled={pending} onClick={() => void perform(async () => { await nativeAction(`operations/${installJob!.id}/cancel`); })}>{tr("Stop")}</button></div></div></div> : <>{tr(stopped ? <OnboardingError message={installJob?.error ?? 'Download stopped. Ready when you are.'} onFolder={changeDownloadFolder}/> : null)}{tr(plan?.plan?.disk_short ? <OnboardingError message={plan.plan.disk_short} onFolder={changeDownloadFolder}/> : null)}</>)}
<section className="onboard-download"><EngineWarning native={native}/><div className="onboard-download-identity"><Icon name="models" size={19}/><strong>{tr(selectedFamily?.title)}</strong><span>{tr(version ? catalogVersionLabel(version) : '')}</span><small>{tr(version?.model)}</small></div><OnboardingArt kind="download" progress={progress?.percent ?? 0} counter={counter}/><div className="onboard-download-info">
{tr(preparing ? <div><strong className="onboard-download-status">{tr(progress ? stageText(installStage(installJob?.result?.install)) : 'Preparing your model…')}</strong><div className="onboard-progress" data-indeterminate={!progress}>{tr(progress ? <progress max={100} value={progress.percent ?? 0} aria-label={tr("Current file download progress")}/> : <span />)}</div><DownloadParts parts={installJob?.result?.install?.parts}/><div className="onboard-progress-meta"><span>{tr(progress ? `${progress.percent ?? 0}%${Number.isFinite(progress.done_gb) && Number.isFinite(progress.total_gb) ? ` · ${progress.done_gb!.toFixed(1)} / ${progress.total_gb!.toFixed(1)} GB` : ''}` : 'Getting files ready…')}</span><div className="onboard-progress-actions">{tr(eta ? <span aria-live="off">{eta}</span> : null)}<button disabled={pending} onClick={() => void perform(async () => { await nativeAction(`operations/${installJob!.id}/cancel`); })}>{tr("Stop")}</button></div></div></div> : <>{tr(stopped ? <OnboardingError message={installJob?.error ?? 'Download stopped. Ready when you are.'} onFolder={changeDownloadFolder}/> : null)}{tr(plan?.plan?.disk_short ? <OnboardingError message={plan.plan.disk_short} onFolder={changeDownloadFolder}/> : null)}</>)}
<small className="onboard-download-location" title={tr(folder)}><Icon name="folder" size={16}/><span><span>{tr("Save to")}</span><strong>{tr(modelFolderLabel(folder))}</strong></span>{tr(!preparing ? <button type="button" onClick={changeDownloadFolder}>{tr("Change")}</button> : null)}</small>
</div></section><OnboardingTour page={tour} onChange={setTour}/>
</div> : <div className="onboard-tune-stage">
+5 -3
View File
@@ -14,7 +14,9 @@ export function NativeDeleteDialog({ id, onClose }: { id: string | null; onClose
const label = (() => { const sizes = nativeCatalogModels(native?.catalog?.families ?? []).flatMap(f => f.sizes); const v = sizes.find(s => s.id === id); return v ? `${catalogVersionLabel(v)} · ${v.model}` : entry?.model_name ?? id ?? ''; })();
const running = !!native?.instances.some(i => i.model === id);
const busy = !!native?.jobs.some(j => j.state === 'running');
const gb = entry?.diskGb ?? 0;
const size = entry?.diskGb ?? 0, gb = entry?.removableGb ?? size;
// Files in the person's own folder (an adopted GGUF) stay where they are: only the entry is removed.
const keepsFiles = entry?.removableGb === 0 && size > 0;
function close() { if (!pending) { onClose(); setError(''); } }
async function remove() {
setPending(true);
@@ -36,8 +38,8 @@ export function NativeDeleteDialog({ id, onClose }: { id: string | null; onClose
finally { setPending(false); }
}
return <Dialog open={!!id} compact title={tr("Delete this version?")} onClose={close} actions={<><Btn disabled={pending} onClick={close}>{tr("Keep version")}</Btn><Btn variant="danger" disabled={pending || busy || !entry} onClick={() => void remove()}>{tr(pending ? 'Working…' : 'Delete version')}</Btn></>}>
<p>{tr(label)}{tr(gb ? ` · ${sizeGb(gb)}` : '')}</p>
{tr(entry ? <p className="small muted storage-delete-note">{tr('Remove {0} GB of downloaded files. You can download this version again.', [gb ? gb.toFixed(1) : '0'])}{tr(running ? ' ' : '')}{tr(running ? 'The running model will be unloaded and its files deleted.' : '')}</p> : <p className="small muted">{tr("This version is no longer downloaded.")}</p>)}
<p>{tr(label)}{tr(size ? ` · ${sizeGb(size)}` : '')}</p>
{tr(!entry ? <p className="small muted">{tr("This version is no longer downloaded.")}</p> : <p className="small muted storage-delete-note">{keepsFiles ? tr('These files are in your own folder, so they stay there. Only the entry in Strata is removed.') : tr('Remove {0} GB of downloaded files. You can download this version again.', [gb ? gb.toFixed(1) : '0'])}{tr(running ? ' ' : '')}{tr(running ? (keepsFiles ? 'The running model will be unloaded.' : 'The running model will be unloaded and its files deleted.') : '')}</p>)}
{tr(error ? <ErrorNotice message={error}/> : null)}
</Dialog>;
}
+53 -32
View File
@@ -6,7 +6,10 @@ import { FamilySwitch } from './FamilySwitch';
import { PcFitCard } from './PcFitCard';
import { fitOn } from '../lib/native-fit';
import { Btn, Dialog, ErrorNotice, Icon, Select } from './ui';
import { installProgress, useDownloadEta } from '../lib/download-eta';
import { installProgress, installStage, stageText, useDownloadEta } from '../lib/download-eta';
import { useKept } from '../lib/kept-state';
import { DownloadParts } from './DownloadParts';
import { EngineWarning } from './EngineWarning';
import { sizeGb } from '../lib/format';
import { NativeDeleteDialog } from './NativeDelete';
import { plainFailureText } from '../lib/plain-errors';
@@ -31,12 +34,14 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
onReady?: () => void;
}) {
const native = useNative(), imageLibrary = useStore(s => s.imageModels), hardware = useStore(s => s.hardware);
const [family, setFamily] = useState(''), [size, setSize] = useState(''), [context, setContext] = useState(32768), [vision, setVision] = useState('yes');
// The choices are kept while the page is closed (Models -> Monitor -> Models used to start them over and a person lost track of what was downloading).
const [family, setFamily] = useKept('models.family', ''), [size, setSize] = useKept('models.size', ''), [context, setContext] = useKept('models.context', 32768), [vision, setVision] = useKept('models.vision', 'yes');
const manual = useStore(s => s.setup?.choices?.onboarding?.manual ?? false);
const [expert, setExpert] = useState(manual), [multiple, setMultiple] = useState(false), [gpus, setGpus] = useState<number[]>([]), [kv, setKv] = useState('int8'), [lowRam, setLowRam] = useState('auto'), [split, setSplit] = useState('auto'), [deviceChoice, setDeviceChoice] = useState('auto');
const [cardsKey, setCardsKey] = useState<string | null>(null);
const [resourceProfile, setResourceProfile] = useState<'work' | 'performance'>('work');
const [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [loadedError, setLoadedError] = useState(''), [tuneOpen, setTuneOpen] = useState(false), [jobId, setJobId] = useState(''), [manage, setManage] = useState(false);
const [expert, setExpert] = useKept('models.expert', manual), [multiple, setMultiple] = useKept('models.multiple', false), [gpus, setGpus] = useKept<number[]>('models.gpus', []), [kv, setKv] = useKept('models.kv', 'int8'), [lowRam, setLowRam] = useKept('models.lowRam', 'auto'), [split, setSplit] = useKept('models.split', 'auto'), [deviceChoice, setDeviceChoice] = useKept('models.device', 'auto');
const [cardsKey, setCardsKey] = useKept<string | null>('models.cards', null);
const [resourceProfile, setResourceProfile] = useKept<'work' | 'performance'>('models.profile', 'work');
const [pending, setPending] = useState(false), [error, setError] = useState(''), [plan, setPlan] = useState<Record<string, any> | null>(null), [loadedError, setLoadedError] = useState(''), [tuneOpen, setTuneOpen] = useState(false), [jobId, setJobId] = useKept('models.job', ''), [manage, setManage] = useState(false);
const [toastedJob, setToastedJob] = useKept('models.toasted', ''), [appliedSig, setAppliedSig] = useKept('models.applied', '');
const presetPending = useRef(false);
const [deleting, setDeleting] = useState<string | null>(null);
const families = nativeCatalogModels(native?.catalog?.families ?? []);
@@ -56,15 +61,11 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
const keptUse = keptModels.map(i => { const s = sizeOf(i.model); const card = pcGpus.find(g => g.index === i.gpus[0]); const f = s && pcRam ? fitOn(s, { ram_gb: pcRam, vram_gb: card?.vram_gb ?? pcVram }) : null; return { gpus: i.gpus, ram: f?.ramUseGb ?? 0, vram: f?.vramUseGb ?? 0 }; });
const keptRam = keptUse.reduce((n, u) => n + u.ram, 0);
const keptVramOn = (cards: number[]) => keptUse.filter(u => u.gpus.some(g => cards.includes(g))).reduce((n, u) => n + u.vram, 0);
// The versions that are really on this computer: a config whose files are all there (every part of a model in parts).
const readyIds = useMemo(() => new Set(downloaded.filter(m => m.ready).map(m => m.model)), [downloaded]);
// What a running job works on, by its name in the list: "Qwen3.8-Flash-Next · Gyro · Smallest".
const jobLabel = (j: NativeJob) => { const v = allSizes.find(s => s.id === j.model || (s.model === j.model && (!j.family || s.sourceFamily === j.family))); const f = v && families.find(x => x.sizes.includes(v)); return v ? [f?.title, catalogVersionLabel(v)].filter(Boolean).join(' · ') : ''; };
const loadedSelected = native?.instances.find(i => i.model === selectedSize?.id && i.status === 'ready');
// A model that is already on this PC loads as it was prepared: the page shows its settings, so loading it never asks to download.
useEffect(() => { if (installed?.ready) {
if (installed.context)
setContext(installed.context);
if (installed.kv)
setKv(installed.kv as typeof kv);
setLowRam((installed.lowRamMode ?? 'auto') as typeof lowRam);
} }, [installed?.model, installed?.ready, installed?.context, installed?.kv, installed?.lowRamMode]);
// What the running model was really started with is what the page shows: after Load the highlight must still be the profile that was applied.
useEffect(() => { if (loadedSelected?.resourceProfile)
setResourceProfile(loadedSelected.resourceProfile); }, [loadedSelected?.id, loadedSelected?.resourceProfile]);
@@ -93,7 +94,8 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
const gpuNumbers = (text: string) => text.replace(/\(card (\d+)\)/g, (_m, n) => `(GPU ${Number(n) + 1})`);
// The fit sentence for a card choice, less what the kept models take of RAM and of that card's memory.
const lessKept = (words: string, cards: number[]) => { const gone = Math.round(keptRam + keptVramOn(cards)); return gone > 0 ? words.replace(/of the (\d+) GB available/, (_m, n) => `of the ${Math.max(0, Number(n) - gone)} GB available`) : words; };
useEffect(() => { setCardsKey(null); }, [selectedSize?.id]);
const shownSize = useRef(selectedSize?.id);
useEffect(() => { if (shownSize.current !== selectedSize?.id) { shownSize.current = selectedSize?.id; setCardsKey(null); } }, [selectedSize?.id]);
const values = useMemo(() => ({ family: selectedSize?.sourceFamily, model: selectedSize?.model, context, vision: selectedSize?.images ? vision : 'no', kv, low_ram: lowRam, resourceProfile, ...(expert && gpus.length ? { gpus: gpus.join(',') } : cardsGpus.length > 1 ? { gpus: cardsGpus.join(',') } : cardsGpus.length === 1 ? { gpu: cardsGpus[0] } : {}), loadAfter: true, multiple: expert && multiple }), [selectedSize?.sourceFamily, selectedSize?.model, context, vision, kv, lowRam, resourceProfile, gpus.join(','), cardsGpus.join(','), expert, multiple]);
const downloadValues = { ...values, vision: selectedSize?.images ? 'yes' : 'no', loadVision: selectedSize?.images ? vision : 'no' };
useEffect(() => { if (!native?.catalog || family)
@@ -102,19 +104,36 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
setSize(f.sizes.find(s => s.id === current?.model)?.id ?? f.sizes.find(s => have.has(s.id))?.id ?? f.sizes.find(s => s.model === rec.model)?.id ?? f.sizes[0]?.id ?? '');
setContext(('context' in rec ? rec.context : undefined) ?? 32768);
} }, [native?.catalog]);
useEffect(() => { if (presetPending.current) {
presetPending.current = false;
return;
} if (installed) {
setContext(installed.context ?? 32768);
setVision(installed.visionEnabled === false || installed.images === 'off' ? 'no' : installed.visionDevice ?? installed.images ?? 'yes');
setKv(installed.kv ?? 'int8');
setLowRam(installed.lowRamMode ?? 'auto');
// A model never tuned has no saved choice: keep the page's own (Leave 20%) rather than flipping to Full performance.
if (installed.resourceProfile && !loadedSelected?.resourceProfile)
setResourceProfile(installed.resourceProfile);
} }, [installed?.model, installed?.context, installed?.kv, installed?.images, installed?.visionEnabled, installed?.visionDevice, installed?.lowRamMode, installed?.resourceProfile]);
useEffect(() => { if (jobId && job?.state === 'done' && (job.kind === 'load' || job.kind === 'install')) {
// The version that is selected: a model already on this PC shows the settings it was prepared with (so loading it never asks to download);
// a model that is not yet downloaded starts with Read pictures ON (owner rule: pictures on by default). This runs when the SELECTION or the
// installed settings change, not when the page opens again: coming back from another tab keeps what was chosen.
useEffect(() => {
if (presetPending.current) {
presetPending.current = false;
return;
}
if (!selectedSize)
return;
const sig = installed ? ['have', installed.model, installed.ready, installed.context, installed.kv, installed.images, installed.visionEnabled, installed.visionDevice, installed.lowRamMode, installed.resourceProfile].join('|') : `new|${selectedSize.id}`;
if (sig === appliedSig)
return;
setAppliedSig(sig);
if (installed) {
if (installed.context)
setContext(installed.context);
// Off only when the person turned it off (a saved choice); a model prepared without the picture files (images 'off') still starts with it ON.
setVision(installed.visionEnabled === false ? 'no' : installed.visionDevice ?? (installed.images && installed.images !== 'off' ? installed.images : 'yes'));
setKv(installed.kv ?? 'int8');
setLowRam(installed.lowRamMode ?? 'auto');
// A model never tuned has no saved choice: keep the page's own (Leave 20%) rather than flipping to Full performance.
if (installed.resourceProfile && !loadedSelected?.resourceProfile)
setResourceProfile(installed.resourceProfile);
}
else
setVision('yes');
}, [selectedSize?.id, installed?.model, installed?.ready, installed?.context, installed?.kv, installed?.images, installed?.visionEnabled, installed?.visionDevice, installed?.lowRamMode, installed?.resourceProfile]);
useEffect(() => { if (jobId && job?.state === 'done' && job.id !== toastedJob && (job.kind === 'load' || job.kind === 'install')) {
setToastedJob(job.id);
selectRemoteModel(null);
pushToast('success', 'Model ready', 'Start a new chat when you’re ready.');
} }, [job?.state]);
@@ -153,7 +172,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
{tr(selectedSize ? <>
<div className="native-choice__head"><span className="native-choice__label">{tr("Version")}</span><span className="native-choice__tools"><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
<div className="native-version"><VersionPicker versions={allSizes} selectedId={selectedSize.id} hardware={fitHardware} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
<div className="native-version"><VersionPicker versions={allSizes} selectedId={selectedSize.id} hardware={fitHardware} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
{tr(noFit ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("May run slower \u00B7 Model data can be read from SSD.")}</p> : null)}
<div className="native-choice__row">
<div className="native-choice__col"><span className="native-choice__label">{tr("Conversation context")}</span><Select ariaLabel={tr("Conversation context")} value={String(context)} options={(native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144]).map(n => ({ value: String(n), label: `${n / 1024}K` }))} onChange={n => setContext(Number(n))}/></div>
@@ -163,7 +182,8 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
{tr(selectedSize?.imagesCapable && !selectedSize.images ? <p className="small muted native-spill-note">{tr("This model can read pictures, but the engine installed here does not include the picture reader yet, so Read pictures is off. Update the engine in Settings → About to turn it on.")}</p> : null)}
{tr(selectedSize?.images ? <><div className="native-image-reading"><label><input type="checkbox" checked={vision !== 'no'} disabled={!selectedSize?.images || !!active} onChange={e => setVision(e.target.checked ? 'yes' : 'no')}/><span><strong>{tr("Read pictures ")}<small>{tr("On by default")}</small></strong><small>{tr("Understand photos, screenshots and charts. The picture files download automatically. Turn it off to save memory.")}</small></span></label>{tr(expert && vision !== 'no' ? <Select ariaLabel={tr("Image encoder device")} value={vision === 'yes' ? 'yes' : vision} options={[{ value: 'yes', label: 'Automatic' }, { value: 'gpu', label: 'GPU' }, { value: 'cpu', label: 'CPU' }]} onChange={setVision}/> : null)}</div>{tr(vision !== 'no' && installed?.ready && installed.visionPrepared === false ? <div className="native-vision-missing" role="status"><span>{tr("Picture reading needs a few more files that are not on this computer yet.")}</span><Btn size="sm" disabled={pending || !!active} onClick={() => void download()}>{tr("Download picture-reading files")}</Btn></div> : null)}</> : null)}
</> : null)}
{tr(active ? <NativeProgress job={active}/> : null)}
<EngineWarning native={native}/>
{tr(active ? <NativeProgress job={active} label={jobLabel(active)}/> : null)}
{tr(job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} action={tr("Retry")} onRetry={() => void load()}/> : null)}
{tr(error ? <ErrorNotice message={error}/> : null)}
{selectedSize && installed?.ready ? <NativeTuneBadge model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
@@ -195,8 +215,9 @@ function installStepLabel(step?: string): string {
return step ? 'Getting things ready…' : 'Downloading and preparing model files…';
return n <= 2 ? 'Checking your computer…' : n <= 4 ? 'Getting things ready…' : n === 5 ? 'Downloading your model…' : n === 6 ? 'Preparing your model for Strata…' : 'Finishing up…';
}
export function NativeProgress({ job }: {
export function NativeProgress({ job, label }: {
job: NativeJob;
label?: string;
}) { const download = installProgress(job.result?.install); const detailsText = [job.details, job.result?.install?.engine?.details].filter(Boolean).join('\n'); const eta = useDownloadEta(job.kind === 'install' ? download : undefined, job.id); const [stopping, setStopping] = useState(false), [error, setError] = useState(''); async function stop() { setStopping(true); setError(''); try {
await nativeAction(`operations/${job.id}/cancel`);
}
@@ -205,4 +226,4 @@ catch (e) {
}
finally {
setStopping(false);
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? 'Preparing your model' : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? installStepLabel(job.result?.install?.step) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
} } return <section className="st-card native-progress" aria-live="polite"><div className="section-toolbar"><h2 className="h4">{tr(job.kind === 'install' ? (label ? tr('Preparing {0}', [label]) : 'Preparing your model') : job.kind === 'load' ? 'Loading model' : job.kind === 'autotune' || job.kind === 'recheck' ? 'Tuning your model' : 'Engine operation')}</h2>{tr(job.kind === 'install' || job.kind === 'load' || job.kind === 'autotune' || job.kind === 'recheck' ? <div className="onboard-progress-actions">{tr(eta ? <span className="small muted">{eta}</span> : null)}<Btn size="sm" disabled={stopping} onClick={() => void stop()}>{tr(stopping ? 'Stopping…' : 'Stop')}</Btn></div> : null)}</div><p className="small">{tr(job.kind === 'install' ? (download && /^5\//.test(job.result?.install?.step ?? '') ? stageText(installStage(job.result?.install)) : installStepLabel(job.result?.install?.step)) : job.summary)}</p>{tr(download ? <><progress max={100} value={download.percent ?? 0}/><p className="xs muted">{tr(download.file === 'model files' ? 'Model files' : download.file === 'picture reader' ? 'Picture reader' : download.file)} · {tr(download.percent ?? 0)}%{tr(Number.isFinite(download.done_gb) && Number.isFinite(download.total_gb) ? ` · ${download.done_gb!.toFixed(1)} / ${download.total_gb!.toFixed(1)} GB` : '')}</p></> : null)}<DownloadParts parts={job.result?.install?.parts}/>{tr(detailsText ? <details><summary className="small">{tr("Details")}</summary><pre className="small muted error-detail-text">{detailsText}</pre></details> : null)}{tr(error ? <ErrorNotice message={error}/> : null)}</section>; }
+14 -10
View File
@@ -1,6 +1,6 @@
import { tr } from '../lib/i18n';
import { useState } from 'react';
import { Btn, Dialog, ErrorNotice, Field, Icon, Select } from './ui';
import { Btn, Dialog, ErrorNotice, Field, Icon } from './ui';
import { changeModelDirectory, getState, lifecycleStatus, removeVariant, runtimeModelAction, unloadModel, useStore } from '../lib/store';
import { pickerGroups, pickerVersionState } from '../lib/model-picker';
import { sizeGb } from '../lib/format';
@@ -17,6 +17,12 @@ export function SettingsStorage() {
const [directoryPending, setDirectoryPending] = useState(false), [directoryError, setDirectoryError] = useState(''), [notice, setNotice] = useState('');
const library = state.library;
const versions = library ? pickerGroups(library).flatMap(group => group.versions) : [];
// ONE list and ONE total: the optimized models (sizes from their real files, shared files counted once) and the other local models together.
const nativeModels = native?.installed?.models ?? [];
const sharedGb = (native?.installed?.usage?.shared_bytes ?? 0) / 1e9;
const nativeGb = native?.installed?.usage ? native.installed.usage.total_gb : nativeModels.reduce((n, m) => n + (m.diskGb ?? 0), 0);
const totalGb = nativeGb + versions.reduce((n, v) => n + (v.diskGb ?? 0), 0);
const rows = nativeModels.length + versions.length;
const version = versions.find(v => v.id === selected);
const running = !!version && (state.runtime?.instances.some(i => i.variantId === version.id) || library?.loaded === version.id);
const lifecycle = lifecycleStatus(state);
@@ -29,7 +35,7 @@ export function SettingsStorage() {
setDirectoryError('');
setNotice('');
try {
const result = await changeModelDirectory(path, true);
const result = await changeModelDirectory(path, path.trim() !== currentDirectory); // checking the same folder unloads nothing
const count = result.library.variants.filter(v => ['installed', 'loaded'].includes(v.state)).length + result.imageModels.variants.filter(v => v.state === 'installed').length;
setEditing(false);
setNotice(`${count} ${count === 1 ? 'version' : 'versions'} found${result.library.mode === 'simulated' ? ' in this example library' : ''}. Future downloads will use this folder.`);
@@ -92,15 +98,13 @@ export function SettingsStorage() {
{tr(notice ? <p className="small storage-directory__notice" role="status"><Icon name="check" size={15}/>{tr(notice)}</p> : null)}
{tr(directoryError ? <ErrorNotice message={directoryError}/> : null)}
</section>
<Field label={tr("Downloaded models")} hint={tr("{0} on this computer. Choose a version to delete its files.", [sizeGb(library?.disk.usedGb ?? 0)])}>
<Select value="" ariaLabel={tr("Delete downloaded model version")} menuSize="comfortable" menuMinWidth={520} singleLine disabled={!versions.length || pending || directoryPending} options={[
{ value: '', label: versions.length ? 'Choose a version…' : 'No downloaded models', disabled: true },
...versions.map(v => ({ value: v.id, label: `${v.local ? v.name : v.family} · ${v.quant} · ${sizeGb(v.diskGb)}${v.local ? ' · Local' : ''}`, disabled: pickerVersionState(v, library?.loaded ?? null).busy })),
]} onChange={id => { setError(''); setSelected(id); }}/>
<Field wide label={tr("Downloaded models")} hint={tr("{0} on this computer. Choose a version to delete its files.", [sizeGb(totalGb)])}>
{tr(rows ? <ul className="storage-models" aria-label={tr("Downloaded models")}>
{tr(nativeModels.map(m => <li className="storage-model" key={m.model}><span className="storage-model__name" title={m.model_name || m.model}><strong>{nativeShortName(m.model)}</strong>{tr(native?.instances.some(i => i.model === m.model) ? <small className="muted">{tr('Loaded')}</small> : null)}</span><span className="storage-model__size muted">{tr(sizeGb(m.diskGb ?? 0))}</span><Btn size="sm" variant="danger" disabled={!!native?.jobs.some(j => j.state === 'running')} onClick={() => setNativeDeleting(m.model)}>{tr("Delete")}</Btn></li>))}
{tr(versions.map(v => <li className="storage-model" key={v.id}><span className="storage-model__name" title={`${v.family} · ${v.quant}`}><strong>{`${v.local ? v.name : v.family} · ${v.quant}`}</strong>{tr(v.local ? <small className="muted">{tr('Local')}</small> : null)}{tr(v.id === library?.loaded ? <small className="muted">{tr('Loaded')}</small> : null)}</span><span className="storage-model__size muted">{tr(sizeGb(v.diskGb))}</span><Btn size="sm" variant="danger" disabled={pickerVersionState(v, library?.loaded ?? null).busy || pending || directoryPending} onClick={() => { setError(''); setSelected(v.id); }}>{tr("Delete")}</Btn></li>))}
{tr(sharedGb > 0 ? <li className="storage-model storage-model--shared"><span className="storage-model__name"><strong>{tr("Shared files")}</strong><small className="muted">{tr("Used by more than one model")}</small></span><span className="storage-model__size muted">{tr(sizeGb(sharedGb))}</span><span/></li> : null)}
</ul> : <p className="small muted">{tr("No downloaded models")}</p>)}
</Field>
{tr(native?.installed?.models.length ? <Field label={tr("Downloaded models")} hint={tr("{0} on this computer. Choose a version to delete its files.", [sizeGb((native.installed.models).reduce((n, m) => n + (m.diskGb ?? 0), 0))])}>
<ul className="storage-models">{tr(native.installed.models.map(m => <li className="storage-model" key={m.model}><span className="storage-model__name" title={m.model_name || m.model}><strong>{nativeShortName(m.model)}</strong>{tr(native.instances.some(i => i.model === m.model) ? <small className="muted">{tr('Loaded')}</small> : null)}</span><span className="storage-model__size muted">{tr(sizeGb(m.diskGb ?? 0))}</span><Btn size="sm" variant="danger" disabled={!!native.jobs.some(j => j.state === 'running')} onClick={() => setNativeDeleting(m.model)}>{tr("Delete")}</Btn></li>))}</ul>
</Field> : null)}
<NativeDeleteDialog id={nativeDeleting} onClose={() => setNativeDeleting(null)}/>
<Dialog open={!!selected} compact title={tr(version?.local ? 'Remove from Strata?' : `Delete ${version?.quant ?? 'model version'}?`)} onClose={close} actions={<><Btn disabled={pending} onClick={close}>{tr("Keep version")}</Btn><Btn variant="danger" disabled={pending || unavailable} onClick={() => void remove()}>{tr(pending ? 'Working…' : version?.local ? 'Remove model' : 'Delete version')}</Btn></>}>
<p>{tr(version ? `${version.local ? version.name : version.family} · ${version.quant} · ${sizeGb(version.diskGb)}` : 'This version is no longer downloaded.')}</p>
+13 -3
View File
@@ -6,11 +6,12 @@ import { useStore } from '../lib/store';
import type { SoftwareUpdate, UpdateCheckResult } from '../lib/types';
import { nativeAction, useNative } from '../lib/native';
import { NativeProgress } from './NativeModels';
import { appVersionLabel } from '../lib/app-version';
/** Installed versions are real host values. Installation needs a host updater. */
export function SettingsUpdates() {
const native = useNative();
const [confirm, setConfirm] = useState('');
const appVersion = useStore(s => s.setup?.app.version);
const appVersion = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
const engineVersion = useStore(s => s.engine?.engine.version);
const [rollback, setRollback] = useState(false);
const [component, setComponent] = useState<'strata' | 'engine'>('strata');
@@ -19,7 +20,7 @@ export function SettingsUpdates() {
const [error, setError] = useState('');
const request = useRef<AbortController | null>(null);
useEffect(() => () => request.current?.abort(), []);
const installed = component === 'strata' ? native?.version ?? appVersion : native?.available ? (native.installed?.engine?.version ?? 'Not installed') : engineVersion;
const installed = component === 'strata' ? appVersion : native?.available ? (native.installed?.engine?.version ?? 'Not installed') : engineVersion;
const active = native?.jobs.find(j => j.state === 'running');
async function operate(kind: string) {
setError('');
@@ -68,7 +69,7 @@ export function SettingsUpdates() {
}
return <>
<div className="settings-versions" aria-busy={checking}>
{tr(versionRow('Strata', native?.version ?? appVersion, result?.strata))}
{tr(versionRow('Strata', appVersion, result?.strata))}
{tr(versionRow('Engine', native?.available ? (native.installed?.engine?.version ?? 'Not installed') : engineVersion, result?.engine))}
<div className="settings-update-actions"><Btn size="sm" icon="refresh" disabled={checking} className={checking ? 'settings-update-checking' : undefined} onClick={() => void check()}>{tr(checking ? 'Checking…' : error || result?.strata.status === 'error' ? 'Try again' : result ? 'Check again' : 'Check for updates')}</Btn><Btn size="sm" variant="ghost" onClick={() => setRollback(true)}>{tr("Roll back\u2026")}</Btn></div>
<div className="settings-update-inline" role="status" aria-live="polite">{tr(checking ? 'Checking for new versions…' : error ? <span className="inline-error">{tr(error)}</span> : result ? <><span className="sr-only">{tr(result.strata.message)} {tr(result.engine.message)} </span>{tr("Checked at ")}{tr(new Date(result.checkedAt).toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit' }))}</> : null)}</div>
@@ -85,3 +86,12 @@ export function SettingsUpdates() {
<Dialog open={!!confirm} compact title={tr(confirm === 'update-strata' ? 'Update Strata?' : 'Update engine?')} onClose={() => setConfirm('')} actions={<><Btn onClick={() => setConfirm('')}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => void operate(confirm)}>{tr("Update")}</Btn></>}><p>{tr("The native installer updates the installation. Model files and settings are kept. Unload native models before updating.")}</p>{tr((native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</Dialog>
</>;
}
/** "Strata 0.2.0-beta · Engine 0.1.45.3": the app's own version first, the engine's beside it (About). */
export function AboutVersions() {
const native = useNative();
const app = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
const engineVersion = useStore(s => s.engine?.engine.version);
const engine = native?.available ? native.installed?.engine?.version : engineVersion;
return <p className="settings-about-version"><strong>{tr("Strata")} {app ?? ''}</strong>{engine ? <span className="muted"> · {tr("Engine")} {engine}</span> : null}</p>;
}
+22 -11
View File
@@ -3,14 +3,16 @@ import { tr } from '../lib/i18n';
import { Icon } from './ui';
import { FloatingMenu } from './FloatingMenu';
import { cx } from '../lib/format';
import type { CatalogVersion } from '../lib/native-catalog';
import { RECOMMENDED, VERSION_GROUPS, fitOn, groupOpen, type FitHardware } from '../lib/native-fit';
import { versionNote, type CatalogVersion } from '../lib/native-catalog';
import { nvfp4Version } from '../lib/native-names';
import { VERSION_GROUPS, fitOn, groupOpen, recommendedVersions, type FitHardware } from '../lib/native-fit';
/** The name a version goes by in the list: the quantization, with the maker where the quantization alone is ambiguous. */
export function versionName(v: CatalogVersion, withMaker = false): string {
switch (v.sourceFamily) {
case 'swift': return `Swift ${v.model}`;
case 'unsloth': return `Unsloth ${v.model}`;
case 'nvfp4': return tr(nvfp4Version(v.model)?.name ?? v.model);
case 'coder': return `Coder ${v.model}`;
case 'qwen': return withMaker ? `${v.model} (GSQ-RCO)` : v.model;
default: return v.model;
@@ -24,43 +26,52 @@ const gb = (n: number) => `${Number.isInteger(n) ? n : n.toFixed(1)} GB`;
* which stays on the SSD, is not in it); the download size is secondary. "Uses SSD N GB" appears only where this computer would read
* part of the model from its SSD. The first group is a fixed list of four; the rest fold away.
*/
export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnGguf }: {
export function VersionPicker({ versions, selectedId, hardware, onSelect, onOwnGguf, downloaded, showRecommended = true, moreLabel }: {
versions: CatalogVersion[];
selectedId: string;
hardware: FitHardware | null | undefined;
onSelect: (id: string) => void;
onOwnGguf: () => void;
/** The versions already on this computer (ready to load): each gets a small "Downloaded" tag. */
downloaded?: ReadonlySet<string>;
/** False where the recommended four are shown elsewhere (the first-run page): the menu then holds only the groups. */
showRecommended?: boolean;
/** What the button says while the selected version is one of the recommended four shown elsewhere. */
moreLabel?: string;
}) {
const trigger = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
// A group is open when the person opened it; until they touch it, only the group that holds the selected version is open.
const [opened, setOpened] = useState<Record<string, boolean>>({});
const selected = versions.find(v => v.id === selectedId);
const recommended = RECOMMENDED.map(r => ({ r, v: versions.find(v => v.sourceFamily === r.family && v.model === r.model) })).filter((x): x is { r: typeof RECOMMENDED[number]; v: CatalogVersion } => !!x.v);
const recommended = recommendedVersions(versions);
// Where the recommended four are shown outside the menu, the menu holds only the others.
const apart = new Set(showRecommended ? [] : recommended.map(x => x.v.id));
const rest = versions.filter(v => !apart.has(v.id));
const groups = [
...VERSION_GROUPS.map(g => ({ id: g.id, title: g.title, items: versions.filter(v => g.families.includes(v.sourceFamily)) })),
{ id: 'other', title: 'Qwen3.6 models', items: versions.filter(v => !VERSION_GROUPS.some(g => g.families.includes(v.sourceFamily))) },
...VERSION_GROUPS.map(g => ({ id: g.id, title: g.title, items: rest.filter(v => g.families.includes(v.sourceFamily)) })),
{ id: 'other', title: 'Qwen3.6 models', items: rest.filter(v => !VERSION_GROUPS.some(g => g.families.includes(v.sourceFamily))) },
].filter(g => g.items.length);
const row = (v: CatalogVersion, label: string, note?: string) => {
const fit = fitOn(v, hardware);
const on = v.id === selectedId;
const line = versionNote(v.model);
return <button key={`${label}-${v.id}`} role="menuitemradio" aria-checked={on} className={cx('version-row', on && 'is-on')} onClick={() => { onSelect(v.id); setOpen(false); }}>
<span className="version-row__name">{tr(on ? <Icon name="check" size={14}/> : null)}<strong>{label}</strong>{tr(note ? <span className="muted"> · {tr(note)}</span> : null)}{tr(fit.known && fit.spillGb >= 1 ? <span className="version-row__ssd">{tr("Uses SSD {0} GB", [Math.round(fit.spillGb)])}</span> : null)}</span>
<span className="version-row__name"><span className="version-row__title">{tr(on ? <Icon name="check" size={14}/> : null)}<strong>{label}</strong>{tr(note ? <span className="muted"> · {tr(note)}</span> : null)}{tr(downloaded?.has(v.id) ? <span className="version-row__have">{tr("Downloaded")}</span> : null)}{tr(fit.known && fit.spillGb >= 1 ? <span className="version-row__ssd">{tr("Uses SSD {0} GB", [Math.round(fit.spillGb)])}</span> : null)}</span>{tr(line ? <small className="version-row__note muted">{line}</small> : null)}</span>
<span className="version-row__ram">{gb(fit.memoryGb)}</span>
<span className="version-row__dl">{gb(v.download_gb)}</span>
</button>;
};
const fit = selected ? fitOn(selected, hardware) : null;
const fit = selected && !apart.has(selected.id) ? fitOn(selected, hardware) : null;
return <>
<button ref={trigger} type="button" className="version-trigger" aria-haspopup="menu" aria-expanded={open} aria-label={tr("Model version")} onClick={() => setOpen(v => !v)}>
<strong>{selected ? versionName(selected, true) : tr("Choose a version")}</strong>
<strong>{selected && !apart.has(selected.id) ? versionName(selected, true) : tr(moreLabel ?? "Choose a version")}</strong>{tr(selected && !apart.has(selected.id) && downloaded?.has(selected.id) ? <span className="version-row__have">{tr("Downloaded")}</span> : null)}
<span className="version-trigger__need muted">{tr(fit ? `${gb(fit.memoryGb)} RAM + VRAM` : '')}</span>
<Icon name="chevron" size={14} className="version-trigger__chev"/>
</button>
{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Model version")} width={620} onClose={() => setOpen(false)}>
<div className="version-menu">
<div className="version-head"><span>{tr("Recommended")}</span><span>{tr("RAM + VRAM")}</span><span>{tr("Download")}</span></div>
{tr(recommended.map(({ r, v }) => row(v, versionName(v, true), r.note)))}
{tr(showRecommended ? <><div className="version-head"><span>{tr("Recommended")}</span><span>{tr("RAM + VRAM")}</span><span>{tr("Download")}</span></div>{tr(recommended.map(({ r, v }) => row(v, versionName(v, true), r.note)))}</> : <div className="version-head"><span>{tr("All other versions")}</span><span>{tr("RAM + VRAM")}</span><span>{tr("Download")}</span></div>)}
{tr(groups.map(g => {
const closed = !groupOpen(opened, g.id, g.items.some(v => v.id === selectedId));
return <div className="version-group" key={g.id} data-open={!closed || undefined}>
+9
View File
@@ -0,0 +1,9 @@
/**
* The version the person sees as "Strata". It is the DESKTOP APP's version (0.2.0-beta), not that of Strata Control, the tools the app runs
* (0.1.45, the version of the Terminal). Showing Control's number as "Strata" made a tester report "Strata 0.1.45" for the 0.2.0-beta app.
* Without a real app version (a source checkout reports 'dev') the other number is the best there is.
*/
export function appVersionLabel(app: string | null | undefined, control?: string | null): string | undefined {
const real = (v: string | null | undefined) => (typeof v === 'string' && v.trim() && v.trim().toLowerCase() !== 'dev' ? v.trim() : undefined);
return real(app) ?? real(control) ?? (typeof app === 'string' && app.trim() ? app.trim() : undefined);
}
+1 -1
View File
@@ -47,7 +47,7 @@ export interface StreamEvent {
export function onEvent(handler: (e: StreamEvent) => void, onStatus?: (s: 'live' | 'offline') => void) {
let source: EventSource;
let seenAt = Date.now();
const names = ['engine', 'monitor', 'models', 'image-models', 'runtime', 'providers', 'serving', 'download', 'log', 'setup', 'skills', 'task', 'goal', 'timeskip', 'plan', 'queue'];
const names = ['engine', 'monitor', 'models', 'native', 'image-models', 'runtime', 'providers', 'serving', 'download', 'log', 'setup', 'skills', 'task', 'goal', 'timeskip', 'plan', 'queue'];
const connect = () => {
source?.close();
seenAt = Date.now();
+19
View File
@@ -53,3 +53,22 @@ export function installProgress(install: InstallParts | undefined): { done_gb: n
if (d && Number.isFinite(d.done_gb) && Number.isFinite(d.total_gb)) return { done_gb: d.done_gb!, total_gb: d.total_gb!, remaining_gb: Number.isFinite(d.remaining_gb) ? d.remaining_gb! : Math.max(0, d.total_gb! - d.done_gb!), percent: d.percent ?? 0, file: d.file };
return undefined;
}
/** Which part of a multi-part install is being fetched now: the model's parts (with "part 2 of 4"), the picture reader or the engine. */
export interface InstallStage { kind: 'model' | 'vision' | 'engine' | 'other'; index?: number; count?: number }
export function installStage(install: { parts?: { kind?: string; state?: string; files_done?: number; files_total?: number }[]; running?: boolean } | undefined): InstallStage | undefined {
const parts = install?.parts ?? [];
const now = parts.find(p => p.state === 'downloading') ?? parts.find(p => !['done', 'current', 'skipped', 'failed'].includes(p.state ?? ''));
if (!now) return undefined;
const kind = now.kind === 'model' || now.kind === 'vision' || now.kind === 'engine' ? now.kind : 'other';
const count = Number(now.files_total) > 1 ? Number(now.files_total) : undefined;
return { kind, ...(count ? { index: Math.min(count, Number(now.files_done ?? 0) + 1), count } : {}) };
}
/** The sentence for it, in plain words. */
export function stageText(stage: InstallStage | undefined): string {
if (!stage) return tr('Downloading model files…');
if (stage.kind === 'vision') return tr('Downloading the picture reader…');
if (stage.kind === 'engine') return tr('Downloading the engine for this PC…');
if (stage.count && stage.index) return tr('Downloading model files · part {0} of {1}', [stage.index, stage.count]);
return tr('Downloading model files…');
}
+23
View File
@@ -0,0 +1,23 @@
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react';
/**
* A piece of screen state that survives leaving the screen. The Models page keeps its choices (version, context, computer use, picture
* reading ...) in useState, so going to Monitor and back reset them and a person lost track of what they were downloading. Kept values
* live in memory for the whole session (not on disk: a new start begins from the installed model again).
*/
const kept = new Map<string, unknown>();
export function useKept<T>(key: string, initial: T): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(() => (kept.has(key) ? kept.get(key) as T : initial));
useEffect(() => { kept.set(key, value); }, [key, value]);
const set = useCallback<Dispatch<SetStateAction<T>>>(next => setValue(old => {
const result = typeof next === 'function' ? (next as (o: T) => T)(old) : next;
kept.set(key, result);
return result;
}), [key]);
return [value, set];
}
export const keptValue = <T,>(key: string): T | undefined => kept.get(key) as T | undefined;
/** Forget everything kept (tests, and a changed installation). */
export const forgetKept = () => kept.clear();
+4 -1
View File
@@ -1,9 +1,12 @@
import type {NativeFamily,NativeSize} from './native';
import {tr} from './i18n';
import {nvfp4Version} from './native-names';
export interface CatalogVersion extends NativeSize{sourceFamily:string;edition:string;images:boolean}
export interface CatalogModel extends Omit<NativeFamily,'sizes'>{sizes:CatalogVersion[]}
export function nativeVersionLabel(model:string){return tr(({Q2_0:'Fastest',IQ2_XS:'Compact',IQ3_XXS:'Balanced',IQ3_S:'High quality',IQ1_M:'Coding','Gyro-S':'Smallest','Gyro-M':'Small memory'} as Record<string,string>)[model]??model);}
export function nativeVersionLabel(model:string){const nv=nvfp4Version(model);if(nv)return tr(nv.name);return tr(({Q2_0:'Fastest',IQ2_XS:'Compact',IQ3_XXS:'Balanced',IQ3_S:'High quality',IQ1_M:'Coding','Gyro-S':'Smallest','Gyro-M':'Small memory'} as Record<string,string>)[model]??model);}
export const catalogVersionLabel=(version:CatalogVersion)=>{const label=nativeVersionLabel(version.model);return [tr(version.edition),label===version.model&&version.edition?'':label].filter(Boolean).join(' · ');};
/** The one line that says how a version differs from its neighbours (the three NVFP4 versions); empty for the rest. */
export const versionNote=(model:string)=>{const nv=nvfp4Version(model);return nv?tr(nv.note):'';};
/** Display groups are distinct from the engine's installation family IDs. */
export function nativeCatalogModels(families:NativeFamily[]):CatalogModel[]{
if(!families.length)return[];
+5
View File
@@ -61,3 +61,8 @@ export const RECOMMENDED: { family: string; model: string; note: string }[] = [
/** Is a group of the version list open? What the person chose wins; until they touch it, only the group holding the selected version is open
* (the running version's group used to stay open whatever was clicked). */
export const groupOpen = (opened: Record<string, boolean>, id: string, containsSelected: boolean): boolean => opened[id] ?? containsSelected;
/** The recommended four, found in a version list (a version the catalog does not offer is skipped). */
export function recommendedVersions<V extends { sourceFamily: string; model: string }>(versions: V[]): { r: typeof RECOMMENDED[number]; v: V }[] {
return RECOMMENDED.map(r => ({ r, v: versions.find(v => v.sourceFamily === r.family && v.model === r.model) })).filter((x): x is { r: typeof RECOMMENDED[number]; v: V } => !!x.v);
}
+15
View File
@@ -4,12 +4,27 @@
*/
const FAMILIES: [string, string][] = [['swift-', 'Swift'], ['coder-', 'Coder'], ['unsloth-', 'Unsloth'], ['gyro-gyro-', 'Gyro'], ['gyro-', 'Gyro'], ['nvfp4-', 'NVFP4'], ['qwen36-', 'Qwen3.6']];
/**
* The three NVFP4 versions, named by who made the weights (the plain model names NVFP4 / NVFP4-NVIDIA / NVFP4-QAD looked like the same thing):
* - NVFP4: Strata's own GPTQ conversion of Qwen's weights, the best quality of the three;
* - NVFP4-NVIDIA: NVIDIA's own NVFP4 checkpoint (plain rounding), converted for Strata, the same size and speed;
* - NVFP4-QAD: NVIDIA's FP4 checkpoint that was trained for FP4 (quantization-aware distillation), slightly faster on short prompts.
* `note` is the one line that says how they differ.
*/
export const NVFP4_VERSIONS: Record<string, { name: string; note: string }> = {
'NVFP4': { name: 'NVFP4 (Strata’s version)', note: 'Strata’s own conversion. The best quality of the NVFP4 versions.' },
'NVFP4-NVIDIA': { name: 'NVFP4 (NVIDIA’s version)', note: 'NVIDIA’s own NVFP4 checkpoint, converted for Strata. Same size and speed.' },
'NVFP4-QAD': { name: 'NVFP4 (NVIDIA, trained for FP4)', note: 'NVIDIA’s FP4 checkpoint, trained for FP4. Slightly faster on short prompts.' },
};
export const nvfp4Version = (model: string) => NVFP4_VERSIONS[String(model ?? '').toUpperCase()];
export function nativeShortName(id: string): string {
const lower = String(id ?? '').toLowerCase();
for (const [prefix, label] of FAMILIES) {
if (!lower.startsWith(prefix)) continue;
const rest = lower.slice(prefix.length).toUpperCase();
if (!rest) return label;
if (label === 'NVFP4') return nvfp4Version(rest)?.name ?? `${label} ${rest}`;
return label === 'Gyro' ? `Gyro-${rest.replace(/^GYRO-/, '')}` : `${label} ${rest}`;
}
return lower.toUpperCase() || String(id);
+49 -6
View File
@@ -6,17 +6,60 @@ export interface GpuChoice {options:GpuChoiceOption[];recommended:string|null;ig
export interface NativeSize {/** The first engine that reads this size's files (the catalog), and whether the installed engine is older: the screen then says "needs a newer engine" and offers the update. */engine_min?:string|null;needsNewerEngine?:boolean;id:string;model:string;about:string;download_gb:number;ram_needed_gb:number;experts_gb:number;on_this_pc:string|null;installed:boolean;gpu_options?:GpuChoice|null;images?:boolean;imagesCapable?:boolean}
export interface NativeFamily {family:string;title:string;about:string;experimental:boolean;images:boolean;sizes:NativeSize[]}
export interface NativeField {id:string;label:string;group:string;type:'number'|'boolean'|'string'|'enum'|'array';min?:number;max?:number;choices?:string[];hint?:string;supported?:boolean}
export interface NativeJob {id:string;kind:string;guided?:boolean;model?:string;state:'running'|'done'|'error'|'cancelled';startedAt:string;summary:string;error:string|null;details?:string;result:Record<string,any>|null;progress?:Record<string,any>|null}
export interface NativeJob {id:string;kind:string;guided?:boolean;model?:string;family?:string;state:'running'|'done'|'error'|'cancelled';startedAt:string;summary:string;error:string|null;details?:string;result:Record<string,any>|null;progress?:Record<string,any>|null}
export interface NativeInstance {id:string;apiModelId:string;model:string;label:string;aliases?:string[];vision?:boolean;resourceProfile?:'work'|'performance'|null;context?:number;port:number;gpus:number[];status:'loading'|'ready'|'unloading'|'error';phase:string;loadedAt:string;error:string|null;metrics:Record<string,any>|null}
export interface NativeState {
/** The picture reader (strata-vision) is installed with the engine, so models marked for pictures can read them here. */
visionReader?: boolean;available:boolean;repo:string;downloadDirectory?:string;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;/** The picture-reading files are on this computer (notes-split-agent8.md, item 8). */visionPrepared?:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';dataFolder?:string;diskGb?:number;diskBytes?:number;missing_files?:string[]}[];data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;gpus:{index:number;name:string;vram_gb:number;usable:boolean}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
let state:NativeState|null=null,timer:ReturnType<typeof setInterval>|null=null,reading:Promise<void>|null=null;
/** Strata Control's view of the engine for this computer (the app): usable false = none found or recognised; `notice` says why in plain words. */
engine?:{usable:boolean;notice?:string|null;version?:string|null};
visionReader?: boolean;available:boolean;repo:string;downloadDirectory?:string;version?:string|null;summary:string;error:string|null;catalog:{families:NativeFamily[];contexts:number[];recommendation:{family:string|null;model:string|null;context?:number;why?:string};context_note:string}|null;installed:{models:{model:string;config:string;model_name:string;ready:boolean;/** The picture-reading files are on this computer (notes-split-agent8.md, item 8). */visionPrepared?:boolean;context?:number;kv?:string;images?:string;visionEnabled?:boolean;visionDevice?:string;lowRamMode?:string;resourceProfile?:'work'|'performance';dataFolder?:string;diskGb?:number;diskBytes?:number;/** Files this model shares with another (the picture reader, the draft layer): counted once, in `usage`. */sharedBytes?:number;/** What Delete frees: files in Strata's own folders that no other model uses. */removableGb?:number;missing_files?:string[]}[];/** What all models take on disk, from their real files, shared files counted once. */usage?:{total_bytes:number;total_gb:number;shared_bytes:number;shared_gb:number;models:Record<string,{bytes:number;gb:number}>}|null;data_folder:string;engine:{version:string|null}}|null;hardware:{ram_gb:number;gpus:{index:number;name:string;vram_gb:number;usable:boolean}[]}|null;fields:NativeField[];instances:NativeInstance[];defaultId:string|null;jobs:NativeJob[];capabilities:{responses?:boolean;updates:boolean;rollback:boolean;calibration:boolean;componentUpdates?:boolean}}
let state:NativeState|null=null;
const listeners=new Set<()=>void>();
let again=false;
function put(value:NativeState){if(state&&JSON.stringify(state)===JSON.stringify(value))return;state=value;listeners.forEach(fn=>fn());}
/** The host pushes its whole native state (jobs included) at every progress step: the page shows it at once, without waiting for the next poll. */
export function applyNativeEvent(value:unknown){if(value&&typeof value==='object'&&'available' in value&&'jobs' in value)put(value as NativeState);}
const READ_LIMIT_MS=60000;
function read(force:boolean):Promise<void>{
const request=api.get<NativeState>(`/api/native${force?'?refresh=1':''}`);
// A read that never answers must not freeze the page: after the limit the next poll starts a fresh one.
let stop:ReturnType<typeof setTimeout>|undefined;
const limit=new Promise<never>((_,reject)=>{stop=setTimeout(()=>reject(new Error('The host did not answer in time.')),READ_LIMIT_MS);});
return Promise.race([request,limit]).then(put).catch(error=>{if(state){state={...state,error:error.message,instances:state.instances.map(i=>({...i,metrics:null,phase:'unavailable'}))};listeners.forEach(fn=>fn());}}).finally(()=>clearTimeout(stop));
}
/* Two kinds of read that never wait for each other: the light poll (the host answers at once with the jobs it holds) and the forced read after an action (the host
re-reads the installation, which can take many seconds while a download is writing). A slow forced read used to hold every later poll, so a running download looked stuck. */
let light:Promise<void>|null=null,forced:Promise<void>|null=null,again=false;
/** A forced refresh (after an action) never joins a read that began before the action: that read cannot know about the new job. It waits for it, then reads again. */
export function refreshNative(force=false):Promise<void>{if(reading){if(!force)return reading;if(again)return reading;again=true;return reading.then(()=>{again=false;return refreshNative(true);});}reading=api.get<NativeState>(`/api/native${force?'?refresh=1':''}`).then(value=>{if(state&&JSON.stringify(state)===JSON.stringify(value))return;state=value;listeners.forEach(fn=>fn());}).catch(error=>{if(state){state={...state,error:error.message,instances:state.instances.map(i=>({...i,metrics:null,phase:'unavailable'}))};listeners.forEach(fn=>fn());}}).finally(()=>{reading=null;});return reading;}
export function useNative(){const value=useSyncExternalStore(fn=>{listeners.add(fn);return()=>listeners.delete(fn);},()=>state);useEffect(()=>{void refreshNative();if(!timer)timer=setInterval(()=>void refreshNative(),2500);return()=>{if(!listeners.size&&timer){clearInterval(timer);timer=null;}};},[]);return value;}
export function refreshNative(force=false):Promise<void>{
if(!force){if(light)return light;light=read(false).finally(()=>{light=null;});return light;}
if(forced){if(again)return forced;again=true;return forced.then(()=>{again=false;return refreshNative(true);});}
forced=read(true).finally(()=>{forced=null;});return forced;
}
const POLL_MS=2500,RUNNING_POLL_MS=1000;
let users=0,timeout:ReturnType<typeof setTimeout>|null=null,watching=false;
const hidden=()=>typeof document!=='undefined'&&document.visibilityState==='hidden';
/** How long until the next poll: quicker while a download or load is running, none while the window is hidden (it reads again the moment it is shown). */
export const nativePollDelay=(value:NativeState|null,isHidden=false):number|null=>isHidden?null:value?.jobs?.some(j=>j.state==='running')?RUNNING_POLL_MS:POLL_MS;
function schedule(){
if(timeout){clearTimeout(timeout);timeout=null;}
if(!users)return;
const wait=nativePollDelay(state,hidden());
if(wait===null)return;
timeout=setTimeout(()=>{timeout=null;void refreshNative().finally(schedule);},wait);
}
const shown=()=>{if(!hidden()&&users){void refreshNative();schedule();}};
export function useNative(){
const value=useSyncExternalStore(fn=>{listeners.add(fn);return()=>listeners.delete(fn);},()=>state);
useEffect(()=>{
users++;void refreshNative().finally(schedule);
if(!watching&&typeof document!=='undefined'){watching=true;document.addEventListener('visibilitychange',shown);window.addEventListener('focus',shown);}
return()=>{users--;if(!users){if(timeout){clearTimeout(timeout);timeout=null;}if(watching&&typeof document!=='undefined'){watching=false;document.removeEventListener('visibilitychange',shown);window.removeEventListener('focus',shown);}}};
},[]);
// a new job (or its end) changes how fast the next poll should come
const running=value?.jobs?.some(j=>j.state==='running')??false;
useEffect(()=>{schedule();},[running]);
return value;
}
export const nativeState=()=>state;
/** Connection-list projection only; memory reservations are not inferred. */
export function nativeRouting(native:NativeState|null,hardware:Hardware|null):RuntimeModels|null{
+4 -1
View File
@@ -13,7 +13,7 @@ import { PREVIEW_EXAMPLES, type PreviewArtifact } from './artifacts';
import { lifecyclePolicy } from './lifecycle';
import { newestServingState } from './serving-state';
import { photoData, uploadChatPhotos, photoHistorySlots, pendingChatPhotos, takeChatPhotos } from './chat-photos';
import { nativeState } from './native';
import { applyNativeEvent, nativeState } from './native';
import { pictureStatus } from './pictures';
import { taskHandoverMessage, taskHandoverTitle } from './task-handover';
import type { Task } from './tasks';
@@ -643,6 +643,9 @@ function applyEvent(e: StreamEvent) {
case 'runtime':
set({runtime:data as unknown as RuntimeModels});
break;
case 'native':
applyNativeEvent(data);
break;
case 'image-models':
if (Array.isArray(data.variants)) set({ imageModels: data as unknown as ImageModelLibrary });
break;
+22 -1
View File
@@ -3248,5 +3248,26 @@
"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.",
"s3237": "Die Datei wurde nicht hinzugefügt",
"s3238": "Versuche, sie noch einmal abzulegen."
"s3238": "Versuche, sie noch einmal abzulegen.",
"s3260": "NVFP4 (Strata-Version)",
"s3261": "NVFP4 (NVIDIA-Version)",
"s3262": "NVFP4 (NVIDIA, für FP4 trainiert)",
"s3263": "Strata-eigene Umwandlung. Die beste Qualität unter den NVFP4-Versionen.",
"s3264": "NVIDIAs eigener NVFP4-Checkpoint, für Strata umgewandelt. Gleiche Größe und Geschwindigkeit.",
"s3265": "NVIDIAs für FP4 trainierter Checkpoint. Bei kurzen Eingaben etwas schneller.",
"s3266": "Alle anderen Versionen",
"s3267": "Weitere Versionen",
"s3268": "Modelldateien werden heruntergeladen · Teil {0} von {1}",
"s3269": "Die Bildleser-Dateien werden heruntergeladen…",
"s3270": "Die Engine für diesen PC wird heruntergeladen…",
"s3271": "{0} wird vorbereitet",
"s3272": "Teile des Downloads",
"s3273": "Bildleser",
"s3274": "Gemeinsame Dateien",
"s3275": "Von mehr als einem Modell genutzt",
"s3276": "Diese Dateien liegen in deinem eigenen Ordner und bleiben dort. Nur der Eintrag in Strata wird entfernt.",
"s3277": "Das laufende Modell wird entladen.",
"s3278": "Lokal",
"s3279": "Strata hat für diesen Computer keine passende Engine erkannt. Das Modell wird trotzdem heruntergeladen, läuft aber möglicherweise erst, wenn die richtige Engine installiert ist (Einstellungen → Engine).",
"s3280": "Strata hat für diesen Computer keine passende Engine erkannt. Das Modell ist heruntergeladen, läuft aber erst, wenn die richtige Engine installiert ist. Öffne Einstellungen → Engine."
}
+22 -1
View File
@@ -3248,5 +3248,26 @@
"s3235": "Try again, or take a screenshot of this window instead.",
"s3236": "The folder window did not open. Type the folder path instead.",
"s3237": "The file was not added",
"s3238": "Try dropping it again."
"s3238": "Try dropping it again.",
"s3260": "NVFP4 (Strata’s version)",
"s3261": "NVFP4 (NVIDIA’s version)",
"s3262": "NVFP4 (NVIDIA, trained for FP4)",
"s3263": "Strata’s own conversion. The best quality of the NVFP4 versions.",
"s3264": "NVIDIA’s own NVFP4 checkpoint, converted for Strata. Same size and speed.",
"s3265": "NVIDIA’s FP4 checkpoint, trained for FP4. Slightly faster on short prompts.",
"s3266": "All other versions",
"s3267": "More versions",
"s3268": "Downloading model files · part {0} of {1}",
"s3269": "Downloading the picture reader…",
"s3270": "Downloading the engine for this PC…",
"s3271": "Preparing {0}",
"s3272": "Download parts",
"s3273": "Picture reader",
"s3274": "Shared files",
"s3275": "Used by more than one model",
"s3276": "These files are in your own folder, so they stay there. Only the entry in Strata is removed.",
"s3277": "The running model will be unloaded.",
"s3278": "Local",
"s3279": "Strata didn’t recognise an engine for this computer. The model will download, but it may not run until the right engine is installed (Settings → Engine).",
"s3280": "Strata didn’t recognise an engine for this computer. The model is downloaded, but it can’t run until the right engine is installed. Open Settings → Engine."
}
+22 -1
View File
@@ -3248,5 +3248,26 @@
"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.",
"s3237": "No se añadió el archivo",
"s3238": "Inténtalo de nuevo soltándolo otra vez."
"s3238": "Inténtalo de nuevo soltándolo otra vez.",
"s3260": "NVFP4 (versión de Strata)",
"s3261": "NVFP4 (versión de NVIDIA)",
"s3262": "NVFP4 (NVIDIA, entrenada para FP4)",
"s3263": "Conversión propia de Strata. La mejor calidad de las versiones NVFP4.",
"s3264": "El punto de control NVFP4 propio de NVIDIA, convertido para Strata. Mismo tamaño y velocidad.",
"s3265": "El punto de control de NVIDIA entrenado para FP4. Algo más rápido con mensajes cortos.",
"s3266": "Todas las demás versiones",
"s3267": "Más versiones",
"s3268": "Descargando los archivos del modelo · parte {0} de {1}",
"s3269": "Descargando el lector de imágenes…",
"s3270": "Descargando el motor para este PC…",
"s3271": "Preparando {0}",
"s3272": "Partes de la descarga",
"s3273": "Lector de imágenes",
"s3274": "Archivos compartidos",
"s3275": "Los usa más de un modelo",
"s3276": "Estos archivos están en tu propia carpeta y se quedan allí. Solo se quita la entrada en Strata.",
"s3277": "El modelo en ejecución se descargará de la memoria.",
"s3278": "Local",
"s3279": "Strata no reconoció un motor para este equipo. El modelo se descargará, pero puede que no funcione hasta que se instale el motor adecuado (Ajustes → Motor).",
"s3280": "Strata no reconoció un motor para este equipo. El modelo está descargado, pero no podrá funcionar hasta que se instale el motor adecuado. Abre Ajustes → Motor."
}
+22 -1
View File
@@ -3248,5 +3248,26 @@
"s3235": "もう一度試すか、このウィンドウのスクリーンショットを撮ってください。",
"s3236": "フォルダーの画面が開きませんでした。代わりにフォルダーのパスを入力してください。",
"s3237": "ファイルを追加できませんでした",
"s3238": "もう一度ドロップしてみてください。"
"s3238": "もう一度ドロップしてみてください。",
"s3260": "NVFP4(Strata 版)",
"s3261": "NVFP4(NVIDIA 版)",
"s3262": "NVFP4(NVIDIA、FP4 向けに学習)",
"s3263": "Strata 独自の変換版です。NVFP4 の中で最も高品質です。",
"s3264": "NVIDIA 自身の NVFP4 チェックポイントを Strata 用に変換したものです。サイズと速度は同じです。",
"s3265": "NVIDIA が FP4 向けに学習したチェックポイントです。短いプロンプトでは少し高速です。",
"s3266": "その他のすべてのバージョン",
"s3267": "その他のバージョン",
"s3268": "モデルファイルをダウンロード中 · {1} 個中 {0} 個目",
"s3269": "画像読み取りファイルをダウンロード中…",
"s3270": "このパソコン用のエンジンをダウンロード中…",
"s3271": "{0} を準備中",
"s3272": "ダウンロードの内訳",
"s3273": "画像読み取り",
"s3274": "共有ファイル",
"s3275": "複数のモデルで使われています",
"s3276": "これらのファイルはあなた自身のフォルダーにあるため、そのまま残ります。Strata 上の項目だけが削除されます。",
"s3277": "動作中のモデルの読み込みを解除します。",
"s3278": "ローカル",
"s3279": "Strata がこのパソコン用のエンジンを認識できませんでした。モデルはダウンロードされますが、適切なエンジンをインストールするまで動作しない場合があります(設定 → エンジン)。",
"s3280": "Strata がこのパソコン用のエンジンを認識できませんでした。モデルはダウンロード済みですが、適切なエンジンをインストールするまで動作しません。設定 → エンジン を開いてください。"
}
+22 -1
View File
@@ -3248,5 +3248,26 @@
"s3235": "다시 시도하거나 이 창을 직접 캡처하세요.",
"s3236": "폴더 창이 열리지 않았습니다. 대신 폴더 경로를 입력하세요.",
"s3237": "파일을 추가하지 못했습니다",
"s3238": "다시 끌어다 놓아 보세요."
"s3238": "다시 끌어다 놓아 보세요.",
"s3260": "NVFP4 (Strata 버전)",
"s3261": "NVFP4 (NVIDIA 버전)",
"s3262": "NVFP4 (NVIDIA, FP4용으로 학습)",
"s3263": "Strata가 직접 변환한 버전입니다. NVFP4 버전 중 품질이 가장 좋습니다.",
"s3264": "NVIDIA가 직접 만든 NVFP4 체크포인트를 Strata용으로 변환했습니다. 크기와 속도는 같습니다.",
"s3265": "NVIDIA가 FP4용으로 학습한 체크포인트입니다. 짧은 입력에서 조금 더 빠릅니다.",
"s3266": "다른 모든 버전",
"s3267": "더 많은 버전",
"s3268": "모델 파일 다운로드 중 · {1}개 중 {0}번째",
"s3269": "이미지 읽기 파일 다운로드 중…",
"s3270": "이 PC용 엔진 다운로드 중…",
"s3271": "{0} 준비 중",
"s3272": "다운로드 구성",
"s3273": "이미지 읽기",
"s3274": "공유 파일",
"s3275": "여러 모델에서 함께 사용",
"s3276": "이 파일들은 내 폴더에 있으므로 그대로 남습니다. Strata의 항목만 삭제됩니다.",
"s3277": "실행 중인 모델을 해제합니다.",
"s3278": "로컬",
"s3279": "Strata가 이 컴퓨터용 엔진을 인식하지 못했습니다. 모델은 다운로드되지만 올바른 엔진을 설치하기 전에는 실행되지 않을 수 있습니다(설정 → 엔진).",
"s3280": "Strata가 이 컴퓨터용 엔진을 인식하지 못했습니다. 모델은 다운로드되었지만 올바른 엔진을 설치하기 전에는 실행할 수 없습니다. 설정 → 엔진을 열어 주세요."
}
+22 -1
View File
@@ -3248,5 +3248,26 @@
"s3235": "Tente de novo ou tire uma captura desta janela.",
"s3236": "A janela de pastas não abriu. Digite o caminho da pasta.",
"s3237": "O arquivo não foi adicionado",
"s3238": "Tente soltá-lo de novo."
"s3238": "Tente soltá-lo de novo.",
"s3260": "NVFP4 (versão da Strata)",
"s3261": "NVFP4 (versão da NVIDIA)",
"s3262": "NVFP4 (NVIDIA, treinada para FP4)",
"s3263": "Conversão própria da Strata. A melhor qualidade entre as versões NVFP4.",
"s3264": "O checkpoint NVFP4 da própria NVIDIA, convertido para a Strata. Mesmo tamanho e velocidade.",
"s3265": "O checkpoint da NVIDIA treinado para FP4. Um pouco mais rápido com mensagens curtas.",
"s3266": "Todas as outras versões",
"s3267": "Mais versões",
"s3268": "Baixando arquivos do modelo · parte {0} de {1}",
"s3269": "Baixando o leitor de imagens…",
"s3270": "Baixando o mecanismo para este PC…",
"s3271": "Preparando {0}",
"s3272": "Partes do download",
"s3273": "Leitor de imagens",
"s3274": "Arquivos compartilhados",
"s3275": "Usados por mais de um modelo",
"s3276": "Esses arquivos estão na sua própria pasta e continuam lá. Só a entrada no Strata é removida.",
"s3277": "O modelo em execução será descarregado.",
"s3278": "Local",
"s3279": "A Strata não reconheceu um mecanismo para este computador. O modelo será baixado, mas pode não funcionar até que o mecanismo certo seja instalado (Configurações → Mecanismo).",
"s3280": "A Strata não reconheceu um mecanismo para este computador. O modelo foi baixado, mas não vai funcionar até que o mecanismo certo seja instalado. Abra Configurações → Mecanismo."
}
+22 -1
View File
@@ -3248,5 +3248,26 @@
"s3235": "Попробуйте ещё раз или сделайте снимок этого окна.",
"s3236": "Окно выбора папки не открылось. Введите путь к папке вручную.",
"s3237": "Файл не добавлен",
"s3238": "Попробуйте перетащить его ещё раз."
"s3238": "Попробуйте перетащить его ещё раз.",
"s3260": "NVFP4 (версия Strata)",
"s3261": "NVFP4 (версия NVIDIA)",
"s3262": "NVFP4 (NVIDIA, обучена для FP4)",
"s3263": "Собственная конвертация Strata. Лучшее качество среди версий NVFP4.",
"s3264": "Собственный NVFP4-чекпойнт NVIDIA, конвертированный для Strata. Тот же размер и скорость.",
"s3265": "Чекпойнт NVIDIA, обученный для FP4. Немного быстрее на коротких запросах.",
"s3266": "Все остальные версии",
"s3267": "Другие версии",
"s3268": "Скачивание файлов модели · часть {0} из {1}",
"s3269": "Скачивание модуля чтения изображений…",
"s3270": "Скачивание движка для этого компьютера…",
"s3271": "Подготовка: {0}",
"s3272": "Части загрузки",
"s3273": "Чтение изображений",
"s3274": "Общие файлы",
"s3275": "Используются несколькими моделями",
"s3276": "Эти файлы лежат в вашей папке и останутся там. Из Strata удаляется только запись.",
"s3277": "Работающая модель будет выгружена.",
"s3278": "Локальная",
"s3279": "Strata не распознала движок для этого компьютера. Модель всё равно скачается, но может не запуститься, пока не будет установлен нужный движок (Настройки → Движок).",
"s3280": "Strata не распознала движок для этого компьютера. Модель скачана, но не запустится, пока не будет установлен нужный движок. Откройте Настройки → Движок."
}
+22 -1
View File
@@ -3248,5 +3248,26 @@
"s3235": "请重试,或改为对此窗口截屏。",
"s3236": "文件夹窗口没有打开。请改为输入文件夹路径。",
"s3237": "文件未能添加",
"s3238": "请再拖放一次。"
"s3238": "请再拖放一次。",
"s3260": "NVFP4(Strata 版本)",
"s3261": "NVFP4(NVIDIA 版本)",
"s3262": "NVFP4(NVIDIA,为 FP4 训练)",
"s3263": "Strata 自己的转换版本,是 NVFP4 各版本中质量最好的。",
"s3264": "NVIDIA 自己的 NVFP4 检查点,已为 Strata 转换。大小和速度相同。",
"s3265": "NVIDIA 为 FP4 训练的检查点。短提示下速度略快。",
"s3266": "其他所有版本",
"s3267": "更多版本",
"s3268": "正在下载模型文件 · 第 {0} 部分,共 {1} 部分",
"s3269": "正在下载图片读取组件…",
"s3270": "正在下载适用于此电脑的引擎…",
"s3271": "正在准备 {0}",
"s3272": "下载分段",
"s3273": "图片读取组件",
"s3274": "共享文件",
"s3275": "被多个模型共用",
"s3276": "这些文件在你自己的文件夹中,会保留在原处,只会从 Strata 中移除该条目。",
"s3277": "将卸载运行中的模型。",
"s3278": "本地",
"s3279": "Strata 没有识别出适用于这台电脑的引擎。模型仍会下载,但在安装正确的引擎之前可能无法运行(设置 → 引擎)。",
"s3280": "Strata 没有识别出适用于这台电脑的引擎。模型已下载,但在安装正确的引擎之前无法运行。请打开 设置 → 引擎。"
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { SettingsModels } from '../components/SettingsModels';
import { SettingsTools } from '../components/SettingsTools';
import { SettingsStorage } from '../components/SettingsStorage';
import { PrivacyNotes } from '../components/PrivacyNotes';
import { SettingsUpdates } from '../components/SettingsUpdates';
import { AboutVersions, SettingsUpdates } from '../components/SettingsUpdates';
import { NativeOperations } from '../components/NativeOperations';
import { FontChoice } from '../components/FontChoice';
import { LANGUAGES } from '../lib/i18n';
@@ -58,7 +58,7 @@ export function SettingsScreen() {
{tr(panel.sections.map(section => <section className="settings-section" key={section}>
<h2 className="h3" style={{ marginBottom: 14 }}>{tr(section)}</h2>
<div className="st-card st-card--flush">
{tr(section === 'About' ? <div className="settings-about-brand"><Brand variant="lockup" height={28}/></div> : null)}
{tr(section === 'About' ? <div className="settings-about-brand"><Brand variant="lockup" height={28}/><AboutVersions/></div> : null)}
{tr(section === 'Storage' ? <SettingsStorage /> : null)}
{tr(section === 'Privacy' ? <PrivacyNotes /> : null)}
{tr(section === 'Update' ? <SettingsUpdates /> : null)}
+9 -3
View File
@@ -5653,7 +5653,8 @@ textarea.config-editor.st-input {
.key-input .st-input { min-width: 0; flex: 1; }
.dialog-feature-icon { display: grid; place-items: center; width: 52px; height: 52px; border: 1px solid var(--st-line); border-radius: 16px; background: var(--st-surface-2); color: var(--st-accent); }
.key-url { overflow-wrap: anywhere; padding: 14px; background: var(--st-surface-2); border-radius: 10px; }
.settings-about-brand { display: flex; padding: 22px 20px 0; }
.settings-about-brand { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; padding: 22px 20px 0; }
.settings-about-version { margin: 0; font-size: var(--st-fs-base); }
.prompt-progress { display: flex; align-items: center; gap: 16px; }
.prompt-progress > .st-bar { flex: 1; }
.hardware-section { display: flex; flex-direction: column; gap: 20px; }
@@ -7021,14 +7022,17 @@ textarea {
.version-menu { display: flex; flex-direction: column; max-height: min(70vh, 640px); overflow: auto; padding: 4px 0; min-width: min(620px, calc(100vw - 24px)); }
.version-head { display: grid; grid-template-columns: 1fr 84px 84px; gap: 10px; padding: 8px 18px 4px; font-size: var(--st-fs-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--st-ink-muted); }
.version-head span:nth-child(n+2) { text-align: right; }
.version-row { display: grid; grid-template-columns: 1fr 84px 84px; gap: 10px; align-items: baseline; width: calc(100% - 12px); margin: 0 6px; padding: 8px 12px; border: 0; border-radius: var(--st-r-sm); background: transparent; color: var(--st-ink); font: inherit; text-align: left; cursor: pointer; }
.version-row { display: grid; grid-template-columns: 1fr 84px 84px; gap: 10px; align-items: baseline; width: calc(100% - 12px); margin: 0 6px; padding: 6px 12px; border: 0; border-radius: var(--st-r-sm); background: transparent; color: var(--st-ink); font: inherit; font-size: var(--st-fs-md); text-align: left; cursor: pointer; }
.version-row:hover, .version-row:focus-visible { background: var(--st-surface-2); }
.version-row.is-on { background: var(--st-surface-2); }
.version-row__name { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.version-row__name svg { color: var(--st-accent-text); margin-right: 6px; vertical-align: -2px; }
.version-row__title { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.version-row__note { display: block; margin-top: 1px; font-size: var(--st-fs-xs); white-space: normal; line-height: 1.35; }
.version-row__have, .version-trigger .version-row__have { margin-left: 8px; padding: 1px 8px; border-radius: var(--st-r-pill); background: var(--st-accent-tint); color: var(--st-accent-text); font-size: var(--st-fs-xs); font-weight: 700; white-space: nowrap; }
.version-row__ssd { margin-left: 10px; font-size: var(--st-fs-xs); color: var(--st-warn-text); }
.version-row__ram { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
.version-row__dl { text-align: right; font-size: var(--st-fs-sm); color: var(--st-ink-muted); font-variant-numeric: tabular-nums; }
.version-row__dl { text-align: right; font-size: var(--st-fs-xs); color: var(--st-ink-muted); font-variant-numeric: tabular-nums; }
.version-group { border-top: 1px solid var(--st-line-soft); margin-top: 4px; }
.version-group[data-open] { background: var(--st-surface-2); padding-bottom: 6px; }
.version-group[data-open] .version-row:hover { background: var(--st-surface); }
@@ -7497,3 +7501,5 @@ textarea {
.st-btn.btn-neutral { background: var(--st-surface); color: var(--st-ink); border: 1px solid var(--st-line); box-shadow: none; }
.st-btn.btn-neutral:hover:not(:disabled) { background: var(--st-surface-2); }
.native-vision-missing { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: var(--st-fs-sm); color: var(--st-ink-soft); }
.storage-model--shared { background: var(--st-surface-2); }
.storage-model--shared small { font-size: var(--st-fs-xs); }
+12
View File
@@ -243,3 +243,15 @@
@media(max-width:700px){.onboard-sharing{grid-template-columns:1fr;gap:20px;padding:22px}.onboard-sharing-computer{width:min(300px,100%);justify-self:center}.onboard-sharing-caption{font-size:14px}.onboard-sharing-numbers{justify-content:space-between;gap:20px}.onboard-sharing-headroom{padding-left:20px}.guided-onboarding .onboard-resource-options>button{padding:16px;grid-template-columns:1fr;gap:7px}.guided-onboarding .onboard-resource-options small{grid-column:1;grid-row:4;justify-self:start}.guided-onboarding .onboard-resource-options strong{font-size:13px}.guided-onboarding .onboard-resource-options p{font-size:11px}.guided-onboarding .onboard-load-vision,.guided-onboarding .onboard-calibration{padding:14px}.guided-onboarding .onboard-load-vision strong,.guided-onboarding .onboard-calibration strong{font-size:12px}}
.onboard-progress-actions{display:flex;align-items:center;gap:10px}
.onboard-tune-stage:has(.error-notice) .onboard-sharing{display:none}
/* ---- QA 2026-10-18: more versions, download parts ---- */
.onboard-more-versions { margin-top: 12px; }
.onboard-more-versions .version-trigger { background: var(--st-surface); }
.native-parts { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: var(--st-fs-sm); }
.native-parts li { display: flex; justify-content: space-between; gap: 12px; padding: 4px 0; border-top: 1px solid var(--st-line-soft); }
.native-parts li[data-state="done"] > span::before { content: "\2713\00a0"; color: var(--st-accent-text); }
.native-parts li[data-state="downloading"] > span { font-weight: 700; }
.engine-warning { display: flex; gap: 10px; align-items: flex-start; margin: 12px 0; padding: 12px 14px; border-radius: var(--st-r-md); background: var(--st-warn-tint); color: var(--st-warn-text); font-size: var(--st-fs-sm); }
.engine-warning p { margin: 0; }
.engine-warning svg { flex: none; margin-top: 1px; }
.engine-warning small { display: block; margin-top: 4px; }
@@ -145,6 +145,10 @@ test('runtime vision checks prepared files, validates mode, and leaves the sourc
assert.equal(runtimeVision(config,'cpu').gpu,false);
assert.equal(runtimeVision(config,'yes').mmproj,config.vision.mmproj);
assert.deepEqual(config,before);
const amd={vision:{exe:'/e/strata-vision-hip',cpu_exe:'/e/strata-vision',mmproj:'/m.gguf',gpu:true}};
assert.deepEqual(runtimeVision(amd,'cpu'),{exe:'/e/strata-vision',mmproj:'/m.gguf',gpu:false});
assert.equal(runtimeVision(amd,'gpu').exe,'/e/strata-vision-hip');assert.equal(runtimeVision(amd,'gpu').cpu_exe,'/e/strata-vision');
assert.equal(runtimeVision(amd,'yes').gpu,true);
assert.throws(()=>runtimeVision({},'yes'),/Prepare/);
assert.throws(()=>runtimeVision(config,'invalid'),/picture reading/);
});
+165
View File
@@ -0,0 +1,165 @@
import test from 'node:test';
import { readFileSync as readSource } from 'node:fs';
import assert from 'node:assert/strict';
import { build } from 'esbuild';
import { createEngineStep } from '../server/engine-step.mjs';
const compiled = await build({
stdin: {
contents: "export * from './src/lib/native'; export * from './src/lib/download-eta'; export * from './src/lib/native-fit'; export * from './src/lib/native-names'; export * from './src/lib/native-catalog'; export * from './src/lib/i18n';",
resolveDir: process.cwd(),
},
bundle: true, write: false, platform: 'node', format: 'esm',
});
const lib = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString('base64')}`);
const job = (progress, state = 'running') => ({ id: 'j1', kind: 'install', state, startedAt: '', summary: '', error: null, result: { install: progress } });
const stateWith = (jobs, extra = {}) => ({ available: true, repo: '', summary: '', error: null, catalog: null, installed: null, hardware: null, instances: [], jobs, ...extra });
const answer = body => ({ ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body) });
test('a slow forced read never holds the light poll: a running download keeps moving', async () => {
const seen = [];
globalThis.fetch = async url => {
seen.push(url);
if (String(url).includes('refresh=1')) { await new Promise(r => setTimeout(r, 400)); return answer(stateWith([job({ download: { done_gb: 1, total_gb: 10 } })])); }
return answer(stateWith([job({ download: { done_gb: 5, total_gb: 10 } })]));
};
const slow = lib.refreshNative(true);
const t0 = Date.now();
await lib.refreshNative();
assert.ok(Date.now() - t0 < 200, 'the light read answered while the forced one was still running');
assert.equal(lib.nativeState().jobs[0].result.install.download.done_gb, 5);
await slow;
assert.ok(seen.some(u => String(u).includes('refresh=1')) && seen.some(u => !String(u).includes('refresh=1')));
});
test('a second forced read waits for the first and then reads again (it never joins a read that began before the action)', async () => {
let calls = 0;
globalThis.fetch = async () => { calls += 1; await new Promise(r => setTimeout(r, 50)); return answer(stateWith([], { summary: `read ${calls}` })); };
const a = lib.refreshNative(true), b = lib.refreshNative(true), c = lib.refreshNative(true);
await Promise.all([a, b, c]);
assert.equal(calls, 2, 'one read for the first, one more for everything asked meanwhile');
assert.equal(lib.nativeState().summary, 'read 2');
});
test('the host pushes its native state and the page takes it at once', () => {
lib.applyNativeEvent(stateWith([job({ download: { done_gb: 7, total_gb: 10 } })], { summary: 'pushed' }));
assert.equal(lib.nativeState().summary, 'pushed');
lib.applyNativeEvent({ nonsense: true });
lib.applyNativeEvent(null);
assert.equal(lib.nativeState().summary, 'pushed', 'a frame that is not a native state changes nothing');
});
test('the poll is quick while a job runs, normal otherwise, and stops while the window is hidden', () => {
assert.equal(lib.nativePollDelay(null), 2500);
assert.equal(lib.nativePollDelay(stateWith([job({})], {})), 1000);
assert.equal(lib.nativePollDelay(stateWith([job({}, 'done')])), 2500);
assert.equal(lib.nativePollDelay(stateWith([job({})]), true), null);
});
const parts = (model, vision, engine = { kind: 'engine', name: 'Engine', state: 'done', done_gb: 0.4, total_gb: 0.4 }) => [model, vision, engine];
const modelPart = (state, done, total, files_done, files_total) => ({ kind: 'model', name: 'Model files', state, done_gb: done, total_gb: total, files_done, files_total });
const visionPart = (state, done = 0) => ({ kind: 'vision', name: 'Picture reader', state, done_gb: done, total_gb: 0.9 });
test('a model in parts says which part is under way, then the picture reader', () => {
lib.setTranslationLanguage('en');
const second = { parts: parts(modelPart('downloading', 20, 66, 1, 2), visionPart('waiting')) };
assert.deepEqual(lib.installStage(second), { kind: 'model', index: 2, count: 2 });
assert.equal(lib.stageText(lib.installStage(second)), 'Downloading model files · part 2 of 2');
const reader = { parts: parts(modelPart('done', 66, 66, 2, 2), visionPart('downloading', 0.3)) };
assert.deepEqual(lib.installStage(reader), { kind: 'vision' });
assert.equal(lib.stageText(lib.installStage(reader)), 'Downloading the picture reader…');
assert.equal(lib.stageText(lib.installStage(undefined)), 'Downloading model files…');
const single = { parts: [modelPart('downloading', 1, 20, 0, 1), visionPart('waiting')] };
assert.equal(lib.stageText(lib.installStage(single)), 'Downloading model files…', 'one file is not "part 1 of 1"');
});
test('the figure counts every part: the model whole but the picture reader not yet is not finished', () => {
const p = lib.installProgress({ parts: parts(modelPart('done', 10, 10, 2, 2), visionPart('downloading', 0.3)) });
assert.equal(p.percent, 94, 'the bar is not at its end while the picture reader is still coming');
assert.ok(Math.abs(p.total_gb - 11.3) < 0.01, `total ${p.total_gb}`);
assert.ok(p.remaining_gb > 0.5);
const half = lib.installProgress({ parts: parts(modelPart('downloading', 33, 66, 1, 2), visionPart('waiting')) });
assert.equal(half.percent, Math.floor(100 * (33 + 0.4) / (66 + 0.9 + 0.4)));
});
test('the engine step keeps the install job\'s own parts (model parts, picture reader) and adds the engine', () => {
const step = createEngineStep({ control: null });
const install = { running: true, step: '5/7: downloading', download: { file: 'picture reader', done_gb: 66.3, total_gb: 66.9, remaining_gb: 0.6, percent: 99 },
parts: [modelPart('done', 66, 66, 2, 2), visionPart('downloading', 0.3)] };
const merged = step.merge(install);
assert.deepEqual(merged.parts.map(p => p.kind), ['model', 'vision', 'engine']);
assert.equal(merged.parts[0].files_total, 2);
const bare = step.merge({ running: true, download: { file: 'model files', done_gb: 1, remaining_gb: 9 } });
assert.deepEqual(bare.parts.map(p => p.kind), ['model', 'engine'], 'an install without part information keeps the old two-part list');
});
test('the recommended four come first; the others are what the "More versions" list holds', () => {
const v = (sourceFamily, model) => ({ sourceFamily, model, id: `${sourceFamily}-${model}` });
const all = [v('qwen', 'Q2_0'), v('gyro', 'Gyro-S'), v('swift', 'IQ2_XS'), v('qwen', 'IQ3_S'), v('unsloth', 'UD-Q4_K_XL'), v('nvfp4', 'NVFP4')];
const rec = lib.recommendedVersions(all).map(x => x.v.model);
assert.deepEqual(rec, ['Gyro-S', 'IQ2_XS', 'IQ3_S', 'UD-Q4_K_XL']);
assert.deepEqual(all.filter(x => !rec.includes(x.model)).map(x => x.model), ['Q2_0', 'NVFP4']);
assert.deepEqual(lib.recommendedVersions([v('qwen36', 'UD-Q4_K_XL')]), [], 'Qwen3.6 has no recommended set: its versions are all shown');
});
test('the three NVFP4 versions have clear names and one line that tells them apart', () => {
lib.setTranslationLanguage('en');
const names = ['NVFP4', 'NVFP4-NVIDIA', 'NVFP4-QAD'].map(m => lib.nativeVersionLabel(m));
assert.deepEqual(names, ['NVFP4 (Strata’s version)', 'NVFP4 (NVIDIA’s version)', 'NVFP4 (NVIDIA, trained for FP4)']);
assert.equal(new Set(names).size, 3);
const lines = ['NVFP4', 'NVFP4-NVIDIA', 'NVFP4-QAD'].map(m => lib.versionNote(m));
assert.match(lines[0], /best quality/i);
assert.match(lines[1], /NVIDIA’s own NVFP4 checkpoint/);
assert.match(lines[2], /trained for FP4/);
assert.match(lines[2], /faster on short prompts/);
assert.equal(lib.versionNote('IQ3_S'), '');
assert.equal(lib.nativeShortName('nvfp4-nvfp4-nvidia'), 'NVFP4 (NVIDIA’s version)');
for (const language of ['zh', 'ja', 'de', 'es', 'ru', 'pt', 'ko']) {
lib.setTranslationLanguage(language);
const local = ['NVFP4', 'NVFP4-NVIDIA', 'NVFP4-QAD'].map(m => lib.nativeVersionLabel(m));
assert.equal(new Set(local).size, 3, `${language}: three different names`);
assert.ok(local.every((n, i) => n !== names[i]), `${language}: translated`);
assert.ok(['NVFP4', 'NVFP4-NVIDIA', 'NVFP4-QAD'].every((m, i) => lib.versionNote(m) !== lines[i]), `${language}: the line is translated`);
}
lib.setTranslationLanguage('en');
});
test('4d: picture reading is on unless the person turned it off (no saved choice is not "off")', async () => {
const { nativeFixture } = await import('./helpers/native-fixture.mjs');
const { readFileSync, writeFileSync } = await import('node:fs');
const { join } = await import('node:path');
const f = nativeFixture();
try {
await f.control.refresh(true);
const first = () => f.control.state().installed.models.find(m => m.model === 'iq2_xs');
assert.equal(first().visionEnabled, undefined, 'a model never loaded and never turned off');
const path = join(f.repo, 'strata-iq2_xs.json');
const cfg = JSON.parse(readFileSync(path, 'utf8'));
writeFileSync(path, JSON.stringify({ ...cfg, ui_vision_enabled: false }));
await f.control.refresh(true);
assert.equal(first().visionEnabled, false, 'turned off by the person: stays off');
writeFileSync(path, JSON.stringify({ ...cfg, ui_vision_enabled: true, ui_vision_device: 'cpu' }));
await f.control.refresh(true);
assert.equal(first().visionEnabled, true);
} finally { await f.cleanup(); }
const screen = readSource(new URL('../src/components/NativeModels.tsx', import.meta.url), 'utf8');
assert.match(screen, /setVision\(installed\.visionEnabled === false \? 'no'/);
assert.match(screen, /else\s+setVision\('yes'\)/, 'a version that is not downloaded starts with Read pictures on');
});
test('no recognised engine: the app still downloads, warns in the card, and loading says it with the next step', async () => {
const { ENGINE_NOT_RECOGNISED } = await import('../server/native-control.mjs');
assert.match(ENGINE_NOT_RECOGNISED, /Settings → Engine/);
assert.match(ENGINE_NOT_RECOGNISED, /didn’t recognise an engine/);
const source = readSource(new URL('../server/native-control.mjs', import.meta.url), 'utf8');
assert.equal((source.match(/ENGINE_NOT_RECOGNISED\+/g) ?? []).length, 2, 'both load paths use it');
const models = readSource(new URL('../src/components/NativeModels.tsx', import.meta.url), 'utf8');
const guided = readSource(new URL('../src/components/GuidedSetup.tsx', import.meta.url), 'utf8');
assert.match(models, /<EngineWarning native=\{native\}\/>/);
assert.match(guided, /<EngineWarning native=\{native\}\/>/);
const warn = readSource(new URL('../src/components/EngineWarning.tsx', import.meta.url), 'utf8');
assert.match(warn, /The model will download, but it may not run until the right engine is installed \(Settings → Engine\)\./);
// the install is not refused for a missing engine (only the engine-minimum refusal for a version that is too old stays)
assert.doesNotMatch(source.slice(source.indexOf('async function install(job,args)'), source.indexOf('function terminate')), /engine\.usable/);
});
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { nativeUnloadPlan } from '../server/storage-check.mjs';
const loaded = [{ id: 'a', model: 'gyro-gyro-s' }, { id: 'b', model: 'iq3_s' }];
test('O01: checking the same folder unloads nothing', () => {
assert.deepEqual(nativeUnloadPlan({ changed: false, unload: true, instances: loaded }), { refuse: null, ids: [] });
assert.deepEqual(nativeUnloadPlan({ changed: false, unload: undefined, instances: loaded }), { refuse: null, ids: [] });
});
test('O01: changing the folder unloads the loaded models only when the person agreed', () => {
assert.match(nativeUnloadPlan({ changed: true, unload: false, instances: loaded }).refuse, /Unload native models/);
assert.deepEqual(nativeUnloadPlan({ changed: true, unload: true, instances: loaded }), { refuse: null, ids: ['a', 'b'] });
assert.deepEqual(nativeUnloadPlan({ changed: true, unload: false, instances: [] }), { refuse: null, ids: [] });
assert.deepEqual(nativeUnloadPlan({ changed: true, unload: true, instances: undefined }), { refuse: null, ids: [] });
});
test('O01: the route uses that plan and the screen asks for an unload only when the folder differs', async () => {
const route = await readFile(new URL('../server/index.mjs', import.meta.url), 'utf8');
assert.match(route, /nativeUnloadPlan\(\{changed,unload:body\.unload,instances:native\.state\(\)\.instances\}\)/);
assert.doesNotMatch(route, /for\(const instance of native\.state\(\)\.instances\)await native\.unload\(instance\.id\);\}\s*\n\s*storageChanging = true/);
const screen = await readFile(new URL('../src/components/SettingsStorage.tsx', import.meta.url), 'utf8');
assert.match(screen, /changeModelDirectory\(path, path\.trim\(\) !== currentDirectory\)/);
});
test('F02: Settings -> Storage has ONE Downloaded models list and one total, with the shared files as a line of their own', async () => {
const screen = await readFile(new URL('../src/components/SettingsStorage.tsx', import.meta.url), 'utf8');
assert.equal((screen.match(/<Field wide label=\{tr\("Downloaded models"\)\}/g) ?? []).length, 1, 'no second section that contradicts the first');
assert.match(screen, /totalGb/);
assert.match(screen, /Shared files/);
assert.match(screen, /No downloaded models/);
});
test('item 8: "Strata" in Settings is the app\'s version; Strata Control\'s 0.1.45 is only a fallback; the update check gets the app version too', async () => {
const { appVersionToShow } = await import('../server/app-version.mjs');
assert.equal(appVersionToShow('0.2.0-beta', '0.1.45'), '0.2.0-beta');
assert.equal(appVersionToShow('dev', '0.1.45'), '0.1.45', 'a source checkout has no app version: the next best number');
assert.equal(appVersionToShow(undefined, null), null);
assert.equal(appVersionToShow('dev', null), 'dev');
const { build } = await import('esbuild');
const out = await build({ stdin: { contents: "export * from './src/lib/app-version';", resolveDir: process.cwd() }, bundle: true, write: false, platform: 'node', format: 'esm' });
const { appVersionLabel } = await import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`);
assert.equal(appVersionLabel('0.2.0-beta', '0.1.45'), '0.2.0-beta');
assert.equal(appVersionLabel('dev', '0.1.45'), '0.1.45');
assert.equal(appVersionLabel(undefined, undefined), undefined);
const route = await readFile(new URL('../server/index.mjs', import.meta.url), 'utf8');
assert.match(route, /appVersion:appVersionToShow\(setupState\(\)\.app\.version,native\.state\(\)\.version\)/);
const updates = await readFile(new URL('../src/components/SettingsUpdates.tsx', import.meta.url), 'utf8');
assert.doesNotMatch(updates, /native\?\.version \?\? appVersion/, 'the Strata row no longer prefers Control\'s version');
assert.match(updates, /AboutVersions/);
});
test('Cargo.toml, tauri.conf.json and the desktop package agree on the app version the host reports', async () => {
const root = new URL('../../desktop/', import.meta.url);
const cargo = /^version\s*=\s*"([^"]+)"/m.exec(await readFile(new URL('src-tauri/Cargo.toml', root), 'utf8'))[1];
const pkg = JSON.parse(await readFile(new URL('package.json', root), 'utf8')).version;
assert.equal(cargo, pkg);
assert.equal(cargo, '0.2.0-beta');
});
@@ -0,0 +1,29 @@
// The saved Monitor picture when a model is loaded (the normal case) starts with the Strata logo alone and the models/time line.
// (QA follow-up: it was checked with a loaded model through the real page; the picture is the one of the Monitor screen, which shows the
// loaded models. NativeMonitor.tsx is not used by any screen, so it needs no header.)
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { readdir } from 'node:fs/promises';
const read = path => readFile(new URL(path, import.meta.url), 'utf8').then(s => s.replace(/\r\n/g, '\n'));
test('with a model loaded the picture is headed by the logo and the line "<model> · <time>"; with none, "N loaded models"', async () => {
const source = await read('../src/screens/Monitor.tsx');
assert.match(source, /const scopeLabel = selectedInstance \? instanceLabel\(selectedInstance\) : `\$\{runtime\?\.instances\.length \?\? 0\} loaded models`;/);
const header = /\{tr\(frozen \? <header className="section-toolbar">(.*?)<\/header> : null\)\}/s.exec(source)?.[1];
assert.ok(header);
assert.match(header, /<Brand variant="lockup"/); assert.doesNotMatch(header, /<h1/);
assert.match(header, /frozen\.modelLabel.*frozen\.time/s);
assert.match(source, /setFrozen\(\{ data: structuredClone\(scoped \?\? live\), hardware: structuredClone\(inventory\), time: new Date\(\)\.toLocaleString\(localeTag\(\)\), modelLabel: scopeLabel \}\)/);
});
test('no screen renders NativeMonitor, so the Monitor screen is the only picture source', async () => {
const dirs = ['../src/screens/', '../src/app/', '../src/components/'];
for (const dir of dirs) {
for (const name of await readdir(new URL(dir, import.meta.url))) {
if (!/\.tsx?$/.test(name) || name === 'NativeMonitor.tsx') continue;
assert.doesNotMatch(await read(dir + name), /<NativeMonitor\b|from '\.\/NativeMonitor'|from '\.\.\/components\/NativeMonitor'/, `${dir}${name}`);
}
}
});
@@ -0,0 +1,29 @@
// Linux file drop (found on z440 under Xvfb with a real GTK drag, WebKitGTK 2.52): the page gets no files and WebKit never raises the drop signal,
// so the shell reads the file:// addresses from the drag data, accepts the drag itself, takes the "leave" with every button up as the drop and
// hands the paths to the host. The behaviour itself is checked on a real X display (scripts in phaseA-tests); these keep the pieces in place.
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('the Linux shell hooks the drag signals of the page and keeps Windows and macOS on their own paths', async () => {
const rust = await read('../../desktop/src-tauri/src/main.rs');
assert.match(rust, /#\[cfg\(target_os = "linux"\)\]\s+hook_linux_drops\(&window, port, shell_token\);/);
assert.match(rust, /view\.connect_drag_data_received\(/); assert.match(rust, /file_uri_to_path\(u\.as_str\(\)\)/);
assert.match(rust, /ctx\.drag_status\(gtk::gdk::DragAction::COPY, time\)/);
assert.match(rust, /pointer_buttons_up\(widget\)/);
assert.match(rust, /hand_over_drop\(target\.clone\(\), port, token\.clone\(\), list\)/);
assert.match(rust, /fn file_uri_to_path\(uri: &str\) -> Option<String>/);
assert.match(rust, /#\[cfg\(windows\)\]\s+let builder = builder\.disable_drag_drop_handler\(\);/);
const toml = await read('../../desktop/src-tauri/Cargo.toml');
assert.match(toml, /\[target\.'cfg\(target_os = "linux"\)'\.dependencies\]\ngtk = "0\.18"\nwebkit2gtk = "2\.0"/);
});
test('only file:// addresses reach the host, which applies the same limits as for every drop', async () => {
const rust = await read('../../desktop/src-tauri/src/main.rs');
assert.match(rust, /strip_prefix\("file:\/\/"\)\?/);
const host = await read('../server/native-drop.mjs');
assert.match(host, /MAX_FILES = 10, MAX_BYTES = 25 \* 1024 \* 1024/);
assert.match(host, /!isAbsolute\(path\)/);
});
+3 -1
View File
@@ -21,7 +21,9 @@ test('item 1: downloaded models get a short name, family and quantization', asyn
assert.equal(nativeShortName('unsloth-ud-q4_k_xl'), 'Unsloth UD-Q4_K_XL');
assert.equal(nativeShortName('gyro-gyro-s'), 'Gyro-S');
assert.equal(nativeShortName('coder-iq1_m'), 'Coder IQ1_M');
assert.equal(nativeShortName('nvfp4-nvfp4'), 'NVFP4 NVFP4');
assert.equal(nativeShortName('nvfp4-nvfp4'), 'NVFP4 (Strata’s version)');
assert.equal(nativeShortName('nvfp4-nvfp4-nvidia'), 'NVFP4 (NVIDIA’s version)');
assert.equal(nativeShortName('nvfp4-nvfp4-qad'), 'NVFP4 (NVIDIA, trained for FP4)');
const storage = await read('components/SettingsStorage.tsx');
assert.match(storage, /title=\{m\.model_name \|\| m\.model\}/, 'the full name is the tooltip');
});
+8 -4
View File
@@ -51,7 +51,12 @@ the kernel's amdgpu driver (no ROCm install needed):
architectures above; the engine is compiled for each of them (cards of two families, e.g. gfx1100 + gfx1201, need
a system ROCm 7: AMD's wheels hold one family). A split pays only when no single card holds the model's experts
(see RDNA4 below).
- **Limits for now:** images only through the CPU encoder (`--vision cpu`, 0.1.32). Setup does not offer the tuning
- **Pictures:** on by default; the reader is the CPU one under 24 GB of VRAM and the GPU one from 24 GB (the same rule for every make, `strata_control/vision_reader.py`); `--vision gpu` / `--vision cpu` switch it. The engine package carries two picture readers: `strata-vision-hip`, which reads a picture on the
card (llama.cpp's HIP backend; about 1.4 GiB of VRAM, measured on an R9700: 0.06 s for a 260-token picture and 0.4 s for 972
tokens, against 3.1 s and 14 s on the CPU), and `strata-vision`, the CPU reader. If the GPU reader cannot start (the card is
not one it was built for, the HIP runtime does not load, the card is busy) the server starts the CPU reader by itself and
prints one line; nothing is asked. `--vision cpu` picks the CPU reader on purpose.
- **Limits for now:** Setup does not offer the tuning
(calibration) on AMD yet: its controls are being checked on HIP one at a time (#566). Since 0.1.39 a tuning run by
hand (`./setup.sh --calibrate`) is saved for the AMD card it ran on and reused when setup runs again. The Monitor
shows the card's load, VRAM, temperature and power from Linux sysfs (0.1.32).
@@ -84,8 +89,7 @@ On a PC with no NVIDIA card Strata can use, the AMD card is chosen by itself; wi
the PATH): if the HIP runtime does not see the card, setup stops there and points to the driver. It also gives the
card's HIP number: with an integrated Radeon that is device 1, not 0 (#325). From then on setup lists the AMD cards
as HIP numbers them, so `--gpu N` and the config's `"gpu"` are HIP numbers.
- **Differences from Windows-on-NVIDIA and Linux-on-AMD:** no images yet (the CPU image encoder is Linux-only for
now), one card per model (`--gpus` is Linux-only for now), no calibration.
- **Differences from Windows-on-NVIDIA and Linux-on-AMD:** one card per model (`--gpus` is Linux-only for now), no calibration.
- Two Windows-only engine details (#247, #325): hipBLAS can return success and still leave `hipErrorInvalidValue`
set after some BF16/FP16 GEMMs (seen on gfx1201); the engine clears that one stale error after a GEMM that
succeeded, on Windows only. `hipHostGetDevicePointer` returns the host pointer itself on Windows: kernels read
@@ -260,7 +264,7 @@ sizes itself automatically and leaves 1 GiB of VRAM headroom.
GPU memory to system RAM (GTT), the OOM killer then ends KWin/plasmashell or `systemd-oomd` ends apps, or the
compositor fails with "Failed to pin framebuffer with error -12".
The installer supports this backend (see "Install with setup" above). Images run through the CPU encoder for now (`--vision cpu`).
The installer supports this backend (see "Install with setup" above). Pictures are read on the card (`strata-vision-hip`) when the engine has it, else on the CPU (`strata-vision`).
Setup installs one AMD card, or several with `--gpus` (the engine's layer split; see RDNA4 below).
**Opt-in switches for RDNA2 prompts and hangs (0.1.40, off by default):** `STRATA_DENSE_MMQ=1` (#820, HIP) runs the
+1 -2
View File
@@ -80,8 +80,7 @@ On Linux setup uses a system ROCm 7 when there is one, or installs ROCm into `.v
sudo), and compiles the engine on your PC for the card (10-20 minutes, once; it needs a C++ compiler and git:
`sudo apt install build-essential git`). Several AMD cards share the model with `--gpus`, as on NVIDIA.
What differs from NVIDIA for now: pictures are read by the image encoder on the CPU (`--vision cpu`, about 3 s per
picture at the default 300 image tokens on 8 cores), `--calibrate` is NVIDIA-only, and Unsloth's 4-bit model needs an NVIDIA card. Measurements per card, the
What differs from NVIDIA for now: `--calibrate` is NVIDIA-only, and Unsloth's 4-bit model needs an NVIDIA card. Measurements per card, the
build by hand and the tuning tables: [AMD_HIP.md](AMD_HIP.md).
## Two or three cards
+3 -2
View File
@@ -28,8 +28,9 @@ to your graphics card) comes with the first model: `strata launch` fetches the o
a helper file is missing.
4. **Context** (8K to 512K, with the extra memory each costs) and the memory format (`m`: 8-bit or 4-bit).
5. **Pictures**: only for models that can read them. They are **on by default** (Yes is preselected; `--vision off` or "No" turns them off). It says what it adds (about 0.9 GB:
the picture-reader file, downloaded with the model through setup's Control mode, the same path the app's "Read pictures" uses). The reader runs on the graphics card
for the NVIDIA (CUDA) and AMD (HIP) builds (it keeps about 1.4 GB free) and on the CPU for the others. It needs `strata-vision` beside the engine in the engine package; with an
the picture-reader file, downloaded with the model through setup's Control mode, the same path the app's "Read pictures" uses). The reader runs on the CPU
for a card under 24 GB (a few seconds per picture, nothing taken from the card) and on the graphics card from 24 GB (about 1-1.4 GB kept free), on every make of card; the
rule lives in one place, `strata_control/vision_reader.py`, for setup, `strata launch` and the app's catalog. `--vision gpu` / `--vision cpu` (or the Pictures step) switch it per model. It needs a picture reader beside the engine in the engine package; with an
engine that has none, pictures default to off and the screen says so in plain words. A model that cannot read pictures stays off with no question.
6. **Parallel requests**: "fit to your card" (12 GB: 1, 20: 2, 24: 3, 32+: 4) or 1 to 4; a warning if it will not fit.
7. **Tuning**: quick (about 20 minutes) / thorough (up to 2 hours) / skip (`strata tune auto`; the "answers may change very slightly" tick is off). The screens suggest quick the first
+2 -1
View File
@@ -133,7 +133,8 @@ setup warns when it finds 13.2.0 or 13.2.1 for such a card, and takes an older 1
**Pictures are refused, or slow.**
"this server was started without the vision encoder": the model was set up for text only - run setup again with
`--vision gpu` (or `--vision cpu`). Pictures that take several seconds (about 3 s at 300 image tokens on 8 cores, more with more tokens) are read by the encoder on the CPU; `--vision gpu`
(NVIDIA, ~1.4 GB of VRAM) makes it 0.1-0.5 s.
(NVIDIA and AMD, ~1.4 GB of VRAM) makes it 0.1-0.5 s. On AMD the GPU reader is the default; if it cannot start, the server
prints "the GPU picture reader did not start (...); pictures are read on the CPU" and goes on with the CPU reader.
## AMD cards
+12 -2
View File
@@ -95,9 +95,9 @@ the manifest file (which holds every file's hash), used as the package's digest
|---|---|---|---|
| `windows-x86_64` cuda, variant `cuda13` | the owner PC, the same cmake line as the release zips (CUDA 13.0, `75;86;89;120` real + `120` PTX, `STRATA_PORTABLE=ON`) | `strata.exe`, `strata_kq.dll`, `strata-vision.exe` (reused, unchanged since 0.1.44), `BUILD.json` | `min_driver` nvidia 580.65 |
| `windows-x86_64` cuda, variant `cuda12` | the owner PC (CUDA 12.9, `60;61;70;75;80;86;89` real + `89` PTX, `STRATA_EXPERIMENTAL_SM60=ON`) | the same four files | experimental, `min_driver` nvidia 575.51; the CUDA 13 build wins when both fit |
| `windows-x86_64` hip | the owner PC, `tools/hip/build_windows.bat` (ROCm 10.2 nightly wheels) then `tools/hip/package_windows.py` | `strata.exe`, `strata-device.exe`, the ROCm DLLs and the rocBLAS/hipBLASLt kernels of the zip (`--tree`) | no Windows AMD test machine yet |
| `windows-x86_64` hip | the owner PC, `tools/hip/build_windows.bat` (ROCm 10.2 nightly wheels) then `tools/hip/package_windows.py` | `strata.exe`, `strata-device.exe`, the ROCm DLLs and the rocBLAS/hipBLASLt kernels of the zip (`--tree`) | no Windows AMD test machine yet; with `STRATA_NO_VISION` unset it also builds the two picture readers (`strata-vision.exe` on the CPU, `strata-vision-hip.exe` on the GPU) |
| `linux-x86_64` cuda | a Linux box with the CUDA toolkit | `strata`, `libstrata_kq.so`, `libcudart/libcublas/libcublasLt` (13.x) | the GPU driver's `libcuda` stays the PC's |
| `linux-x86_64` hip | a box with ROCm (the Python wheels are enough) | `strata`, the HIP runtime, hipBLAS/hipBLASLt/rocBLAS, comgr + libLLVM, `rocblas/library` and `hipblaslt/library` for the listed gfx archs | |
| `linux-x86_64` hip | a box with ROCm (the Python wheels are enough) | `strata`, the HIP runtime, hipBLAS/hipBLASLt/rocBLAS, comgr + libLLVM, `rocblas/library` and `hipblaslt/library` for the listed gfx archs; `packaging/build_hip_linux.sh` builds it with both picture readers |
| `linux-x86_64` hip, gfx1103 (Radeon 780M / Phoenix iGPU) | `packaging/build_hip_gfx1103.sh` on the mini PC (TheRock gfx110X-all SDK) | the same set as the gfx1200/1201 package, with `rocblas/library` and `hipblaslt/library` for gfx1103 (600 MiB) | experimental, unvalidated: the iGPU resets now and then (MES REMOVE_QUEUE, notes-minipc.md); never kill the engine mid-request; run configs set `STRATA_NO_ARENA_THP=1`. `engine_component.resolve` picks a HIP package only when its `archs` list a gfx target the Linux kfd topology reports |
| `linux-x86_64` sycl | a box with oneAPI DPC++ | `strata` (JIT from SPIR-V), libsycl, the Unified Runtime adapters, MKL SYCL libraries | the Level Zero loader and compute runtime are the PC's driver |
| `macos-arm64` metal | an Apple Silicon Mac | `strata`, `metal/kernels/*.metal`, `metal/engine-msl/*.metal`, `metal/ggml-common.h`, `metal/data/*.bin` | the Metal engine compiles its kernel sources when it starts; since S1 it finds them beside the executable (`metal/src/resource_path.h`: environment variable, then `<exe dir>/metal/`, then the compiled-in source path) |
@@ -105,3 +105,13 @@ the manifest file (which holds every file's hash), used as the package's digest
`packaging/collect_libs.py` gathers the Linux libraries (an `ldd` closure with the dlopen'd extras named, kernel data filtered by
GPU architecture) into the folder `package_engine.py` packages; libraries the distribution provides are listed in
`SYSTEM-LIBS.txt` inside the package so a missing one can be named on a target PC.
## Picture readers in an AMD package
A HIP package carries two programs for pictures next to `strata` (`vision` in its BUILD.json): `strata-vision` is the CPU reader, which
needs nothing of the GPU runtime and starts on every PC, and `strata-vision-hip` is the same reader on the card (llama.cpp's HIP
backend, built for the package's `archs`; BUILD.json `vision_archs`). Setup writes the run config with the GPU reader first and the CPU
reader as `vision.cpu_exe`; when the GPU reader does not start (the card is not in its build, the HIP runtime does not load, the card is
busy) the server starts the CPU reader by itself and prints one line. Both programs have RUNPATH `$ORIGIN` on Linux; on Windows the
ROCm DLLs they import sit beside the programs or in `rocm/bin` (the engine's PATH entry, `lib_dirs`). `strata_control/vision_reader.py`
holds the choice; `tools/test_vision_reader.py` the tests.
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# Linux AMD (HIP) engine package: strata + the CPU picture reader (strata-vision) + the GPU picture reader (strata-vision-hip),
# the ROCm libraries both load, and the rocBLAS / hipBLASLt kernel data for the listed archs, packaged as an engine component.
# Run on a Linux box that has ROCm (TheRock Python wheels are enough); the tester packages were built in an Ubuntu 22.04 chroot
# (glibc 2.35) so they run on every distribution since. All settings are environment variables:
#
# VARIANT=rx9070 rx9070 (gfx1200 gfx1201) | rx7900 (gfx1100 gfx1101 gfx1102) | halo (gfx1151) | igpu (gfx1103 gfx1150) | a name + ARCHS=
# ARCHS="gfx1200;gfx1201" the code objects (default: the variant's)
# SRC=<this checkout> the Strata source (default: the checkout this script is in)
# LLAMA=<llama.cpp checkout> at the pinned commit (setup.py LLAMA_CPP_COMMIT); mtmd and ggml come from it
# HP=<ROCm devel root> CORE=<ROCm core root> LIBW=<ROCm libraries root> (TheRock: _rocm_sdk_devel, _rocm_sdk_core, _rocm_sdk_libraries_*)
# OUT=<work folder> PKGVER=0.1.45.3 COMMIT=<source commit for the build id> JOBS=16
# VISION_ONLY=1 build only the two readers (no engine, no package)
# NO_PACKAGE=1 stop after the stage folder
# VISION_HIP_DEFS="-DGGML_CUDA_FA=OFF" extra cmake definitions for the GPU reader only
#
# Both readers get RUNPATH $ORIGIN (tools/vision/CMakeLists.txt), and every bundled library is patched to $ORIGIN, so the
# component folder runs from anywhere with nothing from the environment (packaging/binary_deps.py checks it).
set -u
V=${VARIANT:?VARIANT=rx9070|rx7900|halo|igpu|<name> with ARCHS=}
SRC=${SRC:-$(cd "$(dirname "$0")/.." && pwd)}
LLAMA=${LLAMA:?LLAMA=<llama.cpp checkout at the pinned commit>}
HP=${HP:?HP=<ROCm devel root>}
CORE=${CORE:-$HP}
LIBW=${LIBW:-$HP}
OUT=${OUT:-$PWD/work-$V}
PKGVER=${PKGVER:-0.1.45.3}
COMMIT=${COMMIT:-unknown}
JOBS=${JOBS:-16}
case "$V" in
rx9070) ARCHS=${ARCHS:-gfx1200;gfx1201} ;;
rx7900) ARCHS=${ARCHS:-gfx1100;gfx1101;gfx1102} ;;
halo) ARCHS=${ARCHS:-gfx1151} ;;
igpu) ARCHS=${ARCHS:-gfx1103;gfx1150} ;;
*) ARCHS=${ARCHS:?ARCHS=gfx...} ;;
esac
ARCHLIST=${ARCHS//;/ }
DATAARCH=${ARCHS//;/,}
mkdir -p "$OUT"; S=$OUT/build.status
st() { echo "$* $(date +%T)" >> "$S"; }
: > "$S"
export HIP_PLATFORM=amd HIP_COMPILER=clang HIP_RUNTIME=rocclr ROCM_PATH=$HP HIP_PATH=$HP
HIPDEFS=(-DCMAKE_HIP_COMPILER=$HP/llvm/bin/clang++ -DCMAKE_HIP_COMPILER_ROCM_ROOT=$HP "-DCMAKE_PREFIX_PATH=$HP;$LIBW"
"-DCMAKE_HIP_FLAGS=--rocm-path=$HP --rocm-device-lib-path=$HP/lib/llvm/amdgcn/bitcode")
st "start $V ($ARCHS) commit $COMMIT"
cd "$SRC" || exit 1
if [ -z "${VISION_ONLY:-}" ]; then
cmake -G Ninja -S . -B "$OUT/b" -DCMAKE_BUILD_TYPE=Release -DSTRATA_ENABLE_HIP=ON -DSTRATA_ENABLE_CUDA=OFF -DSTRATA_BUILD_TESTS=OFF \
-DSTRATA_PREFILL_MMQ=ON "-DCMAKE_HIP_ARCHITECTURES=$ARCHS" "${HIPDEFS[@]}" -DSTRATA_GGML_DIR="$LLAMA" -DSTRATA_PORTABLE=ON \
"-DCMAKE_EXE_LINKER_FLAGS=-Wl,-rpath,'\$ORIGIN' -Wl,--enable-new-dtags" > "$OUT/cfg.log" 2>&1
st "engine configured rc=$?"
nice -n 10 cmake --build "$OUT/b" --target strata -j "$JOBS" > "$OUT/build.log" 2>&1
st "engine built rc=$?"
[ -x "$OUT/b/strata" ] || { st "ABORT no strata"; exit 1; }
fi
# the CPU reader: portable (AVX2), the fallback that runs on any PC
cmake -G Ninja -S tools/vision -B "$OUT/bv" -DCMAKE_BUILD_TYPE=Release -DLLAMA_DIR="$LLAMA" -DSTRATA_VISION_CUDA=OFF \
-DSTRATA_PORTABLE=ON > "$OUT/cfg-vision.log" 2>&1
nice -n 10 cmake --build "$OUT/bv" --target strata-vision -j "$JOBS" > "$OUT/build-vision.log" 2>&1
st "cpu reader built rc=$?"
[ -x "$OUT/bv/bin/strata-vision" ] || { st "ABORT no strata-vision"; exit 1; }
# the GPU reader: ggml-hip for the same archs; the CPU kernels in it are portable too
cmake -G Ninja -S tools/vision -B "$OUT/bh" -DCMAKE_BUILD_TYPE=Release -DLLAMA_DIR="$LLAMA" -DSTRATA_VISION_HIP=ON \
"-DSTRATA_VISION_HIP_ARCHS=$ARCHS" -DSTRATA_PORTABLE=ON "${HIPDEFS[@]}" ${VISION_HIP_DEFS:-} > "$OUT/cfg-vision-hip.log" 2>&1
st "gpu reader configured rc=$?"
nice -n 10 cmake --build "$OUT/bh" --target strata-vision -j "$JOBS" > "$OUT/build-vision-hip.log" 2>&1
st "gpu reader built rc=$?"
[ -x "$OUT/bh/bin/strata-vision-hip" ] || { st "ABORT no strata-vision-hip"; tail -20 "$OUT/build-vision-hip.log" >> "$S"; exit 1; }
[ -n "${VISION_ONLY:-}" ] && { st "VISION-DONE"; exit 0; }
STAGE=$OUT/stage; rm -rf "$STAGE"; mkdir -p "$STAGE"
ARCHJSON=$(for a in $ARCHLIST; do printf '"%s", ' "$a"; done | sed 's/, $//')
cat > "$STAGE/BUILD.json" <<EOJ
{"version": "$PKGVER", "source": "f-amd-vision $COMMIT", "variant": "$V", "archs": [$ARCHJSON], "portable": true, "vision": "gpu", "vision_cpu": "strata-vision", "vision_gpu": "strata-vision-hip", "vision_archs": [$ARCHJSON]}
EOJ
# the libraries the engine and the GPU reader load (the CPU reader needs only the system's), collected from the build outputs
DATA=(--data "$LIBW/lib/rocblas/library=rocblas/library" --data "$LIBW/lib/hipblaslt/library=hipblaslt/library" --data-arch "$DATAARCH")
for b in "$OUT/b/strata" "$OUT/bh/bin/strata-vision-hip"; do
python3 packaging/collect_libs.py --binary "$b" --search "$LIBW/lib" --search "$HP/lib" --search "$CORE/lib" --search "$CORE/lib/llvm/lib" \
--also 'libamd_comgr.so*' --also 'libhiprtc*.so*' --out "$STAGE" \
"${DATA[@]}" >> "$OUT/collect.log" 2>&1
st "collected for $(basename "$b") rc=$?"
DATA=() # the kernel data once
done
cp "$OUT/b/strata" "$OUT/bv/bin/strata-vision" "$OUT/bh/bin/strata-vision-hip" "$STAGE/"
for f in "$STAGE"/*.so "$STAGE"/*.so.*; do [ -f "$f" ] && [ ! -L "$f" ] && patchelf --set-rpath '$ORIGIN' "$f"; done
for f in "$STAGE/strata" "$STAGE/strata-vision" "$STAGE/strata-vision-hip"; do
st "RUNPATH $(basename "$f"): $(readelf -d "$f" | grep -E 'RPATH|RUNPATH' | cut -d'[' -f2 | tr -d ']' | paste -sd' ')"
done
[ -n "${NO_PACKAGE:-}" ] && { st "STAGE-DONE"; exit 0; }
rm -rf "$OUT/components"; mkdir -p "$OUT/components/engine"
ARCHARGS=""; for a in $ARCHLIST; do ARCHARGS="$ARCHARGS --arch $a"; done
python3 packaging/package_engine.py --src "$STAGE" --backend hip --version "$PKGVER" --out "$OUT/components/engine" $ARCHARGS \
--variant "$V" --build-id "$COMMIT+rocm" --tree --notes "AMD $V build: engine, CPU picture reader, GPU picture reader" > "$OUT/package.log" 2>&1
st "packaged rc=$?"
cat "$OUT/package.log" >> "$S"
st "BUILD-DONE"
+1 -1
View File
@@ -53,7 +53,7 @@ def sha256(path: Path) -> str:
def wanted(path: Path) -> bool:
name = path.name.lower()
return (name.endswith((".dll", ".so", ".dylib", ".metallib")) or ".so." in name or name == "build.json"
or name.startswith("license") or name in ("strata-vision", "strata-vision.exe")) # the picture reader beside the engine (pictures need it)
or name.startswith("license") or name in ("strata-vision", "strata-vision.exe", "strata-vision-hip", "strata-vision-hip.exe")) # the picture readers (CPU, AMD GPU) beside the engine
def git_build_id(repo: Path = ROOT) -> str:
+35 -10
View File
@@ -66,6 +66,7 @@ from serve.frontend import (THINK_END, ChatTemplate, Event, OutputParser, anthro
from serve.mcp import McpCancelled, hub_from_config # noqa: E402
from serve import runconfig # noqa: E402
from strata_control import engine_component # noqa: E402
from strata_control import vision_reader # noqa: E402
from serve import ready as ready_file # noqa: E402
from serve import owner_guard # noqa: E402
from serve import launch_identity # noqa: E402
@@ -2494,18 +2495,13 @@ class Vision:
return Path(tempfile.mkdtemp(prefix="strata-vision-"))
def __init__(self, cfg: dict, log=None, env: dict | None = None, lazy: bool = False):
absolute = lambda p: os.path.abspath(p) if os.path.dirname(str(p)) else p # the encoder runs in its own dir (#480) # noqa: E731
args = [absolute(cfg["exe"]), "--mmproj", absolute(cfg["mmproj"]), "--model", absolute(cfg["model"])]
if cfg.get("gpu"):
args.append("--gpu")
if cfg.get("threads"):
args += ["--threads", str(cfg["threads"])]
if cfg.get("max_tokens"):
args += ["--max-tokens", str(cfg["max_tokens"])]
if cfg.get("min_tokens"): # #767: mtmd's image_min_tokens (a hand-edited key)
args += ["--min-tokens", str(cfg["min_tokens"])]
args = self.reader_args(cfg)
self.dir = self.work_dir()
self.spawn = (args, log, env) # to start it again after an unload
# AMD: the GPU reader (strata-vision-hip) has the CPU reader (strata-vision, same program, no GPU runtime) to
# fall back on when it does not start; _start switches to it by itself
cpu = vision_reader.cpu_fallback(cfg)
self.fallback = (self.reader_args(cpu), log, env) if cpu else None
self.proc = None
self.stopped = True
self.lock = threading.Lock()
@@ -2519,7 +2515,36 @@ class Vision:
if not lazy and not self.elastic:
self._start()
@staticmethod
def reader_args(cfg: dict) -> list:
absolute = lambda p: os.path.abspath(p) if os.path.dirname(str(p)) else p # the encoder runs in its own dir (#480) # noqa: E731
args = [absolute(cfg["exe"]), "--mmproj", absolute(cfg["mmproj"]), "--model", absolute(cfg["model"])]
if cfg.get("gpu"):
args.append("--gpu")
if cfg.get("threads"):
args += ["--threads", str(cfg["threads"])]
if cfg.get("max_tokens"):
args += ["--max-tokens", str(cfg["max_tokens"])]
if cfg.get("min_tokens"): # #767: mtmd's image_min_tokens (a hand-edited key)
args += ["--min-tokens", str(cfg["min_tokens"])]
return args
def _start(self):
"""Start the reader. When a GPU reader that has a CPU reader to fall back on does not start (the GPU runtime does not
load, the card is not in its build, the card is busy), the CPU reader is started instead, for good, with one log line
and no question."""
try:
self._start_reader()
except (RuntimeError, OSError) as e:
fallback = getattr(self, "fallback", None)
if fallback is None:
raise
why = " ".join(str(e).split())[:240]
print(f"[strata] the GPU picture reader did not start ({why}); pictures are read on the CPU", flush=True)
self.spawn, self.fallback = fallback, None
self._start_reader()
def _start_reader(self):
args, log, env = self.spawn
# cwd is the encoder's directory: the ENC lines name the files relative to it (#480: no space in the line)
self.proc = popen("the image encoder", args, role="vision", stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+99 -33
View File
@@ -574,6 +574,7 @@ VISION = {"gpu": {"max_tokens": 1024, "reserve_mib": 700},
"cpu": {"max_tokens": 300, "reserve_mib": 700}}
EXE = "strata.exe" if WIN else "strata"
VEXE = "strata-vision.exe" if WIN else "strata-vision"
HVEXE = "strata-vision-hip.exe" if WIN else "strata-vision-hip" # AMD: the picture reader on the GPU (tools/vision, STRATA_VISION_HIP)
# MODEL-UPDATE Phase 1: the K-quant prompt kernels (Unsloth's UD-Q4/Q5/Q6_K_XL experts) are a library next to the CUDA
# engine, opened only for those models; an engine without it runs them on the FP16 prompt path
KQ_LIB = "strata_kq.dll" if WIN else "libstrata_kq.so"
@@ -2142,7 +2143,7 @@ def unsloth_images_default(model, family, ram, gpu=None) -> bool:
return g.get("vram_gb", 0.0) >= UNSLOTH_IMAGES_MIN_VRAM_GB
IMAGES_GPU_ENCODER_MIN_VRAM_GB = 23.5 # #2406: below this the images answer "yes" gets the CPU encoder (a card of 12-16 GB: the GPU
IMAGES_GPU_ENCODER_MIN_VRAM_GB = 23.5 # = strata_control/vision_reader.py GPU_MIN_VRAM_GB, the one place for the rule; #2406: below this the images answer "yes" gets the CPU encoder (a card of 12-16 GB: the GPU
# encoder's ~1.1-1.4 GB is ~850 expert slots lost, 3-5% of decode and prompt on P100 / A4000
# 16 GB (Linux, IQ3_S / IQ3_XXS), and 35-60% on an RTX 4080 SUPER under Windows as reported)
@@ -3340,12 +3341,13 @@ def build_engine_hip(gpu, llama, vision="none") -> Path:
floor = cpu_floor(cpu_info()[1]) # "" on an AVX2 CPU: the normal engine
engine_ok = meta.get("backend") == "hip" and (eng / EXE).exists() and meta.get("src") == src and has_archs and \
(meta.get("isa_floor") or "") == floor and not stale_binary(eng / EXE)
vision_ok = vision == "none" or ((eng / VEXE).exists() and meta.get("vision_src") == vsrc)
vision_ok = vision == "none" or ((eng / VEXE).exists() and meta.get("vision_src") == vsrc and
(vision != "gpu" or ((eng / HVEXE).exists() and set(archs) <= set(meta.get("vision_archs", [])))))
if engine_ok and vision_ok:
ok("engine already built for this PC")
return eng
if engine_ok:
return build_vision_cpu(eng, stamp, meta, llama, vsrc)
return build_vision_hip(eng, stamp, meta, llama, vsrc, archs, vision)
if not (shutil.which("c++") or shutil.which("g++")) or not shutil.which("git"):
fail("a C++ compiler and git are needed to compile the AMD engine",
"Ubuntu/Debian: sudo apt install build-essential git Fedora: sudo dnf install gcc-c++ git")
@@ -3376,23 +3378,80 @@ def build_engine_hip(gpu, llama, vision="none") -> Path:
meta = {"source": "local-hip", "backend": "hip", "version": source_version(), "archs": archs, "vision": "none",
"lib_dirs": dirs, "src": src, **({"isa_floor": floor} if floor else {})}
if vision != "none":
return build_vision_cpu(eng, stamp, meta, llama, vsrc)
return build_vision_hip(eng, stamp, meta, llama, vsrc, archs, vision)
stamp.write_text(json.dumps(meta, indent=1))
ok(f"engine compiled: {eng / EXE}")
return eng
def hip_vision(asked) -> str:
"""The image encoder with the AMD backend (--vision): the CPU one when asked for (#304); a HIP (GPU) encoder build
is a later step, so `yes`/`gpu` leave images off, as before, and say how to get them."""
if asked in ("yes", "gpu"):
warn("the AMD backend has no GPU image encoder yet: images off"
+ ("" if WIN else " (--vision cpu reads them on the CPU)"))
if asked == "cpu" and WIN:
warn("images on the CPU with an AMD card are Linux-only for now (the ready-made Windows AMD engine has no "
"image encoder): images off")
return "none"
return "cpu" if asked == "cpu" else "none"
def hip_vision(asked, vram_gb: float = 0.0) -> str:
"""The picture reader wanted with the AMD backend (--vision): when nothing was asked or "yes" (pictures are on by default) the
default of strata_control/vision_reader.py for this card - the CPU reader under 24 GB, the GPU reader from 24 GB (owner decision
2026-10-18, every make of card); "gpu" / "cpu" as asked (#304), "none" for no. "gpu" is a wish: hip_reader() settles it against
what the engine folder carries (the HIP reader, else the CPU one) once the engine is known."""
from strata_control import vision_reader as VR
return VR.wanted(asked, vram_gb)
def reader_archs(eng: Path) -> list:
"""The GPU architectures the engine folder's HIP picture reader was built for: its BUILD.json (vision_archs, else archs),
else the component manifest's archs; [] when neither says (the reader then checks the card itself when it starts)."""
for name, keys in (("BUILD.json", ("vision_archs", "archs")), ("engine-component.json", ("archs",))):
try:
data = json.loads((eng / name).read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
continue
for key in keys:
if isinstance(data, dict) and isinstance(data.get(key), list) and data[key]:
return [str(x) for x in data[key]]
return []
def hip_reader(eng: Path, vision: str, gpu: dict):
"""AMD: the picture reader this engine folder gets for `vision` ("gpu" or "cpu"; strata_control/vision_reader.py): the HIP
reader with the CPU reader to fall back on, else the CPU reader. Returns (vision, Reader); a Reader of mode "none" means
the folder has no reader. The one plain note (a card the HIP reader has no code for, no HIP reader in this engine) is said."""
from strata_control import vision_reader as VR
r = VR.choose(eng, vision, windows=WIN, gpu_arch=str(gpu.get("arch") or ""), archs=reader_archs(eng))
if r.note:
warn(r.note)
return r.mode, r
def build_vision_hip(eng: Path, stamp: Path, meta: dict, llama, vsrc, archs: list, vision: str) -> Path:
"""The picture readers beside the HIP engine: the CPU reader always (the fallback), and for "gpu" the HIP reader
(tools/vision, STRATA_VISION_HIP) for the same architectures. The HIP reader needs the ROCm environment the engine build
sets; a build that fails leaves the CPU reader and says so (pictures still work, on the CPU)."""
if vision == "gpu" and not (shutil.which("hipconfig") or os.environ.get("ROCM_PATH")):
root, dirs = rocm_root(archs) # the engine was built earlier: set the same environment again
os.environ.update({"HIP_PLATFORM": "amd", "HIP_COMPILER": "clang", "HIP_RUNTIME": "rocclr", "ROCM_PATH": str(root),
"HIP_PATH": str(root)})
os.environ["LD_LIBRARY_PATH"] = os.pathsep.join(dirs + [os.environ.get("LD_LIBRARY_PATH", "")]).rstrip(os.pathsep)
os.environ["PATH"] = os.pathsep.join([str(root / "bin"), str(root / "llvm" / "bin"), os.environ.get("PATH", "")])
build_vision_cpu(eng, stamp, meta, llama, vsrc)
meta = json.loads(stamp.read_text(encoding="utf-8"))
if vision != "gpu":
return eng
root, dirs = rocm_root(archs)
libs = [str(Path(d).parent) for d in dirs[1:]]
bitcode = next((p for p in (root / "lib" / "llvm" / "amdgcn" / "bitcode", root / "amdgcn" / "bitcode") if p.is_dir()),
root / "amdgcn" / "bitcode")
say(" Compiling the picture reader for your AMD GPU (a few minutes, once) ...")
try:
cmake_build(ROOT / "tools" / "vision", ROOT / "build-vision-hip", "strata-vision",
[f"-DLLAMA_DIR={llama}", "-DSTRATA_VISION_HIP=ON", "-DSTRATA_VISION_HIP_ARCHS=" + ";".join(archs),
"-DSTRATA_PORTABLE=OFF", f"-DCMAKE_HIP_COMPILER={root / 'llvm' / 'bin' / 'clang++'}",
f"-DCMAKE_HIP_COMPILER_ROCM_ROOT={root}", "-DCMAKE_PREFIX_PATH=" + ";".join([str(root), *libs]),
f"-DCMAKE_HIP_FLAGS=--rocm-path={root} --rocm-device-lib-path={bitcode}"], None, "")
shutil.copy2(ROOT / "build-vision-hip" / "bin" / HVEXE, eng / HVEXE)
except SystemExit:
raise
except Exception as e: # noqa: BLE001 - the CPU reader is there; pictures work without the GPU one
warn(f"the GPU picture reader did not compile ({e}): pictures are read on the CPU")
return eng
stamp.write_text(json.dumps({**meta, "vision": "gpu", "vision_archs": archs, "vision_gpu": HVEXE, "vision_cpu": VEXE}, indent=1))
ok(f"picture reader (GPU): {eng / HVEXE}, (CPU fallback): {eng / VEXE}")
return eng
def build_vision_cpu(eng: Path, stamp: Path, meta: dict, llama, vsrc) -> Path:
@@ -4841,7 +4900,7 @@ SETUP_ENV = frozenset({"STRATA_HIPBLASLT_TUNING", "STRATA_ROCBLAS_TUNING", "STRA
# user's own (STRATA_ROUTE_TAIL_SKIP=0 to turn the tail skip off, STRATA_MMQ_KQ=1 ...): a setup run again keeps them there
# (0.1.44 dropped them with the rest of SETUP_ENV).
SETUP_ENV_UNSLOTH = frozenset({"STRATA_MMQ_KQ", "STRATA_HC_Q8", "STRATA_ROUTE_TAIL_SKIP"})
SETUP_VISION = frozenset({"exe", "mmproj", "model", "gpu", "max_tokens", "threads"})
SETUP_VISION = frozenset({"exe", "cpu_exe", "mmproj", "model", "gpu", "max_tokens", "threads"})
def carry_over(old: dict, cfg: dict) -> list[str]:
@@ -5938,16 +5997,17 @@ def control_engine_step(gpu, vision):
ok(f"engine component: {found.version} ({found.platform}-{found.backend}) in {eng}")
except EC.EngineMissing as e:
plain = getattr(e, "plain_notice", None) or getattr(e, "driver_notice", None)
if (plain or os.environ.get("STRATA_NEED_ENGINE") == "1") and os.environ.get("STRATA_ALLOW_NO_ENGINE") != "1":
# The model is tens of GB: when the engine is a dead end for this PC (a build for another card family, a driver that is too
# old) or the caller (strata launch) needs one now, stop before step 5 instead of after the download.
if os.environ.get("STRATA_NEED_ENGINE") == "1" and os.environ.get("STRATA_ALLOW_NO_ENGINE") != "1":
# strata launch needs an engine now: stop before step 5 instead of after the download, unless launch itself asked to go on
# (STRATA_ALLOW_NO_ENGINE: the person said yes to "download the model anyway"). The app does not stop (owner decision
# 2026-10-18): it says so in its download card and still downloads the model.
fail(plain or "no engine is installed for this PC",
"get the engine that matches this graphics card (`strata update`, or the app's Settings), then run this again; "
"nothing was downloaded and files already downloaded are kept")
eng = home() / "engine"
meta = {"version": "99.0.0", "source": "local", "component": "missing"} # its version is checked when it loads
warn(f"no engine is installed for this PC yet ({e}). The model is downloaded and prepared now; install the engine "
"component for your GPU (a later step of the app, or the Terminal) before loading it")
warn(f"{plain + ' ' if plain else ''}no engine is installed for this PC yet ({e}). The model is downloaded and prepared now; "
"install the engine component for your GPU (the app's Settings, or the Terminal) before loading it")
if vision != "none":
if (eng / VEXE).is_file():
ok(f"image encoder: {eng / VEXE} (carried by the engine component)")
@@ -6029,22 +6089,16 @@ def own_path_vision(a, fam: dict, gpu: dict, hip: bool) -> str:
say()
say(f" Images: {fam['title']} can also read pictures (screenshots, photos, scanned pages). This adds a "
f"{fam['sha256'][fam['mmproj']][0] / 1e9:.1f} GB download" + (
"; the encoder runs on the CPU (no VRAM), a few seconds per picture." if hip else
"; the encoder runs on the GPU when the engine has the AMD one (about 1 GB of VRAM), else on the CPU." if hip else
" and the image encoder runs on the GPU (~1.4 GB of VRAM kept free, 0.1-0.5 s per picture)." if big else
"; the encoder runs on the CPU (a few seconds per picture), because the GPU encoder would take ~1.4 GB "
"of VRAM from the experts on this card."))
default = "n" if (hip and WIN) else "y" if (big or hip) else "n"
default = "y" if (big or hip) else "n"
if ask("Do you want images?", ["y", "n"], default, a.yes) != "y":
return "none"
asked = "cpu" if (hip or not big) else "gpu"
asked = "yes" if hip else "cpu" if not big else "gpu"
if hip:
if asked == "gpu":
say(" The AMD backend has no GPU image encoder yet: the encoder runs on the CPU.")
if WIN:
warn("images on the CPU with an AMD card are Linux-only for now (the ready-made Windows AMD engine has no "
"image encoder): images off")
return "none"
return "cpu"
return hip_vision(asked, vram) # settled against the engine's readers in hip_reader() (GPU reader, else CPU)
if asked == "yes":
asked = "gpu" if big else "cpu"
if asked == "gpu" and not big and vram > 0:
@@ -6207,7 +6261,10 @@ def install_own_path(a, family, gpu, hip, amd, cuda_tk, multi, port, ram, adopte
step(4, "the Strata engine")
llama, eng, meta, lib_dirs, gpu, vision = engine_step(a, gpu, hip, amd, cuda_tk, vision)
if vision != "none" and not (eng / VEXE).exists():
reader = None
if hip and vision != "none":
vision, reader = hip_reader(eng, vision, gpu)
elif vision != "none" and not (eng / VEXE).exists():
warn("this engine has no image encoder: images off (run setup again with --build to compile it)")
vision = "none"
if fam["arch"] not in engine_families(eng):
@@ -6276,6 +6333,9 @@ def install_own_path(a, family, gpu, hip, amd, cuda_tk, multi, port, ram, adopte
cfg["args"].append("--vision")
cfg["vision"] = {"exe": str(eng / VEXE), "mmproj": str(mmproj), "model": str(gguf), "gpu": vision == "gpu",
"max_tokens": vt} # the text model, opened vocab-only
if reader is not None: # AMD: the HIP reader with the CPU reader behind it, or the CPU reader
from strata_control import vision_reader as VR
cfg["vision"] = VR.vision_section(reader, cfg["vision"])
if vision == "cpu":
cfg["vision"]["threads"] = vision_threads()
elif getattr(a, "vision_tokens", None) is not None:
@@ -7125,7 +7185,7 @@ def main() -> int:
# MODEL-UPDATE: Unsloth's files on AMD (Linux) read images with the CPU encoder by default where their experts
# all stay in RAM (it takes no VRAM; measured on an R9700 with UD-Q4_K_XL and UD-Q5_K_XL, docs/QUANTS.md)
vision = hip_vision(a.vision if a.vision is not None or not unsloth_images_default(model, family, ram, gpu)
else "cpu")
else "cpu", float((gpu or {}).get("vram_gb") or 0.0))
elif a.vision:
vision = flash_vision_encoder(a.vision, gpu) if a.vision != "no" else "none"
vision = "none" if vision in ("no", "none") else vision
@@ -7268,6 +7328,9 @@ def main() -> int:
# ---- 4. the engine
step(4, "the Strata engine")
llama, eng, meta, lib_dirs, gpu, vision = engine_step(a, gpu, hip, amd, cuda_tk, vision)
reader = None
if hip and vision != "none": # AMD: the engine folder's reader (GPU, else CPU), settled now the engine is known
vision, reader = hip_reader(eng, vision, gpu)
if (ready or family == "gyro") and meta.get("source") != "local" and \
tuple(int(x) for x in str(meta.get("version", "0")).split(".")[:4] if x.isdigit()) < MODELS[model]["engine"]:
# a ready-made engine from before NVFP4 / Gyro cannot read these experts: one compiled from this source can
@@ -7675,6 +7738,9 @@ def main() -> int:
cfg["vision"] = {"exe": str(eng / VEXE), "mmproj": str(mmproj), # the text model, opened vocab-only:
"model": str(ready_paths(model, models_dir)["dense"] if ready else shards[0]),
"gpu": vision == "gpu", "max_tokens": vt}
if reader is not None: # AMD: the HIP reader with the CPU reader behind it, or the CPU reader
from strata_control import vision_reader as VR
cfg["vision"] = VR.vision_section(reader, cfg["vision"])
if vision == "cpu":
cfg["vision"]["threads"] = vision_threads()
elif a.vision_tokens is not None:
+11 -4
View File
@@ -312,7 +312,10 @@ class Catalog:
vram = comp.main_vram_gb
rec = self.recommendation(hw)
out: list[ModelRow] = []
own = self.strata.own_families() # Qwen3.6 is listed for the app; the Terminal's launch list is Flash-Next's
for fam in self.strata.model_table(hw):
if fam["family"] in own:
continue
for size in fam["sizes"]:
f = fit_on(size, hw.get("ram_gb"), vram)
cat, words = verdict(size.get("on_this_pc"))
@@ -410,15 +413,19 @@ class Catalog:
exe = None
if not exe:
return "unknown"
folder = Path(str(exe)).parent
return "present" if any((folder / n).is_file() for n in ("strata-vision", "strata-vision.exe")) else "missing"
from strata_control import vision_reader as VR
found = VR.find(Path(str(exe)).parent, os.name == "nt")
return "present" if (found["cpu"] or found["gpu"]) else "missing"
def picture_default(self, images: bool) -> str:
"""The picture setting nobody chose: off for a model that cannot read pictures or an engine without the reader; else on, with the
reader on the graphics card for the CUDA and HIP builds and on the CPU for the others."""
reader the one rule of strata_control/vision_reader.py gives: the CPU reader under 24 GB of graphics memory, the GPU reader from 24 GB,
on every make of card (owner decision 2026-10-18). Without a usable graphics card there is nothing to read on: the CPU reader."""
from strata_control import vision_reader as VR
if not images or self.reader_state() == "missing":
return "off"
return "gpu" if self.engine_backend() in ("cuda", "hip") else "cpu"
vram = self.computer().main_vram_gb
return VR.default_mode(vram) if vram > 0 else "cpu" # a Mac (unified memory) or no card: nothing to keep free, the CPU reader
# ---- installed models
def installed_configs(self) -> list[dict]:
+5 -1
View File
@@ -838,8 +838,12 @@ class PicturesScreen(Base):
self.paint()
def explain(self) -> Text:
amd = any(str(g.get("vendor")) == "amd" for g in self.ctx.computer.usable_gpus)
on_card = self.ctx.catalog.picture_default(True) == "gpu"
extra = ("a 0.9 GB download (if it is not on this computer yet), and about 1.4 GB of graphics memory kept free for the picture reader "
extra = ("a 0.9 GB download (if it is not on this computer yet). On your AMD card the picture reader runs on the graphics card "
"(about 1 GB of its memory, under a second per picture) and starts on the processor by itself if it cannot."
if amd and on_card and self.reader() != "missing" else
"a 0.9 GB download (if it is not on this computer yet), and about 1.4 GB of graphics memory kept free for the picture reader "
"(text is a few % slower)." if on_card else
"a 0.9 GB download (if it is not on this computer yet). The picture reader runs on the CPU: a few seconds per picture, nothing "
"taken from the graphics card.")
+123
View File
@@ -0,0 +1,123 @@
"""Which picture reader an engine folder gets: the GPU one (`strata-vision-hip`, AMD) or the CPU one (`strata-vision`).
An AMD engine package carries two programs for pictures. `strata-vision` is the CPU reader: it needs nothing from the GPU
runtime, so it starts on every PC. `strata-vision-hip` is the same reader on the card (llama.cpp's HIP backend), a
fraction of a second per picture instead of seconds. Setup writes the run config with the GPU reader first and the CPU
reader as its fallback (`vision.cpu_exe`); the server (serve/server.py, Vision) starts the CPU reader by itself, with one
log line, when the GPU reader does not start: the HIP runtime does not load, the card is not one the build has code for
(the reader says so before it runs a kernel), or the card is busy. Nothing is asked of the user.
`choose` is the whole decision; setup.py calls it, and tools/test_vision_reader.py checks every branch.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
CPU_READER = "strata-vision"
GPU_READER = "strata-vision-hip"
#: Owner decision 2026-10-18: pictures are ON by default, and the reader runs on the CPU for a card under 24 GB (the GPU reader keeps about
#: 1-1.4 GB of VRAM that the experts would use: a few % of decode, more on some PCs, #2406) and on the GPU for 24 GB and above - on every make
#: of card. A person can switch it per model (--vision gpu / --vision cpu, the Pictures step); what was asked for is kept.
GPU_MIN_VRAM_GB = 23.5
def default_mode(vram_gb: float | None) -> str:
""""cpu" or "gpu": the reader an unspecific "pictures on" gets on a card with this much memory (unknown memory: "gpu")."""
vram = float(vram_gb or 0.0)
return "cpu" if 0 < vram < GPU_MIN_VRAM_GB else "gpu"
def wanted(asked: str | None, vram_gb: float | None) -> str:
"""What is wanted for `asked` (None, "yes", "on", "gpu", "cpu", "no"/"none"/"off"): "gpu", "cpu" or "none". Nothing asked and "yes" mean
the default for this card; "gpu" and "cpu" are kept as asked."""
a = (asked or "").lower()
if a in ("no", "none", "off"):
return "none"
if a in ("gpu", "cpu"):
return a
return default_mode(vram_gb)
GFX = re.compile(r"gfx[0-9a-f]+", re.IGNORECASE)
def reader_name(base: str, windows: bool) -> str:
return base + (".exe" if windows else "")
def find(folder: Path | str, windows: bool) -> dict:
"""The readers in an engine folder: {"cpu": Path | None, "gpu": Path | None}."""
folder = Path(folder)
found = {}
for key, base in (("cpu", CPU_READER), ("gpu", GPU_READER)):
path = folder / reader_name(base, windows)
found[key] = path if path.is_file() else None
return found
def gfx_of(text: object) -> str:
""""gfx1201" out of a gcnArchName ("gfx1201:sramecc-:xnack-"), a device line or a plain arch; "" when there is none."""
m = GFX.search(str(text or ""))
return m.group(0).lower() if m else ""
@dataclass
class Reader:
mode: str # "gpu" | "cpu" | "none"
exe: Path | None = None # the program the run config starts
cpu_exe: Path | None = None # the CPU reader to fall back on when a GPU reader does not start (mode "gpu" only)
note: str = "" # one plain sentence for the setup output when it is not what was asked for
def choose(folder: Path | str, asked: str, *, windows: bool, gpu_arch: str = "", archs: list | None = None) -> Reader:
"""The reader for `asked` ("yes"/"on"/"gpu" = the best the folder has, "cpu", "no"/"none"/"off").
gpu_arch: the card's architecture when setup knows it ("gfx1201"); archs: what the GPU reader was built for
(BUILD.json `vision_archs`, else the package's `archs`). A card the build does not list gets the CPU reader here;
when the card is unknown the GPU reader is tried and falls back by itself (see the module doc)."""
asked = (asked or "").lower()
if asked in ("no", "none", "off", ""):
return Reader("none")
have = find(folder, windows)
if asked == "cpu":
if have["cpu"]:
return Reader("cpu", have["cpu"])
return Reader("none", note="this engine has no picture reader: pictures off")
# yes / on / gpu
gfx = gfx_of(gpu_arch)
built = [gfx_of(a) for a in (archs or []) if gfx_of(a)]
if have["gpu"] is None:
if have["cpu"]:
return Reader("cpu", have["cpu"], note="this engine has no GPU picture reader: pictures are read on the CPU "
"(a few seconds each)" if asked == "gpu" else "")
return Reader("none", note="this engine has no picture reader: pictures off")
if gfx and built and gfx not in built:
if have["cpu"]:
return Reader("cpu", have["cpu"], note=f"the GPU picture reader has no code for your card ({gfx}): pictures "
"are read on the CPU (a few seconds each)")
return Reader("none", note=f"the GPU picture reader has no code for your card ({gfx}): pictures off")
return Reader("gpu", have["gpu"], have["cpu"])
def vision_section(reader: Reader, base: dict) -> dict:
"""The run config's "vision" section for `reader` on top of `base` (mmproj, model, max_tokens ...): exe, gpu, and for the
GPU reader the CPU reader to fall back on."""
out = dict(base)
out["exe"] = str(reader.exe)
out["gpu"] = reader.mode == "gpu"
if reader.mode == "gpu" and reader.cpu_exe:
out["cpu_exe"] = str(reader.cpu_exe)
else:
out.pop("cpu_exe", None)
return out
def cpu_fallback(vision_cfg: dict) -> dict | None:
"""The same section for the CPU reader, when the GPU reader has one to fall back on; None otherwise."""
if not vision_cfg.get("gpu") or not vision_cfg.get("cpu_exe"):
return None
out = {k: v for k, v in vision_cfg.items() if k not in ("cpu_exe", "elastic", "elastic_mib", "elastic_idle_s")}
out["exe"], out["gpu"] = vision_cfg["cpu_exe"], False
return out
+1 -1
View File
@@ -294,7 +294,7 @@ def install(argv) -> None:
S.gpus = lambda *a, **k: []
S.amd_gpus = lambda *a, **k: intel
S.amd_problem = lambda g: None
S.hip_vision = lambda asked: "none" # images are not wired on the SYCL port yet
S.hip_vision = lambda asked, vram_gb=0.0: "none" # images are not wired on the SYCL port yet
S.build_engine_hip = lambda *a, **k: stub
S.hipblaslt_table = lambda *a, **k: None
S.ram_gb = lambda: fake_ram # the experts are in VRAM: setup's RAM rule does not apply
+65
View File
@@ -249,6 +249,71 @@ class InstallerServerTests(unittest.TestCase):
# a resumed install counts what is already on disk
self.assertGreater(self.mcp.combined_progress(dict(step5), job)["download"]["done_gb"], 0)
def test_a_model_in_parts_counts_every_part_and_the_picture_reader(self):
"""QA 2026-10-18 (6): the bar stopped at 99 % while the picture reader downloaded, and a first whole part looked like a finished
model. Every part is counted, the picture reader is a part of its own, and 'done' needs all of them."""
S = self.mcp.Strata(self.root, ROOT).setup_module()
models = self.root / "Strata-data" / "models"
files = self.mcp.expected_files(S, "qwen", "IQ3_S", models, True)
self.assertEqual([f["role"] for f in files], ["model", "model", "vision"])
self.assertEqual(Path(files[2]["path"]).parent, models, "the picture reader sits beside the model folders")
folder = Path(files[0]["path"]).parent
folder.mkdir(parents=True)
job = {"folder": str(folder), "total_gb": 0.004, "files": files}
step5 = {"step": "5/7: downloading Qwen3.8-Flash-Next IQ3_S"}
# the first part is whole, the second not started: the model is NOT downloaded
Path(files[0]["path"]).write_bytes(b"x" * 2_000_000)
one = self.mcp.combined_progress(dict(step5), job)
model = next(x for x in one["parts"] if x["kind"] == "model")
self.assertEqual((model["files_done"], model["files_total"], model["state"]), (1, 2, "downloading"))
self.assertLess(one["download"]["percent"], 99)
# both parts whole, the picture reader under way: still counted, and the bar is not at its end
Path(files[1]["path"]).write_bytes(b"x" * 2_000_000)
Path(files[2]["path"]).with_name(Path(files[2]["path"]).name + ".part").write_bytes(b"x" * 400_000)
two = self.mcp.combined_progress(dict(step5), job)
vision = next(x for x in two["parts"] if x["kind"] == "vision")
self.assertEqual(vision["state"], "downloading")
self.assertEqual(two["download"]["file"], "picture reader")
self.assertGreater(two["download"]["done_gb"], one["download"]["done_gb"])
self.assertGreater(two["download"]["total_gb"], 0.004, "the picture reader is part of the total")
self.assertLessEqual(two["download"]["percent"], 99)
# all whole
Path(files[2]["path"]).with_name(Path(files[2]["path"]).name + ".part").unlink()
Path(files[2]["path"]).write_bytes(b"x" * 907_543_008)
three = self.mcp.combined_progress(dict(step5), job)
self.assertTrue(all(x["state"] == "done" for x in three["parts"]))
self.assertEqual(three["download"]["done_gb"], three["download"]["total_gb"])
# not a download once setup prepares the files
self.assertNotIn("download", self.mcp.combined_progress({"step": "6/7: preparing the model for Strata", "download": {"x": 1}}, job))
def test_a_pack_with_many_files_reports_the_whole_pack(self):
S = self.mcp.Strata(self.root, ROOT).setup_module()
files = self.mcp.expected_files(S, "nvfp4", "NVFP4", self.root / "models", False)
self.assertGreater(len(files), 5)
self.assertTrue(all(f["bytes"] for f in files), "a ready-made pack pins every file's size")
big = next(f for f in files if f["path"].endswith("experts.bin"))
Path(big["path"]).parent.mkdir(parents=True)
Path(big["path"] + ".part").write_bytes(b"x" * 1000)
prog = self.mcp.combined_progress({"step": "5/7: downloading"}, {"files": files, "total_gb": 128.0})
model = prog["parts"][0]
self.assertEqual((model["kind"], model["state"], model["files_done"], model["files_total"]), ("model", "downloading", 0, len(files)))
self.assertAlmostEqual(model["total_gb"], sum(f["bytes"] for f in files) / 1e9, places=2)
def test_a_config_with_only_some_parts_is_not_ready(self):
"""The config names the first part only; 'ready' used to mean that part exists."""
data = self.root / "models" / "x"
data.mkdir(parents=True)
first = data / "Model-00001-of-00003.gguf"
first.write_bytes(b"x")
self.assertEqual(len(self.mcp.shard_gaps([str(first)])), 2)
(data / "Model-00002-of-00003.gguf").write_bytes(b"x")
(data / "Model-00003-of-00003.gguf.part").write_bytes(b"x")
self.assertEqual([Path(g).name for g in self.mcp.shard_gaps([str(first)])], ["Model-00003-of-00003.gguf"])
(data / "Model-00003-of-00003.gguf.part").unlink()
(data / "Model-00003-of-00003.gguf").write_bytes(b"x")
self.assertEqual(self.mcp.shard_gaps([str(first)]), [])
self.assertEqual(self.mcp.shard_gaps([str(data / "single.gguf")]), [])
@unittest.skipUnless(os.name == "nt" or sys.platform.startswith("linux"), "a PC with a GPU probe")
def test_a_real_install_reaches_the_model_download_with_nothing_else_acquired(self):
"""setup.py in the app's mode, as the install job runs it: steps 1-4 pass (PC check, packages bundled, engine component
+9 -5
View File
@@ -295,12 +295,14 @@ class SetupArgs(unittest.TestCase):
s = S.LaunchSettings(family="qwen", model="IQ3_XXS", context=32768, kv="q4_0", **kw)
return Runner(s, catalog=cat).setup_args()
def test_pictures_default_on_the_card_for_nvidia_and_amd_and_on_the_cpu_for_the_rest(self):
def test_pictures_default_on_the_cpu_reader_under_24_gb_and_the_gpu_reader_from_24_gb_on_every_make(self):
from tests.terminal.launch_support import HW_12 as NV
amd = {**NV, "gpus": [{"index": 0, "name": "AMD Radeon RX 9070 XT", "vram_gb": 16.0, "usable": True, "vendor": "amd"}]}
intel = {**NV, "gpus": [{"index": 0, "name": "Intel Arc B580", "vram_gb": 12.0, "usable": True, "vendor": "intel"}]}
card = lambda name, gb, vendor: {**NV, "gpus": [{"index": 0, "name": name, "vram_gb": gb, "usable": True, "vendor": vendor}]} # noqa: E731
amd = card("AMD Radeon RX 9070 XT", 16.0, "amd")
intel = card("Intel Arc B580", 12.0, "intel")
with Home():
for hw, want in ((NV, "gpu"), (amd, "gpu"), (intel, "cpu")):
for hw, want in ((NV, "cpu"), (amd, "cpu"), (intel, "cpu"), (card("NVIDIA GeForce RTX 4090", 24.0, "nvidia"), "gpu"),
(card("AMD Radeon AI PRO R9700", 32.0, "amd"), "gpu")):
cat = catalog(hw)
r = Runner(S.LaunchSettings(family="qwen", model="IQ3_XXS", context=32768), catalog=cat)
self.assertEqual(r.setup_args()[r.setup_args().index("--vision") + 1], want, hw["gpus"][0]["vendor"])
@@ -329,7 +331,9 @@ class SetupArgs(unittest.TestCase):
with Home():
cat = catalog(HW_12)
s, _ = apply_defaults(S.LaunchSettings(family="qwen", model="IQ3_XXS"), cat)
self.assertEqual(s.vision, "gpu")
self.assertEqual(s.vision, "cpu") # a 12 GB card: pictures ON, read on the CPU
s, _ = apply_defaults(S.LaunchSettings(family="qwen", model="IQ3_XXS", vision="gpu"), cat)
self.assertEqual(s.vision, "gpu") # what was asked for is kept
s, _ = apply_defaults(S.LaunchSettings(family="qwen", model="IQ3_XXS", vision="off"), cat)
self.assertEqual(s.vision, "off")
+16 -2
View File
@@ -214,9 +214,9 @@ class SetupControlStep(unittest.TestCase):
except SystemExit as e:
return e, out.getvalue()
def test_wrong_family_stops_before_the_download_when_nobody_said_go_on(self):
def test_wrong_family_stops_before_the_download_for_launch(self):
exc = ec.EngineMissing("nothing matched", Path("/c"), notice=WRONG_FAMILY)
res, out = self.step(exc, {})
res, out = self.step(exc, {"STRATA_NEED_ENGINE": "1"})
self.assertIsInstance(res, SystemExit)
self.assertIn("Ask for the RX 9000 build.", out)
self.assertIn("nothing was downloaded", out)
@@ -232,6 +232,20 @@ class SetupControlStep(unittest.TestCase):
res, out = self.step(ec.EngineMissing("the component folder holds no engine package", Path("/c")), {"STRATA_NEED_ENGINE": "1"})
self.assertIsInstance(res, SystemExit)
def test_the_app_with_a_wrong_family_engine_warns_and_still_downloads(self):
"""Owner decision 2026-10-18: the app downloads the model anyway and warns (its card says so)."""
env = {k: v for k, v in os.environ.items() if k != "STRATA_NEED_ENGINE"}
exc = ec.EngineMissing("nothing matched", Path("/c"), notice=WRONG_FAMILY)
with mock.patch.dict(os.environ, env, clear=True):
res, out = self.step(exc, {})
self.assertNotIsInstance(res, SystemExit)
self.assertIn("Ask for the RX 9000 build.", out)
self.assertIn("The model is downloaded and prepared now", out)
def test_no_engine_stops_before_the_download_for_launch(self):
res, out = self.step(ec.EngineMissing("the component folder holds no engine package", Path("/c")), {"STRATA_NEED_ENGINE": "1"})
self.assertIsInstance(res, SystemExit)
def test_the_app_without_an_engine_keeps_its_order(self):
env = {k: v for k, v in os.environ.items() if k != "STRATA_NEED_ENGINE"}
with mock.patch.dict(os.environ, env, clear=True):
+2 -2
View File
@@ -424,10 +424,10 @@ class PicturesParallelTuning(Base):
self.assertIn("No pictures", t)
self.assertIn("Pictures are on by default", flat(app))
self.assertIn("0.9 GB download", t)
self.assertIn("graphics memory kept free", flat(app)) # the NVIDIA reader runs on the card
self.assertIn("runs on the CPU", flat(app)) # a 12 GB card: the CPU reader (the GPU one from 24 GB)
self.assertEqual(app.screen.menu().index, 1) # "Yes" is preselected
await press(pilot, "enter")
self.assertEqual(ctx.settings.vision, "gpu")
self.assertEqual(ctx.settings.vision, "cpu")
self.assertIn("How many requests at the same time?", text_of(app))
self.run_app(case)
+32 -3
View File
@@ -16,6 +16,9 @@ rem gfx1031 = RX 6700 XT / 6800M, #915 #1078; drop it fro
rem STRATA_ROCM_VERSION 10.2.0a20260930 STRATA_ROCM_INDEX https://nightly.repo.amd.com/rocm/whl-next/
rem ROCM_VENV <repo>\.rocm-win BUILD_DIR <repo>\build-hip-win DIST_DIR <repo>\dist
rem STRATA_GGML_DIR a llama.cpp checkout at the pinned commit (default: CMake fetches it)
rem STRATA_ROCM_KEEP 1 = use the ROCM_VENV as it is (no pip install, even when its archs differ from STRATA_HIP_ARCHS)
rem STRATA_VISION_HIP_ARCHS the GPU reader's archs (default: STRATA_HIP_ARCHS; about 70 MB of the zip per arch)
rem STRATA_NO_VISION 1 = skip the two picture readers (strata-vision.exe for the CPU, strata-vision-hip.exe for the GPU)
setlocal EnableDelayedExpansion
for %%I in ("%~dp0..\..") do set "SRC=%%~fI"
if not defined STRATA_HIP_ARCHS set "STRATA_HIP_ARCHS=gfx1100;gfx1101;gfx1102;gfx1200;gfx1201;gfx1030;gfx1031;gfx1151"
@@ -39,7 +42,7 @@ set "STAMP=%ROCM_VENV%\strata-rocm.txt"
set "WANT=%STRATA_ROCM_VERSION% %EXTRAS%"
set "HAVE="
if exist "%STAMP%" set /p HAVE=<"%STAMP%"
if not "!HAVE!"=="!WANT!" (
if not "!HAVE!"=="!WANT!" if not "%STRATA_ROCM_KEEP%"=="1" (
echo Installing ROCm %STRATA_ROCM_VERSION% [%EXTRAS%] into %ROCM_VENV% ...
"%ROCM_VENV%\Scripts\python.exe" -m pip install --disable-pip-version-check --index-url "%STRATA_ROCM_INDEX%" "rocm[%EXTRAS%]==%STRATA_ROCM_VERSION%" || exit /b 1
"%ROCM_VENV%\Scripts\rocm-sdk.exe" init || exit /b 1
@@ -79,9 +82,35 @@ if "%TESTS%"=="ON" (
cmake --build "%BUILD_DIR%" --target strata strata-device || exit /b 1
)
rem ---- 4. the zip: the two programs, the ROCm DLLs they load (+ rocBLAS/hipBLASLt kernels for these archs), licenses
rem ---- 3b. the picture readers (tools/vision, the same llama.cpp as the engine): strata-vision.exe on the CPU (MSVC, portable: the
rem fallback that starts on any PC) and strata-vision-hip.exe on the GPU (ROCm's clang, ggml-hip, the archs above)
set "VISION_ARGS="
if not defined STRATA_VISION_HIP_ARCHS set "STRATA_VISION_HIP_ARCHS=%STRATA_HIP_ARCHS%"
if not "%STRATA_NO_VISION%"=="1" (
set "LLAMA=%STRATA_GGML_DIR%"
if not defined LLAMA set "LLAMA=%BUILD_DIR%\_deps\strata_llamacpp-src"
set "LLAMA=!LLAMA:\=/!"
if not exist "!LLAMA!/tools/mtmd" (echo llama.cpp with tools/mtmd not found in !LLAMA! - set STRATA_GGML_DIR & exit /b 1)
if not exist "%SRC%\build-vision-cpu-win\build.ninja" (
cmake -G Ninja -S "%SRC%\tools\vision" -B "%SRC%\build-vision-cpu-win" -DCMAKE_BUILD_TYPE=Release -DLLAMA_DIR="!LLAMA!" ^
-DSTRATA_VISION_CUDA=OFF -DSTRATA_PORTABLE=ON -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl || exit /b 1
)
cmake --build "%SRC%\build-vision-cpu-win" --target strata-vision || exit /b 1
if not exist "%SRC%\build-vision-hip-win\build.ninja" (
cmake -G Ninja -S "%SRC%\tools\vision" -B "%SRC%\build-vision-hip-win" -DCMAKE_BUILD_TYPE=Release -DLLAMA_DIR="!LLAMA!" ^
-DSTRATA_VISION_HIP=ON "-DSTRATA_VISION_HIP_ARCHS=%STRATA_VISION_HIP_ARCHS%" -DSTRATA_PORTABLE=ON ^
"-DCMAKE_CXX_FLAGS=--rocm-path=%ROCM_F% --rocm-device-lib-path=%BITCODE%" ^
"-DCMAKE_C_COMPILER=%ROCM_F%/lib/llvm/bin/clang.exe" "-DCMAKE_CXX_COMPILER=%ROCM_F%/lib/llvm/bin/clang++.exe" ^
"-DCMAKE_HIP_COMPILER=%ROCM_F%/lib/llvm/bin/clang++.exe" "-DCMAKE_HIP_COMPILER_ROCM_ROOT=%ROCM_F%" ^
"-DCMAKE_PREFIX_PATH=%ROCM_F%" "-DCMAKE_HIP_FLAGS=--rocm-path=%ROCM_F% --rocm-device-lib-path=%BITCODE%" || exit /b 1
)
cmake --build "%SRC%\build-vision-hip-win" --target strata-vision || exit /b 1
set "VISION_ARGS=--vision-cpu %SRC%\build-vision-cpu-win\bin\strata-vision.exe --vision-hip %SRC%\build-vision-hip-win\bin\strata-vision-hip.exe"
)
rem ---- 4. the zip: the programs, the ROCm DLLs they load (+ rocBLAS/hipBLASLt kernels for these archs), licenses
"%ROCM_VENV%\Scripts\python.exe" "%SRC%\tools\hip\package_windows.py" --build "%BUILD_DIR%" --rocm "%ROCM%" ^
--archs "%STRATA_HIP_ARCHS%" --rocm-version "%STRATA_ROCM_VERSION%" --out "%DIST_DIR%" || exit /b 1
--archs "%STRATA_HIP_ARCHS%" --rocm-version "%STRATA_ROCM_VERSION%" --out "%DIST_DIR%" !VISION_ARGS! || exit /b 1
echo.
echo Done: %DIST_DIR%\strata-windows-x64-hip.zip
endlocal
+13 -1
View File
@@ -7,6 +7,9 @@
tools/hip/build_windows.bat runs it after the build. The zip holds:
strata.exe, strata-device.exe, BUILD.json (backend "hip", the archs, the ROCm and hipBLASLt versions)
strata-vision.exe, strata-vision-hip.exe the picture readers when --vision-cpu / --vision-hip are given: the CPU reader
(the fallback; starts on any PC) and the GPU reader (tools/vision,
STRATA_VISION_HIP; imports the same ROCm DLLs as strata.exe)
amdhip64_7.dll, amd_comgr.dll + what they import the HIP runtime beside the exes too (#468 #461: before System32)
rocm/bin/*.dll the ROCm DLLs the two programs load (their import tables, followed
through the ROCm DLLs, + amd_comgr.dll, which the HIP runtime loads
@@ -73,6 +76,8 @@ def main() -> int:
ap.add_argument("--archs", required=True, help="the archs the engine was compiled for, ';'-separated")
ap.add_argument("--rocm-version", required=True)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--vision-cpu", type=Path, help="strata-vision.exe built for the CPU (tools/vision, STRATA_PORTABLE=ON)")
ap.add_argument("--vision-hip", type=Path, help="strata-vision-hip.exe built for the same archs (STRATA_VISION_HIP=ON)")
a = ap.parse_args()
archs = [x for x in re.split(r"[;, ]+", a.archs) if x]
rbin = a.rocm / "bin"
@@ -83,10 +88,13 @@ def main() -> int:
for p in PROGRAMS:
shutil.copy2(a.build / p, stage / p)
readers = [(src, src.name) for src in (a.vision_cpu, a.vision_hip) if src is not None]
for src, name in readers:
shutil.copy2(src, stage / name)
# the ROCm DLLs: the programs' imports, followed through the ROCm DLLs themselves
rocm_dlls = {p.name.lower(): p for p in rbin.glob("*.dll")}
need, todo, crt = [], [stage / p for p in PROGRAMS] + [rbin / d for d in DYNAMIC], set()
need, todo, crt = [], [stage / p for p in PROGRAMS] + [stage / n for _, n in readers] + [rbin / d for d in DYNAMIC], set()
while todo:
f = todo.pop()
for name in imports(objdump, f):
@@ -157,6 +165,10 @@ def main() -> int:
meta = {"source": "prebuilt", "backend": "hip", "platform": "windows-x64", "version": version, "archs": archs,
"rocm": a.rocm_version, "hipblaslt_version": hlv[0] * 100000 + hlv[1] * 100 + hlv[2], "vision": "none",
"lib_dirs": ["rocm/bin"]}
if a.vision_cpu is not None:
meta.update({"vision": "cpu", "vision_cpu": a.vision_cpu.name})
if a.vision_hip is not None:
meta.update({"vision": "gpu", "vision_gpu": a.vision_hip.name, "vision_archs": archs})
(stage / "BUILD.json").write_text(json.dumps(meta, indent=1), encoding="utf-8")
z = a.out / ASSET
+244 -19
View File
@@ -116,6 +116,7 @@ FALLBACK_FAMILIES = {
"~80 GB of RAM to keep every expert in RAM",
"tag": "nvfp4-"},
}
OWN_PATH_ENGINE = (0, 1, 45) # the first engine that runs Qwen3.6-35B-A3B with pictures and parallel requests (setup.py MIN_ENGINE note)
GYRO_ROOM_GB = 4.0 # recommend() suggests a Gyro size only when VRAM + RAM exceed its memory figure by this much
FALLBACK_CONTEXTS = [8192, 32768, 65536, 131072, 204800, 262144, 393216, 524288]
BENCH_PROMPT = ("Write a short story (about 300 words) about a lighthouse keeper who finds a message in a bottle. "
@@ -474,12 +475,37 @@ class Strata:
def tables(self):
s = self.setup_module()
if s is not None:
return s.MODELS, s.FAMILIES, list(s.CONTEXTS), "setup.py"
return s.MODELS, {**s.FAMILIES, **self.own_families()}, list(s.CONTEXTS), "setup.py"
return FALLBACK_MODELS, FALLBACK_FAMILIES, FALLBACK_CONTEXTS, "built-in table (setup.py not importable)"
@staticmethod
def sizes_of(models, family) -> list:
def own_families(self) -> dict:
"""setup.py's OWN_PATH_FAMILIES (Qwen3.6-35B-A3B): families with an engine path of their own. They are not in FAMILIES
(whose entries are Flash-Next files with packs), so the model list used to leave them out and the app's Qwen3.6 page
showed no versions at all. Each is returned as a family entry (title, about, tag, license) of this server's table."""
s = self.setup_module()
out = {}
for f, d in (getattr(s, "OWN_PATH_FAMILIES", None) or {}).items():
out[f] = {"title": d["title"], "about": d.get("about"), "tag": d["tag"], "license": d.get("license"),
"vision": bool(d.get("vision", True)), "own_path": True}
return out
def own_size(self, family, model) -> dict | None:
"""One size of an own-path family as a model-table row for install_plan / model_table (None: not an own-path size).
The download is the GGUF; the picture reader (mmproj, ~0.9 GB) is counted by the plan when pictures are on."""
d = (getattr(self.setup_module(), "OWN_PATH_FAMILIES", None) or {}).get(family)
if not d or model not in d["models"]:
return None
size_gb = d["sha256"][d["file"].format(q=model)][0] / 1e9
return {"about": d["models"][model]["about"], "download_gb": round(size_gb, 1), "ram_gb": d["models"][model]["ram_gb"],
# the routed experts are ~93% of the file (setup.own_path_parallel_fits_all); the dense part and buffers ~1 GB more
"arena_gb": round(size_gb * 0.93, 1), "vision": bool(d.get("vision", True)),
"engine": OWN_PATH_ENGINE, "families": (family,)}
def sizes_of(self, models, family) -> list:
"""The family's sizes as setup lists them: an experimental one last (never the default)."""
own = (getattr(self.setup_module(), "OWN_PATH_FAMILIES", None) or {}).get(family)
if own:
return list(own["models"])
return sorted((m for m in models if family in models[m].get("families", ("qwen", "swift"))),
key=lambda m: bool(models[m].get("experimental")))
@@ -545,7 +571,7 @@ class Strata:
except (OSError, ValueError):
return {}
def describe_config(self, path: Path) -> dict:
def describe_config(self, path: Path, usage: dict | None = None) -> dict:
cfg = self.read_config(path)
a = cfg.get("args") if isinstance(cfg.get("args"), list) else []
@@ -554,8 +580,12 @@ class Strata:
tag = path.stem[len("strata-"):]
missing = [p for p in [cfg.get("exe"), *[x for x in a if isinstance(x, str) and x.endswith(".gguf")]]
if not p or not Path(p).exists()]
gaps = shard_gaps([x for x in a if isinstance(x, str) and x.endswith(".gguf")]) # a model in parts is whole only with every part
missing += gaps
script = self.root / f"run-{tag}.{'bat' if WIN else 'sh'}"
vis = cfg.get("vision")
own_usage = (usage or self.disk_usage())["models"].get(tag) or {"gb": 0.0, "bytes": 0, "shared_bytes": 0}
plan = self.removal_plan(path)
return {"model": tag, "config": path.name, "model_name": cfg.get("model_name"),
"context": int(val("--max-context")) if str(val("--max-context") or "").isdigit() else None,
"kv": val("--kv"), "images": ("gpu" if vis.get("gpu") else "cpu") if isinstance(vis, dict) else "off",
@@ -563,8 +593,11 @@ class Strata:
"gpu": cfg.get("gpu"), "api_key_set": bool(cfg.get("api_key")),
"low_ram": "--resident-experts" in a or "--mmap-experts" in a,
"start_script": script.name if script.exists() else None,
"ready": not missing and bool(cfg), "missing_files": missing[:3],
"disk_gb": self.removal_plan(path)["gb"], "disk_bytes": self.removal_plan(path)["bytes"],
"ready": not missing and bool(cfg), "missing_files": missing[:3], "parts_missing": len(gaps),
"disk_gb": own_usage["gb"], "disk_bytes": own_usage["bytes"],
"shared_bytes": own_usage["shared_bytes"],
# what Delete would remove (files in Strata's own folders that no other model uses); the person's own GGUF folders stay
"removable_gb": plan["gb"], "kept_files": len(plan["keep"]),
"last_used": time.strftime("%Y-%m-%d %H:%M", time.localtime(path.stat().st_mtime))}
# ---- what a model owns on disk (Delete, and the per-model disk usage in Storage)
@@ -575,9 +608,19 @@ class Strata:
"""(the GGUF files, the folders) a run config names: --native/--ple-gguf/... and --pack/--mtp, plus any other .gguf."""
a = [x for x in (cfg.get("args") or []) if isinstance(x, str)]
files = [Path(a[i + 1]) for i, x in enumerate(a[:-1]) if x in self.FILE_FLAGS]
files += [Path(x) for x in a if x.lower().endswith(".gguf") and Path(x) not in files]
files += [Path(x) for x in a if x.lower().endswith(".gguf")]
dirs = [Path(a[i + 1]) for i, x in enumerate(a[:-1]) if x in self.DIR_FLAGS]
return files, dirs
seen, once = set(), []
for f in files: # one file named by several flags is still one file
if norm(f) not in seen:
seen.add(norm(f))
once.append(f)
seen_d, once_d = set(), []
for d in dirs:
if norm(d) not in seen_d:
seen_d.add(norm(d))
once_d.append(d)
return once, once_d
@staticmethod
def tree_bytes(path: Path) -> int:
@@ -595,6 +638,61 @@ class Strata:
pass
return total
@staticmethod
def file_key(path: Path):
"""What makes two names the same file on disk: device + inode (hard links), else the resolved path."""
try:
st = os.stat(path)
if st.st_ino:
return ("ino", st.st_dev, st.st_ino), st.st_size
return ("path", str(norm(path))), st.st_size
except OSError:
return None, 0
def used_files(self, cfg: dict) -> dict:
"""Every file a model really uses, from its run config: all parts of a multi-part GGUF (the config names the first), the
picture reader, the packs and the draft layer folders. {file identity: bytes}, wherever they live (the Strata folders or the
person's own)."""
files, dirs = self.model_paths(cfg)
paths = list(files)
vis = cfg.get("vision")
if isinstance(vis, dict) and isinstance(vis.get("mmproj"), str):
paths.append(Path(vis["mmproj"]))
for f in list(paths): # the other parts of a multi-part file
m = SHARD_NAME.match(str(f))
if m:
paths += [Path(f"{m.group(1)}-{i:05d}-of-{m.group(3)}.gguf") for i in range(1, int(m.group(3)) + 1)]
for d in dirs:
for base, _sub, names in os.walk(d):
paths += [Path(base) / n for n in names]
out = {}
for p in paths:
if p.is_file():
key, size = self.file_key(p)
if key is not None:
out[key] = size
return out
def disk_usage(self) -> dict:
"""What the installed models take on disk, from the real files: each model's own files, the files shared between models
(the picture reader, the draft layer) counted ONCE, and the total. A model's own figure leaves out the shared files and
every other model's; rows plus 'shared' add up to the total."""
per = {c.stem[len("strata-"):]: self.used_files(self.read_config(c)) for c in self.configs()}
owners: dict = {}
for files in per.values():
for k in files:
owners[k] = owners.get(k, 0) + 1
models, shared = {}, {}
for tag, files in per.items():
own = sum(b for k, b in files.items() if owners[k] == 1)
models[tag] = {"bytes": own, "gb": round(own / 1e9, 2), "files": len(files),
"shared_bytes": sum(b for k, b in files.items() if owners[k] > 1)}
shared.update({k: b for k, b in files.items() if owners[k] > 1})
shared_bytes = sum(shared.values())
total = sum(m["bytes"] for m in models.values()) + shared_bytes
return {"models": models, "shared_bytes": shared_bytes, "shared_gb": round(shared_bytes / 1e9, 2),
"total_bytes": total, "total_gb": round(total / 1e9, 2)}
def removal_plan(self, config: Path) -> dict:
"""What deleting this model removes and what it keeps. A file or folder another installed model's config still names
(the shared MTP draft, a shared PLE shard) is kept; so is anything outside Strata's own folders."""
@@ -682,9 +780,11 @@ class Strata:
entry["downloading"] = bool(list(c.glob("*.part")))
items.setdefault(sub, []).append(entry)
mtp = data / "mtp" / "rt" / "experts.bin"
usage = self.disk_usage()
return {"strata_folder": str(self.root), "data_folder": str(data), "data_folder_exists": data.is_dir(),
"python_env": self.venv_python().exists(), "engine": self.engine_info(),
"models": [self.describe_config(c) for c in self.configs()],
"models": [self.describe_config(c, usage) for c in self.configs()],
"usage": usage,
"downloads": items.get("models", []), "prepared": items.get("packs", []),
"draft_layer": mtp.exists()}
@@ -964,14 +1064,17 @@ class Strata:
for f, fd in families.items():
sizes = []
for m in self.sizes_of(models, f):
d = models[m]
own = self.own_size(f, m)
d = own or models[m]
tag = (fd.get("tag", "") + m).lower()
verdict = None
if ram is not None:
have_gb = ram + vram if d.get("mem_total") else ram # Gyro: VRAM and RAM together
verdict = "fits" if have_gb >= d["ram_gb"] else "tight" if have_gb >= d["ram_gb"] - 8 else "does not fit"
try:
if d.get("budget") and S and hasattr(S, "unsloth_budget") and not S.unsloth_budget(m, ram):
if own: # its own engine path: no budget or low-RAM mode of Flash-Next's to judge by
pass
elif d.get("budget") and S and hasattr(S, "unsloth_budget") and not S.unsloth_budget(m, ram):
verdict = ("experimental: " if d.get("experimental") else "") + "fits (all of its experts in RAM)"
elif d.get("budget"):
verdict = (("experimental: " if d.get("experimental") else "")
@@ -986,6 +1089,9 @@ class Strata:
sizes.append({"model": m, "about": d["about"], "download_gb": d["download_gb"],
"experimental": bool(d.get("experimental")),
"images": d.get("vision", fd.get("vision", True)) is not False,
# which picture reader "pictures on" gets on this PC (strata_control/vision_reader.py, the one rule: the CPU reader
# under 24 GB of graphics memory, the GPU reader from 24 GB; the person can switch it per model)
"images_reader": self.images_reader(vram),
"ram_needed_gb": d["ram_gb"], "experts_gb": d.get("arena_gb"),
# RAM + VRAM the weights need together, WITHOUT the n-gram table (that stays on the SSD): Gyro states it
# (mem_total), for the rest the experts plus ~1 GB for the dense part (Swift IQ2_XS: 35.5 + 1 = 36.5)
@@ -997,13 +1103,21 @@ class Strata:
"on_this_pc": verdict, "installed": tag in have, "id": tag,
# which graphics cards to use for it (strata_control/gpu_fit.py, the one rule app / Terminal / setup share):
# only with two or more cards that count; "recommended" names the pre-selected option
"gpu_options": self.gpu_options(d, ram, hw)})
"gpu_options": None if own else self.gpu_options(d, ram, hw)}) # Qwen3.6 runs on one card so far
out.append({"family": f, "title": fd["title"], "about": fd.get("about"),
"experimental": bool(fd.get("experimental")),
"images": any(x["images"] for x in sizes) if sizes else fd.get("vision", True) is not False,
"sizes": sizes})
return out
@staticmethod
def images_reader(vram) -> str:
try:
from strata_control import vision_reader
return vision_reader.default_mode(vram) if vram else "cpu"
except Exception: # noqa: BLE001
return "cpu"
@staticmethod
def gpu_options(d: dict, ram, hw) -> dict | None:
try:
@@ -1119,12 +1233,112 @@ class Strata:
return out
SHARD_NAME = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$")
DEFAULT_MMPROJ_BYTES = 907_543_008 # the Flash-Next picture reader (mmproj BF16), when a family does not pin its size
def shard_gaps(ggufs) -> list:
"""The parts of a multi-part GGUF (name-00001-of-00004.gguf) that are not there whole: a missing part, or one still being
downloaded (<part>.part). A config that names only the first part used to look finished with one part of four on disk."""
gaps = []
for g in ggufs:
m = SHARD_NAME.match(str(g))
if not m:
continue
for i in range(1, int(m.group(3)) + 1):
sib = Path(f"{m.group(1)}-{i:05d}-of-{m.group(3)}.gguf")
if not sib.is_file() or sib.with_name(sib.name + ".part").exists():
gaps.append(str(sib))
return gaps
def expected_files(S, family: str, model: str, models_dir: Path, vision: bool) -> list:
"""The files setup fetches for one install, with the sizes it can know before the download starts:
[{path, bytes|None, role}] where role is "model" (the shards or the pack's files) or "vision" (the picture reader).
Empty when the setup module cannot say (the caller then falls back to the folder scan)."""
out = []
try:
own = (getattr(S, "OWN_PATH_FAMILIES", None) or {}).get(family)
if own: # Qwen3.6: the GGUF and the picture reader in the model's folder
folder = Path(models_dir) / (own["tag"] + model)
name = own["file"].format(q=model)
out.append({"path": str(folder / name), "bytes": own["sha256"][name][0], "role": "model"})
if vision:
out.append({"path": str(folder / own["mmproj"]), "bytes": own["sha256"][own["mmproj"]][0], "role": "vision"})
return out
fam, d = S.FAMILIES[family], S.MODELS[model]
if d.get("repo"): # NVFP4: a ready-made pack, many files with pinned sizes
for f in S.ready_needed(model, Path(models_dir)):
out.append({"path": str(S.ready_where(Path(models_dir), f)), "bytes": f[4], "role": "model"})
else:
folder = Path(models_dir) / (fam.get("tag", "") + model)
for i in range(1, S.model_shards(fam, model) + 1):
out.append({"path": str(folder / S.model_file(fam, model, i)), "bytes": None, "role": "model"})
if vision: # beside the model folders, not inside them (setup step 5)
size = (fam.get("mmproj_sha256") or (None,))[0] or DEFAULT_MMPROJ_BYTES
out.append({"path": str(Path(models_dir) / fam.get("mmproj", S.MMPROJ)), "bytes": size, "role": "vision"})
except Exception: # noqa: BLE001 - progress is a convenience: never break the install
return []
return out
def files_progress(prog: dict, job: dict, files: list) -> dict:
"""One honest download figure for a model in parts: every part counted (finished, partial, waiting), the picture reader as a part
of its own, and a part list the screen can name ("part 2 of 4"). The finished state needs EVERY part: a first shard that is whole
does not make the model downloaded."""
log = prog.get("download") or {}
cur_gb = float(log.get("total_gb") or 0)
rows = []
for f in files:
p = Path(f["path"])
part = p.with_name(p.name + ".part")
try:
if part.is_file():
state, have = "downloading", part.stat().st_size
elif p.is_file():
state, have = "done", p.stat().st_size
else:
state, have = "waiting", 0
except OSError:
state, have = "waiting", 0
total = f.get("bytes") or (have if state == "done" else 0)
if state == "downloading" and not total and cur_gb and p.name in str(log.get("file", "")): # the log line names this file
total = int(cur_gb * 1e9)
rows.append({"role": f.get("role", "model"), "state": state, "have": have, "total": max(total, have)})
plan_total = float(job.get("total_gb") or 0) * 1e9
parts = []
for role, name in (("model", "Model files"), ("vision", "Picture reader")):
mine = [r for r in rows if r["role"] == role]
if not mine:
continue
have = sum(r["have"] for r in mine)
known = sum(r["total"] for r in mine)
total = known if all(r["total"] for r in mine) else max(plan_total if role == "model" else 0, known, have)
k = sum(1 for r in mine if r["state"] == "done")
state = "done" if k == len(mine) else "downloading" if any(r["state"] == "downloading" for r in mine) or k else "waiting"
parts.append({"kind": role, "name": name, "state": state, "done_gb": round(min(have, total) / 1e9, 3),
"total_gb": round(total / 1e9, 3), "files_done": k, "files_total": len(mine)})
total_gb = sum(x["total_gb"] for x in parts)
done_gb = min(sum(x["done_gb"] for x in parts), total_gb)
active = next((x for x in parts if x["state"] != "done"), parts[-1] if parts else None)
if active and active["state"] == "waiting" and any(x["state"] == "done" for x in parts):
active = dict(active, state="downloading")
prog["download"] = {"file": "picture reader" if active and active["kind"] == "vision" else "model files",
"done_gb": round(done_gb, 3), "total_gb": round(total_gb, 3),
"remaining_gb": round(max(0.0, total_gb - done_gb), 3),
"percent": min(99, int(100 * done_gb / total_gb)) if total_gb else 0}
prog["parts"] = parts
return prog
def combined_progress(prog: dict, job: dict) -> dict:
"""One total for the whole model: the bytes of all its files (finished, partial and the one in flight, read from the
model's folder) over the download size, instead of each file's own 0-100% (a two-file model went 70 GB -> 0 -> up
again). Only while the download step runs; later steps are not a download."""
folder, total = job.get("folder"), job.get("total_gb")
step = str(prog.get("step") or "")
if job.get("files") and step.startswith("5/"): # the install knows its files: count each of them
return files_progress(prog, job, job["files"])
if not folder or not total or not step.startswith("5/"):
prog.pop("download", None)
return prog
@@ -1589,9 +1803,12 @@ class Tools:
elif family is None:
fams = [f for f in families if model in s.sizes_of(models, f)]
family = "qwen" if "qwen" in fams else fams[0]
if family not in families:
raise ToolError(f"unknown family {family}; the families: {', '.join(families)}")
sizes = s.sizes_of(models, family)
if model not in sizes:
raise ToolError(f"{families[family]['title']} has no {model}; its sizes: {', '.join(sizes)}")
md = s.own_size(family, model) or models[model] # Qwen3.6-35B-A3B: its sizes are not in setup's MODELS
mac = bool(hw.get("mac"))
if mac: # a Mac: no card to pick, no KV choice, no RAM mode: unified memory decides
if rec.get("model") is None and model is not None and s.mac() and hw.get("ram_gb", 0) < s.mac().MIN_STREAM_RAM_GB:
@@ -1602,10 +1819,10 @@ class Tools:
usable = s.engine_gpus(hw)
vram = max((g["vram_gb"] for g in usable), default=12)
context = 32768 if vram < 14 else 65536 if vram < 20 else 131072
if models[model].get("budget") and (not hasattr(s.setup_module(), "unsloth_budget") or
if md.get("budget") and (not hasattr(s.setup_module(), "unsloth_budget") or
s.setup_module().unsloth_budget(model, hw.get("ram_gb") or 0.0)):
context = 8192 if vram < 14 else 32768
if vision in ("yes", "gpu", "cpu") and models[model].get("vision", families[family].get("vision")) is False:
if vision in ("yes", "gpu", "cpu") and md.get("vision", families[family].get("vision")) is False:
raise ToolError(f"images are not available with {families[family]['title']} {model} yet: use vision=no")
if vision is not None and backend == "hip" and vision != "no":
# setup.py's hip_vision rules (#990): the AMD backend has no GPU image encoder, but --vision cpu reads
@@ -1621,7 +1838,7 @@ class Tools:
tag = (families[family].get("tag", "") + model)
have_dir = target / "models" / tag
partly = (have_dir.is_dir() and any(have_dir.glob("*.gguf*"))) or bool(gguf_dir) # a local folder: nothing to download
need = (8 if partly else models[model]["download_gb"] + 8) + (1 if vision in ("yes", "gpu", "cpu") else 0)
need = (8 if partly else md["download_gb"] + 8) + (1 if vision in ("yes", "gpu", "cpu") else 0)
free = s.disk_free(target)
short = None
if free is not None and free < need:
@@ -1631,8 +1848,11 @@ class Tools:
"--vision", vision or "no"]
if family in ("qwen", "coder"):
args += ["--experimental-speed-projection", "off"]
own = bool(s.own_families().get(family))
for flag, v in (("--kv", kv), ("--gpu", gpu), ("--gpus", gpus), ("--low-ram", low_ram), ("--port", port),
("--data-dir", str(target) if data_dir else None)):
if own and flag in ("--kv", "--low-ram"): # Flash-Next settings: Qwen3.6's engine path does not use them (setup would warn)
continue
if v is not None:
args += [flag, str(v)]
if gguf_dir:
@@ -1645,11 +1865,11 @@ class Tools:
fit = None
ram = hw.get("ram_gb")
if ram is not None:
need_ram = models[model]["ram_gb"]
need_ram = md["ram_gb"]
fit = "fits" if ram >= need_ram else f"needs ~{need_ram} GB of RAM, this PC has {ram:.0f} GB"
installed = (s.root / f"strata-{tag.lower()}.json").exists()
plan = {"title": f"{families[family]['title']} {model}", "family": family, "model": model,
"context": context, "images": vision or "no", "download_gb": models[model]["download_gb"],
"context": context, "images": vision or "no", "download_gb": md["download_gb"],
"partly_downloaded": partly, "ram": fit, "data_folder": str(target), "model_folder": str(have_dir),
"disk_free_gb": free,
"disk_needed_gb": round(need), "disk_short": short, "already_installed": installed,
@@ -1661,7 +1881,7 @@ class Tools:
"the options (finished steps are skipped)")
if families[family].get("license"):
plan["license"] = families[family]["license"]
if families[family].get("experimental") or models[model].get("experimental"):
if families[family].get("experimental") or md.get("experimental"):
plan["experimental"] = True
return plan, args
@@ -1687,8 +1907,13 @@ class Tools:
ident = proc_identity(p.pid)
if ident is None and p.poll() and s.state("install-result").get("job") != job_id:
raise ToolError("setup did not start: " + " | ".join(read_tail(log_path, 5)))
folder = plan.get("model_folder")
files = []
if s.setup_module() is not None and folder: # every file of this install, for a progress that counts all the parts
files = expected_files(s.setup_module(), plan["family"], plan["model"], Path(folder).parent,
plan.get("images") in ("yes", "gpu", "cpu"))
s.save_state("install", {"job": job_id, "pid": p.pid, "ident": ident, "log": str(log_path),
"model": plan["model"], "family": plan["family"], "folder": plan.get("model_folder"),
"model": plan["model"], "family": plan["family"], "folder": folder, "files": files,
"total_gb": plan.get("download_gb"),
"started": time.strftime("%Y-%m-%d %H:%M:%S"), "args": args})
return {"summary": f"Install of {plan['title']} started in the background (about 20-90 minutes, mostly the "
+38 -7
View File
@@ -429,13 +429,44 @@ class CalibrationKey(unittest.TestCase):
self.assertIsNone(setup.setup_calibration(cfg, hip=True)) # Windows HIP: defaults for now
class WindowsHipVision(unittest.TestCase):
def test_no_cpu_encoder_on_windows(self):
with mock.patch.object(setup, "WIN", True), mock.patch.object(setup, "warn", lambda *a: None):
self.assertEqual(setup.hip_vision("cpu"), "none")
self.assertEqual(setup.hip_vision("yes"), "none")
with mock.patch.object(setup, "WIN", False), mock.patch.object(setup, "warn", lambda *a: None):
self.assertEqual(setup.hip_vision("cpu"), "cpu")
class HipVisionWish(unittest.TestCase):
"""AMD pictures are on by default, on Linux and Windows: the wish is "gpu" (hip_reader() settles it against the readers the
engine folder carries: the HIP one, else the CPU one), "cpu" when asked for, "none" for no."""
def test_wish(self):
for win in (True, False):
with mock.patch.object(setup, "WIN", win):
self.assertEqual(setup.hip_vision(None), "gpu")
self.assertEqual(setup.hip_vision("yes"), "gpu")
self.assertEqual(setup.hip_vision("gpu"), "gpu")
self.assertEqual(setup.hip_vision("cpu"), "cpu")
self.assertEqual(setup.hip_vision("no"), "none")
self.assertEqual(setup.hip_vision("none"), "none")
# owner decision 2026-10-18: nothing asked / yes = the CPU reader under 24 GB, the GPU reader from 24 GB; gpu / cpu are kept
self.assertEqual(setup.hip_vision(None, 16.0), "cpu")
self.assertEqual(setup.hip_vision("yes", 16.0), "cpu")
self.assertEqual(setup.hip_vision("yes", 23.9), "gpu")
self.assertEqual(setup.hip_vision(None, 32.0), "gpu")
self.assertEqual(setup.hip_vision("gpu", 16.0), "gpu")
self.assertEqual(setup.hip_vision("cpu", 32.0), "cpu")
def test_reader_settled_against_the_engine_folder(self):
for win, suffix in ((False, ""), (True, ".exe")):
with tempfile.TemporaryDirectory() as d, mock.patch.object(setup, "WIN", win), mock.patch.object(setup, "warn", lambda *a: None):
eng = Path(d)
(eng / ("strata-vision" + suffix)).write_bytes(b"x")
self.assertEqual(setup.hip_reader(eng, "gpu", {"arch": "gfx1201"})[0], "cpu") # no HIP reader: the CPU one
(eng / ("strata-vision-hip" + suffix)).write_bytes(b"x")
mode, r = setup.hip_reader(eng, "gpu", {"arch": "gfx1201"})
self.assertEqual((mode, r.exe.name, r.cpu_exe.name),
("gpu", "strata-vision-hip" + suffix, "strata-vision" + suffix))
(eng / "BUILD.json").write_text('{"vision_archs": ["gfx1100"]}')
self.assertEqual(setup.hip_reader(eng, "gpu", {"arch": "gfx1201"})[0], "cpu") # the card is not in its build
self.assertEqual(setup.hip_reader(eng, "gpu", {"arch": "gfx1100"})[0], "gpu")
self.assertEqual(setup.hip_reader(eng, "cpu", {"arch": "gfx1100"})[0], "cpu")
(eng / ("strata-vision" + suffix)).unlink()
(eng / ("strata-vision-hip" + suffix)).unlink()
self.assertEqual(setup.hip_reader(eng, "gpu", {"arch": "gfx1100"})[0], "none")
class HipRuntimeBesideExe(unittest.TestCase):
+4 -6
View File
@@ -318,15 +318,13 @@ class ExperimentalSm60(unittest.TestCase):
class HipVision(unittest.TestCase):
"""#304: --vision cpu with --backend hip builds the CPU image encoder beside the HIP engine."""
@mock.patch.object(setup, "WIN", False) # Linux: compiled here (Windows has no AMD image encoder yet)
@mock.patch.object(setup, "WIN", False)
def test_the_choice(self):
self.assertEqual(quiet(setup.hip_vision, "cpu"), ("cpu", ""))
for asked in (None, "no", "none"):
for asked in ("no", "none"):
self.assertEqual(quiet(setup.hip_vision, asked), ("none", ""))
for asked in ("yes", "gpu"):
got, out = quiet(setup.hip_vision, asked)
self.assertEqual(got, "none")
self.assertIn("--vision cpu", out)
for asked in (None, "yes", "gpu"): # pictures are on by default; the engine folder's readers settle gpu vs cpu
self.assertEqual(quiet(setup.hip_vision, asked), ("gpu", ""))
def build(self, meta, vision, vexe=False):
"""build_engine_hip with an engine that is already built: only the encoder can be missing."""
+29 -6
View File
@@ -88,6 +88,7 @@ class InstallOwnPath(unittest.TestCase):
self.root = Path(self.tmp.name)
self.calls = []
self.families = {"qwen4exp", "qwen35moe"}
self.hip_reader = True
eng = self.root / "engine"
self.cfg = {}
@@ -96,6 +97,8 @@ class InstallOwnPath(unittest.TestCase):
if vision != "none": # the image encoder beside the engine
eng.mkdir(parents=True, exist_ok=True)
(eng / setup.VEXE).write_text("x")
if hip and self.hip_reader: # an AMD engine package carries the GPU reader too
(eng / setup.HVEXE).write_text("x")
return self.root / "llama", eng, {"version": "0.1.44"}, ["/libs"], gpu, vision
def write_cfg(path, cfg, source=None):
@@ -219,16 +222,36 @@ class InstallOwnPath(unittest.TestCase):
self.assertTrue(self.cfg["vision"]["gpu"])
self.assertIn("kept as you chose", out)
def test_amd_reads_pictures_on_the_cpu(self):
with mock.patch.object(setup, "WIN", False):
def test_amd_reads_pictures_on_the_gpu_with_the_cpu_reader_behind_it(self):
for win, suffix in ((False, ""), (True, ".exe")):
with mock.patch.object(setup, "WIN", win), mock.patch.object(setup, "VEXE", "strata-vision" + suffix), mock.patch.object(setup, "HVEXE", "strata-vision-hip" + suffix):
rc, out = self.install(model="UD-Q4_K_XL", vram=32.0, hip=True, vision="yes")
self.assertEqual(rc, 0, out)
v = self.cfg["vision"]
self.assertTrue(v["gpu"])
self.assertEqual(Path(v["exe"]).name, "strata-vision-hip" + suffix)
self.assertEqual(Path(v["cpu_exe"]).name, "strata-vision" + suffix)
self.assertIn("--vision", self.cfg["args"])
rc, out = self.install(model="UD-Q4_K_XL", vram=32.0, hip=True, vision="cpu")
v = self.cfg["vision"]
self.assertFalse(v["gpu"])
self.assertEqual(Path(v["exe"]).name, "strata-vision" + suffix)
self.assertNotIn("cpu_exe", v)
def test_amd_engine_without_the_gpu_reader_reads_pictures_on_the_cpu(self):
self.hip_reader = False
with mock.patch.object(setup, "WIN", False), mock.patch.object(setup, "VEXE", "strata-vision"):
rc, out = self.install(model="UD-Q4_K_XL", vram=32.0, hip=True, vision="yes")
self.assertEqual(rc, 0, out)
self.assertFalse(self.cfg["vision"]["gpu"])
self.assertNotIn("cpu_exe", self.cfg["vision"])
self.assertIn("--vision", self.cfg["args"])
with mock.patch.object(setup, "WIN", True):
rc, out = self.install(model="UD-Q4_K_XL", vram=32.0, hip=True, vision="cpu")
self.assertNotIn("vision", self.cfg)
self.assertIn("Linux-only", out)
def test_amd_pictures_are_on_when_nothing_is_asked(self):
with mock.patch.object(setup, "WIN", False), mock.patch.object(setup, "VEXE", "strata-vision"), mock.patch.object(setup, "HVEXE", "strata-vision-hip"):
rc, out = self.install(model="UD-Q4_K_XL", vram=32.0, hip=True)
self.assertEqual(rc, 0, out)
self.assertTrue(self.cfg["vision"]["gpu"])
def test_parallel_is_opt_in_and_recommended_where_the_experts_fit(self):
fam = setup.OWN_PATH_FAMILIES["qwen36"]
+115
View File
@@ -597,6 +597,39 @@ class Helpers(unittest.TestCase):
self.assertEqual(by["UD-Q4_K_XL"]["on_this_pc"], "fits (all of its experts in RAM)")
self.assertIn("read from the SSD", by["Q8_0"]["on_this_pc"])
def test_qwen36_is_listed_with_its_three_sizes(self):
"""QA 2026-10-18: the Qwen3.6-35B-A3B page of the first-run setup showed no versions, because setup.py keeps the family
in OWN_PATH_FAMILIES (not FAMILIES) and the model list only walked FAMILIES."""
s = M.Strata(HERE.parent)
self.assertIn("qwen36", s.tables()[1])
fam = next(f for f in s.model_table({"ram_gb": 63.7, "gpus": [{"index": 0, "name": "x", "vram_gb": 12.0, "usable": True}]})
if f["family"] == "qwen36")
by = {x["model"]: x for x in fam["sizes"]}
self.assertEqual(list(by), ["UD-Q4_K_XL", "UD-Q6_K_XL", "Q8_0"])
self.assertEqual(by["UD-Q4_K_XL"]["id"], "qwen36-ud-q4_k_xl") # the tag the install writes its config under
self.assertEqual(by["UD-Q4_K_XL"]["download_gb"], 22.9)
self.assertTrue(all(x["images"] and x["engine_min"] == "0.1.45" and x["gpu_options"] is None for x in by.values()))
self.assertEqual({x["on_this_pc"] for x in by.values()}, {"fits"})
small = next(f for f in s.model_table({"ram_gb": 16, "gpus": []}) if f["family"] == "qwen36")
self.assertEqual({x["on_this_pc"] for x in small["sizes"]}, {"does not fit"})
self.assertEqual(s.sizes_of(s.tables()[0], "unsloth")[-1], "Q8_0") # the Unsloth Flash-Next sizes are untouched
def test_qwen36_install_plan(self):
s = M.Strata(HERE.parent)
s.hardware = lambda fresh=False: {"ram_gb": 64.0, "gpus": [{"index": 0, "name": "x", "vram_gb": 12.0, "usable": True, "vendor": "nvidia"}],
"cpu": {"avx2": True}}
s.disk_free = lambda p: 1000.0
plan, args = M.Tools(s).install_plan("qwen36", "UD-Q4_K_XL", 32768, "yes", "int8", None, None, None, "auto", None, None)
self.assertEqual((plan["title"], plan["download_gb"], plan["family"]), ("Qwen3.6-35B-A3B UD-Q4_K_XL", 22.9, "qwen36"))
self.assertEqual(args[:10], ["--yes", "--no-start", "--family", "qwen36", "--model", "UD-Q4_K_XL", "--context", "32768",
"--vision", "yes"])
for flag in ("--kv", "--low-ram", "--experimental-speed-projection"): # Flash-Next settings: setup would only warn
self.assertNotIn(flag, args)
plan, _a = M.Tools(s).install_plan("qwen36", None, 32768, "no", None, None, None, None, None, None, None)
self.assertEqual(plan["model"], "UD-Q4_K_XL")
with self.assertRaises(M.ToolError):
M.Tools(s).install_plan("qwen36", "IQ3_S", 32768, "no", None, None, None, None, None, None, None)
def test_recommendation_follows_ram(self):
s = M.Strata(HERE.parent)
gpu = [{"index": 0, "name": "x", "vram_gb": 24.0, "usable": True, "vendor": "nvidia"}]
@@ -766,3 +799,85 @@ class RemoveMissingFilesTest(unittest.TestCase):
self.assertFalse(folder.exists()); self.assertFalse(cfg.exists())
again = mod.Tools(S()).strata_remove(model="gyro-m", confirm=True)
self.assertEqual(again["failed"], [], "a second delete of the same model finds nothing left and still succeeds")
class DiskUsageTest(unittest.TestCase):
"""QA 2026-10-18 (F02): Storage showed IQ3_S as 0.0 GB while it uses 84 GB, and Gyro-S as 119 GB while it is 59 GB. The sizes come from the real
files: every part of a model in parts, the picture reader, the packs; a file one config names through several flags counts once; a
file two models share counts once in the total; a model's own figure leaves out another model's files."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
d = Path(self.tmp.name)
self.root, self.data, self.own = d / "install", d / "data", d / "owner-folder"
for p in (self.root, self.data / "models" / "gyro-Gyro-S", self.data / "packs" / "swift-iq2_xs", self.data / "mtp" / "rt", self.own):
p.mkdir(parents=True)
(d / "settings.json").write_text(json.dumps({"data_dir": str(self.data)}), encoding="utf-8")
env = mock.patch.dict(os.environ, {"STRATA_SETTINGS_FILE": str(d / "settings.json")})
env.start()
self.addCleanup(env.stop)
def blob(self, path: Path, size: int) -> str:
path.write_bytes(b"x" * size)
return str(path)
def write_config(self, name: str, args: list, vision=None):
cfg = {"exe": sys.executable, "args": args, "port": 8080}
if vision:
cfg["vision"] = {"mmproj": vision}
(self.root / f"strata-{name}.json").write_text(json.dumps(cfg), encoding="utf-8")
def test_sizes_come_from_the_files_not_from_what_may_be_deleted(self):
gyro = self.blob(self.data / "models" / "gyro-Gyro-S" / "Gyro-S.gguf", 59_000)
reader = self.blob(self.data / "models" / "mmproj.gguf", 900)
part1 = self.blob(self.own / "Model-IQ3_S-00001-of-00002.gguf", 30_000)
self.blob(self.own / "Model-IQ3_S-00002-of-00002.gguf", 54_000)
# Gyro: the one file is --native, --ple-gguf, --embd-gguf and --native-dense-gguf at once
self.write_config("gyro-gyro-s", ["--native", gyro, "--ple-gguf", gyro, "--embd-gguf", gyro, "--native-dense-gguf", gyro], reader)
# IQ3_S: adopted from the person's own folder, the config names the first part only
self.write_config("iq3_s", ["--native", part1], reader)
s = M.Strata(self.root)
use = s.disk_usage()
self.assertEqual(use["models"]["gyro-gyro-s"]["bytes"], 59_000, "the Gyro file is counted once, not four times")
self.assertEqual(use["models"]["iq3_s"]["bytes"], 84_000, "both parts of IQ3_S, although the config names the first")
self.assertEqual(use["shared_bytes"], 900, "the picture reader both use is one file")
self.assertEqual(use["total_bytes"], 59_000 + 84_000 + 900, "rows plus the shared files add up to the total")
self.assertEqual(use["models"]["iq3_s"]["shared_bytes"], 900)
# deleting IQ3_S would remove nothing: its files are the person's own
by = {d["model"]: d for d in (s.describe_config(c, use) for c in s.configs())}
self.assertEqual(by["iq3_s"]["disk_bytes"], 84_000)
self.assertEqual(by["iq3_s"]["removable_gb"], 0.0)
self.assertEqual(by["gyro-gyro-s"]["disk_bytes"], 59_000)
def test_a_pack_folder_and_the_shared_draft_layer_count_once(self):
gguf = self.blob(self.data / "models" / "gyro-Gyro-S" / "m.gguf", 10_000)
pack = self.data / "packs" / "swift-iq2_xs"
self.blob(pack / "experts.bin", 40_000)
self.blob(pack / "index.txt", 100)
mtp = self.data / "mtp" / "rt"
self.blob(mtp / "experts.bin", 5_000)
self.write_config("swift-iq2_xs", ["--native", gguf, "--pack", str(pack), "--mtp", str(mtp)])
link_target = self.data / "models" / "gyro-Gyro-S" / "m2.gguf"
try:
os.link(gguf, link_target) # a hard link: the same bytes under another name
except OSError:
self.skipTest("no hard links here")
self.write_config("other", ["--native", str(link_target), "--mtp", str(mtp)])
use = M.Strata(self.root).disk_usage()
self.assertEqual(use["models"]["swift-iq2_xs"]["bytes"], 40_100)
self.assertEqual(use["models"]["other"]["bytes"], 0)
self.assertEqual(use["shared_bytes"], 10_000 + 5_000, "the hard-linked GGUF and the draft layer are shared")
self.assertEqual(use["total_bytes"], 55_100)
def test_a_model_with_a_part_missing_is_not_ready(self):
part1 = self.blob(self.own / "Model-00001-of-00003.gguf", 10)
self.blob(self.own / "Model-00002-of-00003.gguf", 10)
self.write_config("three-parts", ["--native", part1])
s = M.Strata(self.root)
desc = s.describe_config(s.configs()[0])
self.assertFalse(desc["ready"], "one part of three is on disk: not downloaded")
self.assertTrue(any(m.endswith("Model-00003-of-00003.gguf") for m in desc["missing_files"]), desc["missing_files"])
self.blob(self.own / "Model-00003-of-00003.gguf", 10)
desc = s.describe_config(s.configs()[0])
self.assertEqual([m for m in desc["missing_files"] if m.endswith(".gguf")], [])
+213
View File
@@ -0,0 +1,213 @@
"""The picture reader choice for an AMD engine folder (strata_control/vision_reader.py) and the server's fallback from the GPU
reader to the CPU reader (serve/server.py, Vision._start): hip present, hip missing, hip start failure, then cpu.
python -m unittest tools.test_vision_reader
"""
from __future__ import annotations
import io
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
from strata_control import vision_reader as VR
import serve.server as server
def folder(*names: str) -> Path:
d = Path(tempfile.mkdtemp(prefix="vr-"))
for n in names:
(d / n).write_bytes(b"x")
return d
class Choose(unittest.TestCase):
def test_the_default_reader_by_card_memory_is_one_rule_for_every_make(self):
self.assertEqual(VR.default_mode(12), "cpu")
self.assertEqual(VR.default_mode(16), "cpu")
self.assertEqual(VR.default_mode(23.5), "gpu")
self.assertEqual(VR.default_mode(24), "gpu")
self.assertEqual(VR.default_mode(32), "gpu")
self.assertEqual(VR.default_mode(None), "gpu") # memory unknown: the GPU reader falls back by itself
for asked, vram, want in ((None, 16, "cpu"), ("yes", 12, "cpu"), ("on", 32, "gpu"), ("gpu", 12, "gpu"), ("cpu", 32, "cpu"),
("no", 32, "none"), ("off", 12, "none")):
self.assertEqual(VR.wanted(asked, vram), want, (asked, vram))
def test_hip_present_gives_the_gpu_reader_with_the_cpu_one_to_fall_back_on(self):
d = folder("strata-vision", "strata-vision-hip")
for ask in ("yes", "on", "gpu"):
r = VR.choose(d, ask, windows=False, gpu_arch="gfx1201", archs=["gfx1200", "gfx1201"])
self.assertEqual((r.mode, r.exe.name, r.cpu_exe.name), ("gpu", "strata-vision-hip", "strata-vision"), ask)
self.assertEqual(r.note, "")
def test_hip_missing_gives_the_cpu_reader(self):
d = folder("strata-vision")
r = VR.choose(d, "yes", windows=False, gpu_arch="gfx1201")
self.assertEqual((r.mode, r.exe.name, r.cpu_exe), ("cpu", "strata-vision", None))
self.assertEqual(r.note, "") # "yes" never asked for the GPU in particular
self.assertIn("no GPU picture reader", VR.choose(d, "gpu", windows=False).note) # --vision gpu: said once
def test_card_not_in_the_build_gives_the_cpu_reader(self):
d = folder("strata-vision", "strata-vision-hip")
r = VR.choose(d, "yes", windows=False, gpu_arch="gfx1100", archs=["gfx1200", "gfx1201"])
self.assertEqual((r.mode, r.exe.name), ("cpu", "strata-vision"))
self.assertIn("gfx1100", r.note)
def test_unknown_card_tries_the_gpu_reader(self):
d = folder("strata-vision", "strata-vision-hip")
self.assertEqual(VR.choose(d, "yes", windows=False, gpu_arch="", archs=["gfx1201"]).mode, "gpu")
self.assertEqual(VR.choose(d, "yes", windows=False, gpu_arch="gfx1100", archs=None).mode, "gpu")
def test_gcn_arch_names_with_features_match(self):
self.assertEqual(VR.gfx_of("gfx1201:sramecc-:xnack-"), "gfx1201")
d = folder("strata-vision", "strata-vision-hip")
self.assertEqual(VR.choose(d, "yes", windows=False, gpu_arch="gfx1151:sramecc+:xnack-", archs=["gfx1151"]).mode, "gpu")
def test_cpu_asked_and_off(self):
d = folder("strata-vision", "strata-vision-hip")
self.assertEqual(VR.choose(d, "cpu", windows=False).exe.name, "strata-vision")
for ask in ("no", "none", "off", None):
self.assertEqual(VR.choose(d, ask, windows=False).mode, "none")
def test_no_reader_at_all(self):
d = folder("strata")
for ask in ("yes", "cpu", "gpu"):
r = VR.choose(d, ask, windows=False)
self.assertEqual(r.mode, "none", ask)
self.assertIn("no picture reader", r.note)
def test_windows_names(self):
d = folder("strata-vision.exe", "strata-vision-hip.exe")
r = VR.choose(d, "yes", windows=True, gpu_arch="gfx1201", archs=["gfx1201"])
self.assertEqual((r.mode, r.exe.name, r.cpu_exe.name), ("gpu", "strata-vision-hip.exe", "strata-vision.exe"))
self.assertEqual(VR.choose(folder("strata-vision-hip"), "yes", windows=True).mode, "none") # a Linux name on Windows
def test_run_config_section(self):
d = folder("strata-vision", "strata-vision-hip")
base = {"mmproj": "m.gguf", "model": "t.gguf", "max_tokens": 1024}
sec = VR.vision_section(VR.choose(d, "yes", windows=False), base)
self.assertTrue(sec["gpu"])
self.assertEqual(Path(sec["exe"]).name, "strata-vision-hip")
self.assertEqual(Path(sec["cpu_exe"]).name, "strata-vision")
cpu = VR.cpu_fallback(sec)
self.assertFalse(cpu["gpu"])
self.assertEqual(Path(cpu["exe"]).name, "strata-vision")
self.assertNotIn("cpu_exe", cpu)
self.assertEqual(cpu["mmproj"], "m.gguf")
cpu_only = VR.vision_section(VR.choose(folder("strata-vision"), "yes", windows=False), base)
self.assertFalse(cpu_only["gpu"])
self.assertNotIn("cpu_exe", cpu_only)
self.assertIsNone(VR.cpu_fallback(cpu_only))
self.assertIsNone(VR.cpu_fallback({**sec, "cpu_exe": ""}))
self.assertIsNone(VR.cpu_fallback({"exe": "strata-vision", "gpu": True})) # an NVIDIA config has no fallback
class FakeProc:
def __init__(self, first_line: str):
self.stdin, self.stdout = io.StringIO(), io.StringIO(first_line)
self.killed = False
def kill(self):
self.killed = True
def wait(self, timeout=None):
return 0
def poll(self):
return None
def make_vision(cfg: dict) -> "server.Vision":
with mock.patch.object(server.Vision, "work_dir", staticmethod(lambda: Path(tempfile.gettempdir()))):
return server.Vision(cfg, lazy=True)
class Fallback(unittest.TestCase):
CFG = {"exe": "/e/strata-vision-hip", "cpu_exe": "/e/strata-vision", "gpu": True, "mmproj": "/m/mm.gguf",
"model": "/m/t.gguf", "max_tokens": 1024, "threads": 6}
def run_start(self, answers: dict, cfg: dict | None = None):
"""answers: exe name -> its first stdout line, or an exception to raise from popen."""
started = []
def popen(what, args, **kw):
name = Path(args[0]).name
started.append(args)
a = answers[name]
if isinstance(a, Exception):
raise a
return FakeProc(a)
v = make_vision(cfg or self.CFG)
out = io.StringIO()
with mock.patch.object(server, "popen", popen), mock.patch("builtins.print", lambda *a, **k: out.write(" ".join(map(str, a)) + "\n")):
err = None
try:
v._start()
except RuntimeError as e:
err = e
return v, started, out.getvalue(), err
def test_hip_present_and_starts(self):
v, started, log, err = self.run_start({"strata-vision-hip": "READY 2048\n"})
self.assertIsNone(err)
self.assertEqual([Path(a[0]).name for a in started], ["strata-vision-hip"])
self.assertIn("--gpu", started[0])
self.assertEqual(log, "")
self.assertFalse(v.stopped)
def test_hip_start_failure_falls_back_to_cpu_with_one_line(self):
v, started, log, err = self.run_start({"strata-vision-hip": "ERR this GPU (gfx1100) is not in the picture reader's build (gfx1200,gfx1201)\n",
"strata-vision": "READY 2048\n"})
self.assertIsNone(err)
self.assertEqual([Path(a[0]).name for a in started], ["strata-vision-hip", "strata-vision"])
self.assertNotIn("--gpu", started[1])
self.assertIn("--threads", started[1])
self.assertEqual(log.count("\n"), 1)
self.assertIn("gfx1100", log)
self.assertIn("CPU", log)
self.assertFalse(v.stopped)
self.assertIsNone(v.fallback)
def test_hip_process_that_dies_silently_falls_back(self): # a GPU runtime that aborts: stdout closes empty
v, started, log, err = self.run_start({"strata-vision-hip": "", "strata-vision": "READY 2048\n"})
self.assertIsNone(err)
self.assertEqual(len(started), 2)
self.assertEqual(log.count("\n"), 1)
def test_hip_missing_file_falls_back(self): # popen raises (exe gone, not executable)
v, started, log, err = self.run_start({"strata-vision-hip": FileNotFoundError("strata-vision-hip"),
"strata-vision": "READY 2048\n"})
self.assertIsNone(err)
self.assertEqual(len(started), 2)
def test_restart_after_a_fallback_stays_on_the_cpu_reader(self):
v, started, log, err = self.run_start({"strata-vision-hip": "ERR nope\n", "strata-vision": "READY 2048\n"})
started.clear()
with mock.patch.object(server, "popen", lambda what, args, **kw: started.append(args) or FakeProc("READY 2048\n")):
v.restart()
self.assertEqual([Path(a[0]).name for a in started], ["strata-vision"])
def test_cpu_failure_after_the_fallback_is_an_error(self):
v, started, log, err = self.run_start({"strata-vision-hip": "ERR nope\n", "strata-vision": "ERR cannot load the vision encoder\n"})
self.assertIsNotNone(err)
self.assertIn("cannot load", str(err))
def test_a_config_without_cpu_exe_has_no_fallback(self): # NVIDIA: the error is shown as before
cfg = {k: v for k, v in self.CFG.items() if k != "cpu_exe"}
v, started, log, err = self.run_start({"strata-vision-hip": "ERR nope\n"}, cfg)
self.assertIsNotNone(err)
self.assertEqual(len(started), 1)
self.assertEqual(log, "")
def test_cpu_config_is_unchanged(self):
cfg = {"exe": "/e/strata-vision", "gpu": False, "mmproj": "/m/mm.gguf", "model": "/m/t.gguf"}
v, started, log, err = self.run_start({"strata-vision": "READY 2048\n"}, cfg)
self.assertIsNone(err)
self.assertNotIn("--gpu", started[0])
if __name__ == "__main__":
unittest.main()
+39
View File
@@ -3,6 +3,7 @@
#
# cmake -S tools/vision -B build-vision -G Ninja -DCMAKE_BUILD_TYPE=Release -DLLAMA_DIR=third_party/llama.cpp
# [-DSTRATA_VISION_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=native] [-DSTRATA_PORTABLE=OFF]
# [-DSTRATA_VISION_HIP=ON -DSTRATA_VISION_HIP_ARCHS="gfx1200;gfx1201" + the HIP compiler the engine build uses]
# cmake --build build-vision --target strata-vision
#
# Portable (AVX2 baseline) is the default, so an encoder built without extra flags runs on any AVX2 PC: the 0.1.32
@@ -14,6 +15,14 @@ project(strata_vision C CXX)
set(LLAMA_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../third_party/llama.cpp" CACHE PATH "llama.cpp checkout")
option(STRATA_VISION_CUDA "run the vision encoder on the GPU (builds ggml-cuda)" OFF)
option(STRATA_VISION_HIP "run the vision encoder on an AMD GPU (builds ggml-hip); the program is named strata-vision-hip" OFF)
set(STRATA_VISION_HIP_ARCHS "" CACHE STRING "the gfx architectures strata-vision-hip is compiled for (';'-separated)")
if(STRATA_VISION_HIP AND STRATA_VISION_CUDA)
message(FATAL_ERROR "strata-vision: STRATA_VISION_CUDA and STRATA_VISION_HIP are two different programs")
endif()
if(STRATA_VISION_HIP AND NOT STRATA_VISION_HIP_ARCHS)
message(FATAL_ERROR "strata-vision: STRATA_VISION_HIP needs STRATA_VISION_HIP_ARCHS, for example \"gfx1200;gfx1201\"")
endif()
option(STRATA_PORTABLE "build for other PCs: AVX2 baseline, static MSVC runtime" ON)
if(STRATA_PORTABLE AND MSVC)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded")
@@ -52,6 +61,13 @@ else()
endif()
set(GGML_BACKEND_DL OFF CACHE BOOL "" FORCE)
set(GGML_CUDA ${STRATA_VISION_CUDA} CACHE BOOL "" FORCE)
set(GGML_HIP ${STRATA_VISION_HIP} CACHE BOOL "" FORCE)
if(STRATA_VISION_HIP)
# the engine's HIP build says the same (cmake/hip_backend.cmake); ggml-hip reads AMDGPU_TARGETS / CMAKE_HIP_ARCHITECTURES
set(AMDGPU_TARGETS "${STRATA_VISION_HIP_ARCHS}" CACHE STRING "" FORCE)
set(GPU_TARGETS "${STRATA_VISION_HIP_ARCHS}" CACHE STRING "" FORCE)
set(CMAKE_HIP_ARCHITECTURES "${STRATA_VISION_HIP_ARCHS}" CACHE STRING "" FORCE)
endif()
add_subdirectory(${LLAMA_DIR} llama EXCLUDE_FROM_ALL)
@@ -59,9 +75,32 @@ add_executable(strata-vision strata_vision.cpp)
target_link_libraries(strata-vision PRIVATE mtmd llama)
target_compile_features(strata-vision PRIVATE cxx_std_17)
set_target_properties(strata-vision PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
if(STRATA_VISION_HIP)
# beside strata (and, in a component, the CPU reader "strata-vision"): the GPU reader is its own program, so a PC where
# the HIP runtime does not load still has the CPU one to fall back on
set_target_properties(strata-vision PROPERTIES OUTPUT_NAME strata-vision-hip)
string(REPLACE ";" "," _hip_archs "${STRATA_VISION_HIP_ARCHS}")
target_compile_definitions(strata-vision PRIVATE STRATA_VISION_HIP STRATA_VISION_HIP_ARCHS="${_hip_archs}")
endif()
if(WIN32 AND STRATA_VISION_HIP)
# hipblas.dll (and rocblas.dll behind it) live in rocm\bin of the engine package: loaded on first use, after main() has put that folder on
# the DLL search path, so the reader starts from any PATH and says "no GPU" itself where there is none
target_link_libraries(strata-vision PRIVATE delayimp)
target_link_options(strata-vision PRIVATE -Wl,/delayload:hipblas.dll)
endif()
if(UNIX AND NOT APPLE)
# the libraries it needs are the ones in its own folder (a component folder is relocatable): RUNPATH $ORIGIN
set_target_properties(strata-vision PROPERTIES BUILD_WITH_INSTALL_RPATH ON INSTALL_RPATH "$ORIGIN")
target_link_options(strata-vision PRIVATE -Wl,--enable-new-dtags)
endif()
# opens a GGUF that has tensor types llama.cpp does not know (a Gyro quantization's TQK6 / TQK7) for its vocabulary only
add_executable(strata-vision-gguf-test EXCLUDE_FROM_ALL gguf_meta_test.cpp)
target_link_libraries(strata-vision-gguf-test PRIVATE llama)
target_compile_features(strata-vision-gguf-test PRIVATE cxx_std_17)
set_target_properties(strata-vision-gguf-test PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
# the HIP reader's architecture check (gpu_arch.h), without a GPU
add_executable(strata-vision-arch-test EXCLUDE_FROM_ALL gpu_arch_test.cpp)
target_compile_features(strata-vision-arch-test PRIVATE cxx_std_17)
set_target_properties(strata-vision-arch-test PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
+33
View File
@@ -0,0 +1,33 @@
// tools/vision/gpu_arch.h - which GPU the HIP picture reader (strata-vision-hip) was compiled for, and whether the GPU it
// found is one of them. ggml-hip prints " Device 0: AMD Radeon RX 9070 XT, gfx1201:sramecc-:xnack- (0x1201), ..." when it
// starts; a card whose architecture is not in the build would fail at its first kernel ("no kernel image", an abort), so
// the reader reads that line and refuses before any kernel runs. The server then starts the CPU reader (see
// serve/server.py, Vision._start). Header only, so gpu_arch_test.cpp can check it without a GPU.
#pragma once
#include <cctype>
#include <string>
#include <vector>
// "gfx1201" out of ggml's device line (or a gcnArchName: "gfx1201:sramecc-:xnack-"); "" when there is none
inline std::string strata_gfx_in(const std::string& text) {
for (size_t at = text.find("gfx"); at != std::string::npos; at = text.find("gfx", at + 1)) {
size_t end = at + 3;
while (end < text.size() && std::isxdigit((unsigned char) text[end])) ++end;
if (end > at + 3) return text.substr(at, end - at);
}
return "";
}
// "gfx1200,gfx1201" (the build's list; ',' or ';' between) holds `arch`
inline bool strata_arch_listed(const std::string& list, const std::string& arch) {
if (arch.empty()) return false;
size_t start = 0;
while (start <= list.size()) {
size_t end = list.find_first_of(",; ", start);
if (end == std::string::npos) end = list.size();
if (list.compare(start, end - start, arch) == 0 && end - start == arch.size()) return true;
start = end + 1;
}
return false;
}
+24
View File
@@ -0,0 +1,24 @@
// tools/vision/gpu_arch_test.cpp - the HIP picture reader's architecture check (gpu_arch.h) without a GPU. Exit 0 = passed.
#include "gpu_arch.h"
#include <cstdio>
static int bad = 0;
#define CHECK(c) do { if (!(c)) { std::printf("FAIL line %d: %s\n", __LINE__, #c); ++bad; } } while (0)
int main() {
CHECK(strata_gfx_in(" Device 0: AMD Radeon RX 9070 XT, gfx1201:sramecc-:xnack- (0x1201), VMM: no, Wave Size: 32") == "gfx1201");
CHECK(strata_gfx_in(" Device 0: AMD Radeon Graphics, gfx1151 (0x1151)") == "gfx1151");
CHECK(strata_gfx_in(" Device 0: gfx, nothing after it") == "");
CHECK(strata_gfx_in("no architecture here") == "");
CHECK(strata_arch_listed("gfx1200,gfx1201", "gfx1201"));
CHECK(strata_arch_listed("gfx1200;gfx1201", "gfx1200"));
CHECK(strata_arch_listed("gfx1100", "gfx1100"));
CHECK(!strata_arch_listed("gfx1200,gfx1201", "gfx1100"));
CHECK(!strata_arch_listed("gfx1100", "gfx110")); // a prefix is not an architecture
CHECK(!strata_arch_listed("gfx1100", "gfx11000"));
CHECK(!strata_arch_listed("", "gfx1100"));
CHECK(!strata_arch_listed("gfx1100", ""));
std::printf(bad ? "gpu_arch_test: %d FAILED\n" : "gpu_arch_test: ok\n", bad);
return bad ? 1 : 0;
}
+64
View File
@@ -13,9 +13,11 @@
// QUIT
// The output file is int32 {0x31455653 'SVE1', n_tokens, nx, ny, n_embd} then float32 [n_tokens][n_embd],
// row i at grid position (x = i % nx, y = i / nx). The text model is opened vocab-only (no weights).
#include "ggml-backend.h"
#include "ggml-cpu.h"
#include "gguf.h"
#include "gguf_meta.h"
#include "gpu_arch.h"
#include "llama.h"
#include "mtmd.h"
#include "mtmd-helper.h"
@@ -24,6 +26,10 @@
#if !defined(_WIN32)
#include <fcntl.h>
#include <unistd.h>
#elif defined(STRATA_VISION_HIP)
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#endif
#include <chrono>
#include <cstdio>
@@ -37,7 +43,14 @@
namespace {
// the GPU architectures ggml-hip printed while it started (" Device 0: ..., gfx1201:sramecc-:xnack- (0x1201), ...")
std::vector<std::string> g_gfx;
void quiet_log(ggml_log_level level, const char* text, void*) {
if (level == GGML_LOG_LEVEL_INFO && std::strstr(text, "Device ") != nullptr) {
const std::string gfx = strata_gfx_in(text);
if (!gfx.empty()) g_gfx.push_back(gfx);
}
if (level >= GGML_LOG_LEVEL_WARN) std::fputs(text, stderr);
}
@@ -56,6 +69,22 @@ bool parse_enc(const std::string& line, std::string& img, std::string& out) {
} // namespace
int main(int argc, char** argv) {
#if defined(_WIN32) && defined(STRATA_VISION_HIP)
// hipblas.dll is delay-loaded (tools/vision/CMakeLists.txt): it and the ROCm DLLs behind it (rocblas.dll finds its kernel
// library and ../.kpack beside itself) stay in rocm\bin of the engine package, which is not on the DLL search path of a
// program started with nothing but System32 in PATH. Put that folder on it before the first call.
{
wchar_t exe[MAX_PATH];
const DWORD n = GetModuleFileNameW(nullptr, exe, MAX_PATH);
std::wstring dir(exe, n);
const size_t cut = dir.find_last_of(L"\\/");
if (cut != std::wstring::npos) {
dir.resize(cut);
const std::wstring rocm = dir + L"\\rocm\\bin";
if (GetFileAttributesW(rocm.c_str()) != INVALID_FILE_ATTRIBUTES) SetDllDirectoryW(rocm.c_str());
}
}
#endif
std::string mmproj, model;
bool gpu = false;
int threads = 0, max_tokens = 0, min_tokens = 0;
@@ -70,6 +99,14 @@ int main(int argc, char** argv) {
if (a == "--mmproj") mmproj = next();
else if (a == "--model") model = next();
else if (a == "--gpu") gpu = true;
else if (a == "--version") {
#ifdef STRATA_VISION_HIP
std::printf("strata-vision hip (GPU architectures: %s)\n", STRATA_VISION_HIP_ARCHS);
#else
std::printf("strata-vision\n");
#endif
return 0;
}
else if (a == "--threads") threads = std::atoi(next().c_str());
else if (a == "--max-tokens") max_tokens = std::atoi(next().c_str());
else if (a == "--min-tokens") min_tokens = std::atoi(next().c_str()); // mtmd image_min_tokens (#767)
@@ -91,13 +128,40 @@ int main(int argc, char** argv) {
if (!gpu) {
#ifdef _WIN32
_putenv_s("CUDA_VISIBLE_DEVICES", "-1");
_putenv_s("HIP_VISIBLE_DEVICES", "-1");
#else
setenv("CUDA_VISIBLE_DEVICES", "-1", 1);
setenv("HIP_VISIBLE_DEVICES", "-1", 1);
#endif
}
llama_log_set(quiet_log, nullptr);
mtmd_helper_log_set(quiet_log, nullptr);
llama_backend_init();
#ifdef STRATA_VISION_HIP
if (gpu) {
// The AMD GPU reader: starting the backends prints the GPU line. With --gpu and no GPU, mtmd would quietly use the CPU
// (a second, slower CPU reader inside the HIP program), and a card the build has no code for would abort at its first
// kernel. Both end here with a line the server reads: it then starts the CPU reader (serve/server.py, Vision._start).
bool have_gpu = false;
for (size_t i = 0; i < ggml_backend_dev_count(); ++i) {
const auto type = ggml_backend_dev_type(ggml_backend_dev_get(i));
have_gpu = have_gpu || type == GGML_BACKEND_DEVICE_TYPE_GPU || type == GGML_BACKEND_DEVICE_TYPE_IGPU;
}
if (!have_gpu) {
std::printf("ERR this picture reader found no GPU (it was built for: %s)\n", STRATA_VISION_HIP_ARCHS);
std::fflush(stdout);
return 3;
}
const char* check = std::getenv("STRATA_VISION_ARCH_CHECK");
if (!g_gfx.empty() && (check == nullptr || std::atoi(check) != 0) &&
!strata_arch_listed(STRATA_VISION_HIP_ARCHS, g_gfx.front())) {
std::printf("ERR this GPU (%s) is not in the picture reader's build (%s)\n", g_gfx.front().c_str(),
STRATA_VISION_HIP_ARCHS);
std::fflush(stdout);
return 3;
}
}
#endif
const auto load_t0 = std::chrono::steady_clock::now();
#if !defined(_WIN32) && !defined(__APPLE__) // macOS has no posix_fadvise