@@ -46,30 +46,6 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
4646const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models' ;
4747
4848let _cachedAllModels = [ ] ;
49- const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1' ;
50- const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000 ;
51-
52- function _readCachedModelScan ( sig ) {
53- try {
54- const all = JSON . parse ( localStorage . getItem ( _CACHED_MODELS_SCAN_KEY ) || '{}' ) ;
55- const entry = all [ sig ] ;
56- if ( entry && Date . now ( ) - ( entry . ts || 0 ) < _CACHED_MODELS_SCAN_TTL ) return entry . data || null ;
57- } catch { }
58- return null ;
59- }
60-
61- function _writeCachedModelScan ( sig , data ) {
62- try {
63- const all = JSON . parse ( localStorage . getItem ( _CACHED_MODELS_SCAN_KEY ) || '{}' ) ;
64- all [ sig ] = { ts : Date . now ( ) , data } ;
65- const keys = Object . keys ( all ) ;
66- if ( keys . length > 12 ) {
67- keys . sort ( ( a , b ) => ( all [ a ] . ts || 0 ) - ( all [ b ] . ts || 0 ) ) ;
68- for ( const k of keys . slice ( 0 , keys . length - 12 ) ) delete all [ k ] ;
69- }
70- localStorage . setItem ( _CACHED_MODELS_SCAN_KEY , JSON . stringify ( all ) ) ;
71- } catch { }
72- }
7349
7450function _loadServeFavorites ( ) {
7551 try {
@@ -551,7 +527,7 @@ function _selectedServeTarget(panel) {
551527 env : server ?. env || '' ,
552528 port : host ? ( server ?. port || _getPort ( host ) || '' ) : '' ,
553529 venv,
554- platform : server ?. platform || _envState . platform || '' ,
530+ platform : host ? ( server ?. platform || '' ) : ( _envState . hostPlatform || '' ) ,
555531 label,
556532 } ;
557533}
@@ -682,6 +658,12 @@ function _selectedGgufSizeGb(model, relPath) {
682658 return bytes / ( 1024 ** 3 ) ;
683659}
684660
661+ function _projectorGgufFiles ( model ) {
662+ return _ggufFilesForModel ( model )
663+ . filter ( f => ( f . role || '' ) === 'projector' || / ( ^ | \/ ) m m p r o j [ ^ / ] * \. g g u f $ / i. test ( f . rel_path || f . name || '' ) )
664+ . sort ( ( a , b ) => String ( a . rel_path || a . name || '' ) . localeCompare ( String ( b . rel_path || b . name || '' ) ) ) ;
665+ }
666+
685667function _ggufFileLabel ( file ) {
686668 const base = ( file . name || file . rel_path || '' ) . split ( '/' ) . pop ( ) ;
687669 const size = _formatGgufSize ( file . size_bytes ) ;
@@ -1222,6 +1204,7 @@ function _rerenderCachedModels() {
12221204 panelHtml += `<div class="hwfit-serve-warn" style="margin:0 0 8px;padding:6px 10px;border-radius:5px;font-size:11px;background:color-mix(in srgb, var(--color-warning, #f0ad4e) 14%, transparent);border:1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);color:var(--color-warning, #f0ad4e);display:flex;gap:6px;align-items:flex-start;line-height:1.4;"><span aria-hidden="true">⚠</span><span>${ _warnText } </span></div>` ;
12231205 }
12241206 panelHtml += `<div class="hwfit-serve-preset-row">${ _slotsHtml } </div>` ;
1207+ panelHtml += `<div class="hwfit-serve-vision-warn" style="display:none;margin:0 0 8px;padding:6px 10px;border-radius:5px;font-size:11px;background:color-mix(in srgb, var(--color-warning, #f0ad4e) 14%, transparent);border:1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);color:var(--color-warning, #f0ad4e);gap:6px;align-items:flex-start;line-height:1.4;"><span aria-hidden="true">⚠</span><span>Vision is enabled, but no mmproj GGUF projector was found in the cached model scan. Download an mmproj-*.gguf for this model, then refresh the cached model list before launching.</span></div>` ;
12251208 // Row 1: Engine + Server + Env
12261209 panelHtml += `<div class="hwfit-serve-row">` ;
12271210 const backendOpts = _backendChoices . map ( ( [ v , l ] ) => `<option value="${ v } "${ defaultBackend === v ?' selected' :'' } >${ l } </option>` ) . join ( '' ) ;
@@ -1548,6 +1531,11 @@ function _rerenderCachedModels() {
15481531 if ( el . type === 'checkbox' ) f [ el . dataset . field ] = el . checked ;
15491532 else f [ el . dataset . field ] = el . value ;
15501533 } ) ;
1534+ const buildTarget = _selectedServeTarget ( panel ) ;
1535+ f . host = buildTarget . host || '' ;
1536+ f . platform = buildTarget . platform || '' ;
1537+ const hostField = panel . querySelector ( '[data-field="host"]' ) ;
1538+ if ( hostField ) hostField . value = f . host ;
15511539 const backend = f . backend || 'vllm' ;
15521540 const serveModel = ( f . model_path || '' ) . trim ( ) || ( m . is_local_dir && m . path ? `${ m . path } /${ repo } ` : repo ) ;
15531541 if ( backend === 'llamacpp' ) {
@@ -1567,11 +1555,11 @@ function _rerenderCachedModels() {
15671555 : m . is_local_dir && m . path
15681556 ? `$({ find ${ _ldir } -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${ _ldir } -name '*.gguf' 2>/dev/null | sort; } | head -1)`
15691557 : `$({ find ${ dir } -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${ dir } -name '*.gguf' 2>/dev/null | sort; } | head -1)` ;
1570- // Vision: auto-find the mmproj (CLIP/projector ) file in the same dir .
1571- // Resolved at runtime so the toggle just works if an mmproj-*.gguf is
1572- // present (downloaded alongside the model). Empty if none → cmd omits it .
1573- const _vsearchdir = ( m . is_local_dir && m . path ) ? _ldir : dir ;
1574- f . _mmproj_path = `$(find ${ _vsearchdir } -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)` ;
1558+ // Vision: use the scanned projector (CLIP/mmproj ) file when present .
1559+ // Keeping this as a printf path avoids generating a command substitution
1560+ // that the backend serve-command validator must reject as unsafe .
1561+ const selectedProjector = _projectorGgufFiles ( m ) [ 0 ] ;
1562+ f . _mmproj_path = selectedProjector ? _selectedGgufExpr ( m , repo , selectedProjector . rel_path ) : '' ;
15751563 }
15761564 if ( f . reasoning_parser ) {
15771565 const _rpEl2 = panel . querySelector ( '[data-field="reasoning_parser"]' ) ;
@@ -1587,6 +1575,10 @@ function _rerenderCachedModels() {
15871575 }
15881576 let cmd = _buildServeCmd ( f , serveModel , backend ) ;
15891577 if ( f . extra && f . extra . trim ( ) ) cmd += ' ' + f . extra . trim ( ) ;
1578+ const missingVisionProjector = backend === 'llamacpp' && ! ! f . vision && ! f . _mmproj_path ;
1579+ panel . _visionMissingProjector = missingVisionProjector ;
1580+ const _visionWarn = panel . querySelector ( '.hwfit-serve-vision-warn' ) ;
1581+ if ( _visionWarn ) _visionWarn . style . display = missingVisionProjector ? 'flex' : 'none' ;
15901582 const _ce2 = panel . querySelector ( '.hwfit-serve-cmd' ) ; _ce2 . value = _formatServeCmdPreview ( cmd ) ; _ce2 . style . height = 'auto' ; _ce2 . style . height = _ce2 . scrollHeight + 'px' ;
15911583 panel . _cmd = cmd ;
15921584 panel . _host = f . host || '' ;
@@ -2962,12 +2954,16 @@ function _rerenderCachedModels() {
29622954 } ) ;
29632955 serveState . backend = serveState . backend || ( _detectBackend ( m ) . backend ) || 'vllm' ;
29642956 const launchTarget = _selectedServeTarget ( panel ) ;
2957+ if ( serveState . backend === 'llamacpp' && serveState . vision && ! / (?: ^ | \s ) (?: - - m m p r o j | - - c l i p _ m o d e l _ p a t h ) \b / . test ( launchCmd ) ) {
2958+ _restoreLaunchBtn ( ) ;
2959+ uiModule . showToast ( 'Vision is checked, but no mmproj projector is in the launch command. Refresh cached models after downloading mmproj, or add --mmproj manually.' , 8000 ) ;
2960+ return ;
2961+ }
29652962 if ( serveState . backend === 'diffusers' && _remoteWindowsDiffusersUnsupported ( launchTarget ) ) {
29662963 _restoreLaunchBtn ( ) ;
29672964 uiModule . showToast ( 'Diffusers serving is not supported on remote Windows servers yet. Use local Windows or a Linux server.' , 9000 ) ;
29682965 return ;
29692966 }
2970-
29712967 // Pre-launch: check our own task list for a serve already running
29722968 // on this host. Offer to stop+launch as the default action — the
29732969 // SSH-based port probe below is more thorough but it can miss
@@ -3596,91 +3592,12 @@ export async function openServePanelForRepo(repo, fields) {
35963592
35973593// ── Fetch cached models from server ──
35983594
3599- function _renderCachedModelsData ( list , data , host ) {
3600- // CHANGELOG: 'ready' already excludes partial downloads;
3601- // show every complete model regardless of size/backend.
3602- const ready = ( data . models || [ ] ) . filter ( m => m . status === 'ready' ) ;
3603-
3604- const downloading = ( data . models || [ ] ) . filter ( m => m . status === 'downloading' ) ;
3605- const allModels = [ ...ready , ...downloading ] ;
3606- _cachedAllModels = allModels ;
3607-
3608- if ( ! allModels . length ) {
3609- if ( ! host ) {
3610- list . innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:6px;text-align:center;"><div>No cached models found</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Docker Local uses Odysseus’s cache in <code>data/huggingface</code>. Download a model here, or copy an existing host HuggingFace cache into that folder once.</div></div>' ;
3611- } else {
3612- list . innerHTML = '<div class="hwfit-loading">No cached models found</div>' ;
3613- }
3614- const tagContainer = document . getElementById ( 'serve-tags' ) ;
3615- if ( tagContainer ) tagContainer . innerHTML = '' ;
3616- return ;
3617- }
3618-
3619- // Auto-detect type + family tags
3620- const _tagMap = { } ;
3621- const _familyMap = { } ;
3622- const _families = [
3623- [ / q w e n / i, 'qwen' ] , [ / l l a m a / i, 'llama' ] , [ / m i s t r a l | m i x t r a l / i, 'mistral' ] ,
3624- [ / d e e p s e e k / i, 'deepseek' ] , [ / g e m m a / i, 'gemma' ] , [ / p h i / i, 'phi' ] ,
3625- [ / m i n i m a x / i, 'minimax' ] , [ / g l m / i, 'glm' ] , [ / f l u x / i, 'flux' ] ,
3626- [ / s t a b l e .? d i f f u s i o n | s d x l / i, 'sd' ] , [ / z - i m a g e / i, 'z-image' ] ,
3627- [ / w h i s p e r / i, 'whisper' ] , [ / c o m m a n d | c o h e r e / i, 'cohere' ] ,
3628- [ / y i - / i, 'yi' ] , [ / i n t e r n / i, 'intern' ] , [ / f a l c o n / i, 'falcon' ] ,
3629- ] ;
3630- for ( const m of allModels ) {
3631- const n = ( m . repo_id || '' ) . toLowerCase ( ) ;
3632- let tag = 'other' ;
3633- if ( m . backend === 'ollama' || m . is_ollama ) tag = 'llm' ;
3634- else if ( m . is_diffusion || / f l u x | s d x l | s t a b l e - d i f f u s i o n | z - i m a g e | q w e n - i m a g e | d i f f u s i o n | d r e a m s h a r / i. test ( n ) ) tag = 'image' ;
3635- else if ( / w h i s p e r | s t t | a s r / i. test ( n ) ) tag = 'stt' ;
3636- else if ( / t t s | c o s y v o i c e | p a r l e r / i. test ( n ) ) tag = 'tts' ;
3637- else if ( / e m b e d | b g e | m i n i l m | e 5 - / i. test ( n ) ) tag = 'embedding' ;
3638- else if ( / l o r a | a d a p t e r / i. test ( n ) ) tag = 'lora' ;
3639- else tag = 'llm' ;
3640- m . _tag = tag ;
3641- _tagMap [ tag ] = ( _tagMap [ tag ] || 0 ) + 1 ;
3642- m . _family = '' ;
3643- for ( const [ re , fam ] of _families ) {
3644- if ( re . test ( n ) ) { m . _family = fam ; _familyMap [ fam ] = ( _familyMap [ fam ] || 0 ) + 1 ; break ; }
3645- }
3646- if ( ( m . backend === 'ollama' || m . is_ollama ) && ! m . _family ) {
3647- m . _family = 'ollama' ;
3648- _familyMap . ollama = ( _familyMap . ollama || 0 ) + 1 ;
3649- }
3650- }
3651-
3652- // Render tag chips
3653- const tagContainer = document . getElementById ( 'serve-tags' ) ;
3654- if ( tagContainer ) {
3655- const tagOrder = [ 'llm' , 'image' , 'lora' , 'embedding' , 'tts' , 'stt' , 'other' ] ;
3656- let tagHtml = `<button class="memory-cat-chip active" data-serve-tag="">All (${ allModels . length } )</button>` ;
3657- for ( const t of tagOrder ) {
3658- if ( ! _tagMap [ t ] ) continue ;
3659- tagHtml += `<button class="memory-cat-chip" data-serve-tag="${ t } ">${ t } (${ _tagMap [ t ] } )</button>` ;
3660- }
3661- const sortedFamilies = Object . entries ( _familyMap ) . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) ;
3662- if ( sortedFamilies . length ) {
3663- for ( const [ fam , count ] of sortedFamilies ) {
3664- const logo = providerLogo ( fam ) ;
3665- const logoHtml = logo ? `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:2px;opacity:0.6;">${ logo } </span>` : '' ;
3666- tagHtml += `<button class="memory-cat-chip" data-serve-tag="fam:${ fam } ">${ logoHtml } ${ fam } (${ count } )</button>` ;
3667- }
3668- }
3669- tagContainer . innerHTML = tagHtml ;
3670- }
3671-
3672- _rerenderCachedModels ( ) ;
3673- }
3674-
3675- export async function _fetchCachedModels ( fresh = false ) {
3595+ export async function _fetchCachedModels ( ) {
36763596 const list = document . getElementById ( 'hwfit-cached-list' ) ;
36773597 if ( ! list ) return ;
36783598
36793599 list . innerHTML = '' ;
3680- const _dlWp = spinnerModule . createWhirlpool ( 22 ) ;
3681- _dlWp . element . classList . add ( 'cookbook-section-loading-wp' ) ;
3682- _dlWp . element . style . width = '22px' ;
3683- _dlWp . element . style . height = '22px' ;
3600+ const _dlWp = spinnerModule . createWhirlpool ( 18 ) ;
36843601 const _dlWrap = document . createElement ( 'div' ) ;
36853602 _dlWrap . className = 'hwfit-loading' ;
36863603 _dlWrap . style . cssText = 'flex-direction:column;gap:6px;' ;
@@ -3742,13 +3659,6 @@ export async function _fetchCachedModels(fresh = false) {
37423659 if ( host ) { qp . set ( 'host' , host ) ; const _sp4 = _getPort ( host ) ; if ( _sp4 ) qp . set ( 'ssh_port' , _sp4 ) ; const _plat = _getPlatform ( host ) ; if ( _plat ) qp . set ( 'platform' , _plat ) ; }
37433660 if ( modelDirs . length ) qp . set ( 'model_dir' , modelDirs . join ( ',' ) ) ;
37443661 const params = qp . toString ( ) ? `?${ qp } ` : '' ;
3745- const scanSig = params || 'local' ;
3746- const cached = fresh ? null : _readCachedModelScan ( scanSig ) ;
3747- if ( cached ) {
3748- _dlWp . destroy ( ) ;
3749- _renderCachedModelsData ( list , cached , host ) ;
3750- return ;
3751- }
37523662 const res = await fetch ( `/api/model/cached${ params } ` ) ;
37533663 if ( ! res . ok ) {
37543664 const body = await res . text ( ) . catch ( ( ) => '' ) ;
@@ -3763,9 +3673,80 @@ export async function _fetchCachedModels(fresh = false) {
37633673 throw new Error ( `HTTP ${ res . status } ${ res . statusText } ${ msg ? `: ${ msg } ` : '' } ` ) ;
37643674 }
37653675 const data = await res . json ( ) ;
3766- _writeCachedModelScan ( scanSig , data ) ;
37673676 _dlWp . destroy ( ) ;
3768- _renderCachedModelsData ( list , data , host ) ;
3677+
3678+ // CHANGELOG: 'ready' already excludes partial downloads;
3679+ // show every complete model regardless of size/backend.
3680+ const ready = data . models . filter ( m => m . status === 'ready' ) ;
3681+
3682+ const downloading = data . models . filter ( m => m . status === 'downloading' ) ;
3683+ const allModels = [ ...ready , ...downloading ] ;
3684+ _cachedAllModels = allModels ;
3685+
3686+ if ( ! allModels . length ) {
3687+ if ( ! host ) {
3688+ list . innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:6px;text-align:center;"><div>No cached models found</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Docker Local uses Odysseus’s cache in <code>data/huggingface</code>. Download a model here, or copy an existing host HuggingFace cache into that folder once.</div></div>' ;
3689+ } else {
3690+ list . innerHTML = '<div class="hwfit-loading">No cached models found</div>' ;
3691+ }
3692+ document . getElementById ( 'serve-tags' ) . innerHTML = '' ;
3693+ return ;
3694+ }
3695+
3696+ // Auto-detect type + family tags
3697+ const _tagMap = { } ;
3698+ const _familyMap = { } ;
3699+ const _families = [
3700+ [ / q w e n / i, 'qwen' ] , [ / l l a m a / i, 'llama' ] , [ / m i s t r a l | m i x t r a l / i, 'mistral' ] ,
3701+ [ / d e e p s e e k / i, 'deepseek' ] , [ / g e m m a / i, 'gemma' ] , [ / p h i / i, 'phi' ] ,
3702+ [ / m i n i m a x / i, 'minimax' ] , [ / g l m / i, 'glm' ] , [ / f l u x / i, 'flux' ] ,
3703+ [ / s t a b l e .? d i f f u s i o n | s d x l / i, 'sd' ] , [ / z - i m a g e / i, 'z-image' ] ,
3704+ [ / w h i s p e r / i, 'whisper' ] , [ / c o m m a n d | c o h e r e / i, 'cohere' ] ,
3705+ [ / y i - / i, 'yi' ] , [ / i n t e r n / i, 'intern' ] , [ / f a l c o n / i, 'falcon' ] ,
3706+ ] ;
3707+ for ( const m of allModels ) {
3708+ const n = ( m . repo_id || '' ) . toLowerCase ( ) ;
3709+ let tag = 'other' ;
3710+ if ( m . backend === 'ollama' || m . is_ollama ) tag = 'llm' ;
3711+ else if ( m . is_diffusion || / f l u x | s d x l | s t a b l e - d i f f u s i o n | z - i m a g e | q w e n - i m a g e | d i f f u s i o n | d r e a m s h a r / i. test ( n ) ) tag = 'image' ;
3712+ else if ( / w h i s p e r | s t t | a s r / i. test ( n ) ) tag = 'stt' ;
3713+ else if ( / t t s | c o s y v o i c e | p a r l e r / i. test ( n ) ) tag = 'tts' ;
3714+ else if ( / e m b e d | b g e | m i n i l m | e 5 - / i. test ( n ) ) tag = 'embedding' ;
3715+ else if ( / l o r a | a d a p t e r / i. test ( n ) ) tag = 'lora' ;
3716+ else tag = 'llm' ;
3717+ m . _tag = tag ;
3718+ _tagMap [ tag ] = ( _tagMap [ tag ] || 0 ) + 1 ;
3719+ m . _family = '' ;
3720+ for ( const [ re , fam ] of _families ) {
3721+ if ( re . test ( n ) ) { m . _family = fam ; _familyMap [ fam ] = ( _familyMap [ fam ] || 0 ) + 1 ; break ; }
3722+ }
3723+ if ( ( m . backend === 'ollama' || m . is_ollama ) && ! m . _family ) {
3724+ m . _family = 'ollama' ;
3725+ _familyMap . ollama = ( _familyMap . ollama || 0 ) + 1 ;
3726+ }
3727+ }
3728+
3729+ // Render tag chips
3730+ const tagContainer = document . getElementById ( 'serve-tags' ) ;
3731+ if ( tagContainer ) {
3732+ const tagOrder = [ 'llm' , 'image' , 'lora' , 'embedding' , 'tts' , 'stt' , 'other' ] ;
3733+ let tagHtml = `<button class="memory-cat-chip active" data-serve-tag="">All (${ allModels . length } )</button>` ;
3734+ for ( const t of tagOrder ) {
3735+ if ( ! _tagMap [ t ] ) continue ;
3736+ tagHtml += `<button class="memory-cat-chip" data-serve-tag="${ t } ">${ t } (${ _tagMap [ t ] } )</button>` ;
3737+ }
3738+ const sortedFamilies = Object . entries ( _familyMap ) . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) ;
3739+ if ( sortedFamilies . length ) {
3740+ for ( const [ fam , count ] of sortedFamilies ) {
3741+ const logo = providerLogo ( fam ) ;
3742+ const logoHtml = logo ? `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:2px;opacity:0.6;">${ logo } </span>` : '' ;
3743+ tagHtml += `<button class="memory-cat-chip" data-serve-tag="fam:${ fam } ">${ logoHtml } ${ fam } (${ count } )</button>` ;
3744+ }
3745+ }
3746+ tagContainer . innerHTML = tagHtml ;
3747+ }
3748+
3749+ _rerenderCachedModels ( ) ;
37693750 } catch ( e ) {
37703751 _dlWp . destroy ( ) ;
37713752 list . innerHTML = `<div class="hwfit-loading">Failed: ${ esc ( e . message ) } </div>` ;
0 commit comments