mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 18:35:04 -10:00
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:
commit
95f3807699
72 files changed
+2271
-209
No files matched your search
Generated
+2
@@ -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]]
|
||||
|
||||
@@ -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 = [] }
|
||||
|
||||
|
||||
@@ -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' }));");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}:{})};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>; }
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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…');
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 がこのパソコン用のエンジンを認識できませんでした。モデルはダウンロード済みですが、適切なエンジンをインストールするまで動作しません。設定 → エンジン を開いてください。"
|
||||
}
|
||||
@@ -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가 이 컴퓨터용 엔진을 인식하지 못했습니다. 모델은 다운로드되었지만 올바른 엔진을 설치하기 전에는 실행할 수 없습니다. 설정 → 엔진을 열어 주세요."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 не распознала движок для этого компьютера. Модель скачана, но не запустится, пока не будет установлен нужный движок. Откройте Настройки → Движок."
|
||||
}
|
||||
@@ -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 没有识别出适用于这台电脑的引擎。模型已下载,但在安装正确的引擎之前无法运行。请打开 设置 → 引擎。"
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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); }
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")], [])
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user