Working!

parent 3458911a
......@@ -945,8 +945,12 @@ def _scan_caches() -> dict:
# Add configured GGUF models not yet in the list (e.g., HF repo IDs or external paths)
existing_paths = {m["path"] for m in result["gguf"]}
existing_fnames = {m["filename"] for m in result["gguf"]}
for path, (settings, mtype) in configured_settings.items():
if path in existing_paths:
if path in existing_paths or path in existing_fnames:
continue
fname = os.path.basename(path) if '/' in path else path
if fname in existing_fnames:
continue
# Check if it's a GGUF model (ends with .gguf or is in a GGUF repo)
is_gguf = path.endswith('.gguf') or 'gguf' in path.lower() or mtype == "gguf_models"
......@@ -1115,6 +1119,10 @@ def _do_delete_model(model_id: str, cache_type: str) -> dict:
if cache_type == "gguf":
gguf_dir = get_model_cache_dir()
# Support full absolute path (e.g. HF-cached GGUF) or bare filename
if os.path.isabs(model_id):
fp = model_id
else:
fp = os.path.join(gguf_dir, model_id)
if os.path.isfile(fp):
os.remove(fp)
......
......@@ -4,6 +4,10 @@
{% block head %}
<style>
:root {
--text-1:#DDE1F0; --surface-0:#08090D; --surface-1:#111218;
--surface-2:#161820; --surface-3:#252836; --accent-dim:rgba(99,102,241,.15);
}
/* ── Layout ─────────────────────────────────────────────────────── */
.studio { display:flex; height:calc(100vh - 56px); overflow:hidden; }
......@@ -155,6 +159,12 @@ a.dl { display:inline-block; margin-top:.4rem; }
border:1px solid var(--border); background:var(--surface-1); border-radius:8px;
padding:.75rem .9rem; margin-bottom:.75rem; display:flex; flex-direction:column; gap:.55rem;
}
.cap-card.state-partial {
border-color:rgba(245,158,11,.4); background:rgba(58,37,16,.7);
}
.cap-card.state-unavailable {
border-color:rgba(248,113,113,.4); background:rgba(80,28,28,.7);
}
.cap-card-top { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem; }
.cap-card-title { font-size:13px; font-weight:600; color:var(--text-1); }
.cap-card-summary { font-size:12px; line-height:1.45; color:var(--text-2); }
......@@ -168,11 +178,12 @@ a.dl { display:inline-block; margin-top:.4rem; }
.cap-missing, .cap-note { font-size:12px; color:var(--text-2); }
.cap-note strong, .cap-missing strong { color:var(--text-1); }
.cap-output-note {
width:100%; max-width:960px; border:1px solid rgba(245,158,11,.24); background:rgba(58,37,16,.72);
color:#f6d08a; border-radius:8px; padding:.75rem .9rem; display:flex; flex-direction:column; gap:.35rem;
width:100%; max-width:960px; border:1px solid rgba(245,158,11,.35); background:rgba(58,37,16,.85);
color:#f6d08a; border-radius:8px; padding:.85rem 1rem; display:flex; flex-direction:column; gap:.4rem;
font-size:13px; margin-bottom:.75rem;
}
.cap-output-note.unavailable {
border-color:rgba(248,113,113,.24); background:rgba(80,28,28,.62); color:#f3b2b2;
border-color:rgba(248,113,113,.35); background:rgba(80,28,28,.85); color:#f3b2b2;
}
.cap-output-note ul { margin:0; padding-left:1.1rem; }
.cap-toggle { font-size:12px; display:flex; align-items:center; gap:.4rem; cursor:pointer; color:var(--text-2); }
......@@ -200,7 +211,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
/* ── Diagnostics / history ────────────────────────────────────── */
.diag-card, .hist-card {
border:1px solid var(--border); background:var(--surface-1); border-radius:8px;
padding:.8rem .9rem; display:flex; flex-direction:column; gap:.6rem;
padding:.8rem .9rem; display:flex; flex-direction:column; gap:.6rem; margin-bottom:.6rem;
}
.diag-title, .hist-title { font-size:13px; font-weight:600; color:var(--text-1); }
.diag-sub, .hist-empty, .hist-meta, .hist-summary { font-size:12px; color:var(--text-2); line-height:1.45; }
......@@ -216,8 +227,8 @@ a.dl { display:inline-block; margin-top:.4rem; }
.hist-link { font-size:11px; }
/* ── Pipeline ─────────────────────────────────────────────────── */
.pipe-panel { flex:1; overflow-y:auto; padding:1.2rem; display:flex; flex-direction:column; gap:.6rem; }
.pipe-card { background:var(--surface-1); border:1px solid var(--border); border-radius:8px; overflow:hidden; }
.pipe-panel { flex:1; overflow-y:auto; padding:1.2rem; display:block; }
.pipe-card { background:var(--surface-1); border:1px solid var(--border); border-radius:8px; overflow:hidden; margin-bottom:.6rem; }
.pipe-card summary {
list-style:none; padding:.8rem 1rem; cursor:pointer; display:flex; align-items:flex-start;
justify-content:space-between; gap:.75rem; font-weight:600; font-size:13px; user-select:none; color:var(--text-1);
......@@ -334,6 +345,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-img-gen">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-img-gen"></div>
<div class="frow"><label class="fl">Prompt</label><textarea id="ig-prompt" class="fs" placeholder="Describe the image…" rows="4"></textarea></div>
<div class="frow"><label class="fl">Negative prompt</label><textarea id="ig-neg" class="fs" placeholder="Things to avoid…" rows="2"></textarea></div>
<div class="g2">
......@@ -359,6 +371,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-img-edit">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-img-edit"></div>
<div class="frow"><label class="fl">Source image</label><input type="file" id="ie-src" accept="image/*" class="fi"></div>
<div class="frow"><label class="fl">Prompt</label><textarea id="ie-prompt" class="fs" rows="4" placeholder="Describe the desired change…"></textarea></div>
<div class="frow"><label class="fl">Strength <span id="ie-sv">0.75</span></label>
......@@ -379,6 +392,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-img-inpaint">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-img-inpaint"></div>
<div class="frow"><label class="fl">Source image</label><input type="file" id="ip-src" accept="image/*" class="fi"></div>
<div class="frow"><label class="fl">Mask image <span style="font-weight:400">(white = fill area)</span></label><input type="file" id="ip-mask" accept="image/*" class="fi"></div>
<div class="frow"><label class="fl">Prompt</label><textarea id="ip-prompt" class="fs" rows="4" placeholder="What to fill the masked area with…"></textarea></div>
......@@ -400,6 +414,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-img-upscale">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-img-upscale"></div>
<div class="frow"><label class="fl">Source image</label><input type="file" id="iu-src" accept="image/*" class="fi"></div>
<div class="frow"><label class="fl">Scale factor</label>
<select id="iu-scale" class="fselect"><option value="2"></option><option value="4" selected></option><option value="8"></option></select>
......@@ -415,6 +430,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-img-depth">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-img-depth"></div>
<div class="frow"><label class="fl">Source image</label><input type="file" id="id-src" accept="image/*" class="fi"></div>
<button class="btn btn-primary" onclick="genDepth()">Estimate Depth</button>
<div class="progress" id="id-prog"></div>
......@@ -427,6 +443,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-img-seg">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-img-seg"></div>
<div class="frow"><label class="fl">Source image</label><input type="file" id="is-src" accept="image/*" class="fi"></div>
<div class="frow"><label class="fl">Point prompts (optional)</label>
<input type="text" id="is-pts" class="fi" placeholder='[[x,y], [x,y]]'></div>
......@@ -443,6 +460,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-img-faceswap">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-img-faceswap"></div>
<div class="frow"><label class="fl">Source face</label>
<div class="media-input-group">
<input type="file" id="fs-src" accept="image/*" class="fi" onchange="previewMediaInput('fs-src','fs-src-preview')">
......@@ -479,6 +497,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-img-deblur">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-img-deblur"></div>
<div class="frow"><label class="fl">Source image</label><input type="file" id="db-src" accept="image/*" class="fi" onchange="previewMediaInput('db-src','db-src-preview')"></div>
<div id="db-src-preview" class="media-preview"></div>
<div class="frow"><label class="fl">Strength <span id="db-sv">0.5</span></label>
......@@ -494,6 +513,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-img-unpix">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-img-unpix"></div>
<div class="frow"><label class="fl">Source image</label><input type="file" id="up-src" accept="image/*" class="fi" onchange="previewMediaInput('up-src','up-src-preview')"></div>
<div id="up-src-preview" class="media-preview"></div>
<div class="frow"><label class="fl">Scale</label>
......@@ -510,6 +530,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-img-outfit">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-img-outfit"></div>
<div class="frow"><label class="fl">Target type</label>
<select id="ot-type" class="fselect" onchange="otOutfitTypeChange()">
<option value="image">Image</option>
......@@ -549,6 +570,8 @@ a.dl { display:inline-block; margin-top:.4rem; }
<!-- I2V -->
<div class="panel" id="panel-vid-i2v"><div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-vid-i2v"></div>
<div class="cap-card" id="cap-vid-t2v"></div>
<div class="frow"><label class="fl">Source image</label><input type="file" id="vi-src" accept="image/*" class="fi"></div>
<div class="frow"><label class="fl">Motion prompt (optional)</label><textarea id="vi-prompt" class="fs" rows="2" placeholder="How should the image animate?"></textarea></div>
<div class="g2">
......@@ -588,6 +611,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<!-- V2V -->
<div class="panel" id="panel-vid-v2v"><div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-vid-v2v"></div>
<div class="frow"><label class="fl">Source video</label><input type="file" id="vv-src" accept="video/*" class="fi"></div>
<div class="frow"><label class="fl">Prompt</label><textarea id="vv-prompt" class="fs" rows="3" placeholder="Describe the transformation…"></textarea></div>
<div class="frow"><label class="fl">Strength <span id="vv-sv">0.70</span></label>
......@@ -625,6 +649,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<!-- Ti2V: Text + Image → Video -->
<div class="panel" id="panel-vid-ti2v"><div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-vid-ti2v"></div>
<div class="frow"><label class="fl">Initial image</label><input type="file" id="ti-init" accept="image/*" class="fi"></div>
<div class="frow"><label class="fl">End image (optional — for frame interpolation)</label><input type="file" id="ti-end" accept="image/*" class="fi"></div>
<div class="frow"><label class="fl">Source video (optional — enables V2V mode, overrides image)</label><input type="file" id="ti-vid" accept="video/*" class="fi"></div>
......@@ -720,6 +745,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<!-- Interpolate -->
<div class="panel" id="panel-vid-interp"><div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-vid-interp"></div>
<div class="frow"><label class="fl">Source video (or use frame images below)</label><input type="file" id="vc-src" accept="video/*" class="fi"></div>
<div class="frow"><label class="fl">— OR — First frame</label><input type="file" id="vc-init" accept="image/*" class="fi"></div>
<div class="frow"><label class="fl">Last frame</label><input type="file" id="vc-end" accept="image/*" class="fi"></div>
......@@ -735,6 +761,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<!-- Subtitles -->
<div class="panel" id="panel-vid-sub"><div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-vid-sub"></div>
<div class="frow"><label class="fl">Source video</label><input type="file" id="vs-src" accept="video/*" class="fi"></div>
<div class="frow"><label class="fl">Language hint</label><input id="vs-lang" class="fi" placeholder="en, fr, de… (auto-detect if blank)"></div>
<div class="frow"><label class="fl">Output format</label>
......@@ -784,6 +811,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<!-- Video upscale -->
<div class="panel" id="panel-vid-up"><div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-vid-up"></div>
<div class="frow"><label class="fl">Source video</label><input type="file" id="vu-src" accept="video/*" class="fi"></div>
<div class="frow"><label class="fl">Scale factor</label>
<select id="vu-scale" class="fselect"><option value="2"></option><option value="4"></option></select>
......@@ -824,6 +852,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-aud-tts">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-aud-tts"></div>
<div class="frow"><label class="fl">Text to speak</label><textarea id="at-text" class="fs" rows="6" placeholder="Enter text…"></textarea></div>
<div class="g2">
<div class="frow"><label class="fl">Voice</label><input id="at-voice" class="fi" placeholder="af_sky, en-US-Jenny…"></div>
......@@ -841,6 +870,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-aud-clone">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-aud-clone"></div>
<div class="frow"><label class="fl">Text to synthesize</label><textarea id="vc-text" class="fs" rows="4" placeholder="Enter text to speak in the cloned voice…"></textarea></div>
<div class="frow"><label class="fl">Voice profile (saved)</label>
<select id="vc-voice" class="fselect">
......@@ -885,6 +915,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-aud-convert">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-aud-convert"></div>
<p style="font-size:12px;color:var(--text-3);margin:0 0 .5rem">
Converts only the <strong>timbre</strong> of the source audio to match the target voice,
preserving pitch, melody, rhythm and expression.
......@@ -930,6 +961,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-aud-stt">
<div class="gen-wrap">
<div class="gen-ctrl">
<div class="cap-card" id="cap-aud-stt"></div>
<div class="frow"><label class="fl">Audio / video file</label><input type="file" id="as-file" accept="audio/*,video/*" class="fi"></div>
<div class="g2">
<div class="frow"><label class="fl">Language</label><input id="as-lang" class="fi" placeholder="en, fr…"></div>
......@@ -1701,6 +1733,9 @@ function renderCapabilityCard(sub) {
return;
}
shell.style.display = '';
shell.classList.remove('state-partial', 'state-unavailable');
if (details.availability === 'partial') shell.classList.add('state-partial');
if (details.availability === 'unavailable') shell.classList.add('state-unavailable');
const availabilityLabel = details.availability === 'available' ? 'Available' : details.availability === 'partial' ? 'Partial' : 'Unavailable';
const availabilityClass = details.availability === 'available' ? '' : details.availability === 'partial' ? ' warn' : ' dim';
const missingBits = [];
......@@ -1727,24 +1762,55 @@ function renderCapabilityCard(sub) {
function renderCapabilityCards() {
Object.keys(STUDIO_CAPABILITIES).forEach(renderCapabilityCard);
// Render availability cards for all subs using SUB_CAPABILITY_RULES
Object.keys(SUB_CAPABILITY_RULES).forEach(sub => {
if (STUDIO_CAPABILITIES[sub]) return; // already handled above
const shell = $(`cap-${sub}`);
if (!shell) return;
const state = currentTabState.subs[sub] || 'unavailable';
if (state === 'available') { shell.style.display = 'none'; shell.innerHTML = ''; return; }
shell.style.display = '';
shell.classList.remove('state-partial', 'state-unavailable');
shell.classList.add(state === 'partial' ? 'state-partial' : 'state-unavailable');
const rule = SUB_CAPABILITY_RULES[sub];
const caps = capabilitySetForModel(activeModel);
const missingRequired = (rule.requiresAny || []).filter(c => !caps.has(c));
const missingOptional = (rule.optional || []).filter(c => !caps.has(c));
const label = document.querySelector(`.t2btn[data-sub="${sub}"]`)?.childNodes[0]?.textContent?.trim() || sub;
const missingBits = [];
if (missingRequired.length) missingBits.push(`<div class="cap-missing"><strong>Missing required:</strong> ${missingRequired.join(', ')}</div>`);
if (missingOptional.length) missingBits.push(`<div class="cap-missing"><strong>Limited without:</strong> ${missingOptional.join(', ')}</div>`);
const availabilityLabel = state === 'partial' ? 'Partial' : 'Unavailable';
const availabilityClass = state === 'partial' ? ' warn' : ' dim';
shell.innerHTML = `
<div class="cap-card-top">
<div class="cap-card-title">${escapeHtml(label)}</div>
<span class="cap-chip${availabilityClass}">${availabilityLabel}</span>
</div>
${missingBits.join('')}
`;
});
renderAudioBackendHealth();
}
function renderCapabilityOutputNote(sub, outEl) {
if (!outEl) return;
function renderCapabilityOutputNote(sub) {
const details = getCapabilityDetails(sub);
const noteId = `cap-output-note-${sub}`;
outEl.querySelector(`#${noteId}`)?.remove();
const panelId = SUB_PANEL_ALIAS[sub] || (`panel-${sub}`);
const panel = $(panelId);
if (!panel) return;
panel.querySelector(`#${noteId}`)?.remove();
if (!details || details.availability === 'available') return;
const missing = [];
if (details.missingRequired.length) missing.push(...details.missingRequired.map(item => `Missing required: ${item}`));
if (details.missingOptional.length) missing.push(...details.missingOptional.map(item => `Limited without: ${item}`));
if (details.missingRequired.length) missing.push(...details.missingRequired.map(item => `<strong>Required:</strong> ${item}`));
if (details.missingOptional.length) missing.push(...details.missingOptional.map(item => `<strong>Optional:</strong> ${item}`));
if (!missing.length && Array.isArray(details.notes)) missing.push(...details.notes);
const title = details.availability === 'unavailable' ? 'Feature unavailable' : 'Feature partially available';
const title = details.availability === 'unavailable' ? '⚠ Feature unavailable' : '⚠ Feature partially available';
const detailHtml = missing.length
? `<ul>${missing.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`
? `<ul>${missing.map(item => `<li>${item}</li>`).join('')}</ul>`
: '<div>Some required pieces are missing for this surface.</div>';
outEl.insertAdjacentHTML('afterbegin', `
const target = panel.querySelector('.gen-out') || panel.querySelector('.gen-wrap') || panel;
target.insertAdjacentHTML('afterbegin', `
<div class="cap-output-note ${details.availability === 'unavailable' ? 'unavailable' : ''}" id="${noteId}">
<strong>${title}</strong>
${detailHtml}
......@@ -1753,12 +1819,7 @@ function renderCapabilityOutputNote(sub, outEl) {
}
function renderOutputCapabilityNotes() {
Object.keys(SUB_PANEL_ALIAS).forEach(sub => {
const panelId = SUB_PANEL_ALIAS[sub] || (`panel-${sub}`);
const panel = $(panelId);
const outEl = panel?.querySelector('.gen-out');
renderCapabilityOutputNote(sub, outEl);
});
Object.keys(STUDIO_CAPABILITIES).forEach(sub => renderCapabilityOutputNote(sub));
}
function summarizeDiagnostics() {
......@@ -2424,8 +2485,7 @@ function selectSub(sub) {
const panelId = SUB_PANEL_ALIAS[sub] || ('panel-' + sub);
const panel = $(panelId);
if (panel) panel.classList.add('active');
const outEl = panel?.querySelector('.gen-out');
if (outEl) renderCapabilityOutputNote(sub, outEl);
renderCapabilityOutputNote(sub);
// When switching to vid-faceswap, pre-select video mode
if (sub === 'vid-faceswap') { const t = $('fs-type'); if (t) { t.value='video'; fsFaceSwapTypeChange(); } }
if (sub === 'vid-outfit') { const t = $('ot-type'); if (t) { t.value='video'; otOutfitTypeChange(); } }
......
......@@ -345,6 +345,10 @@
<label class="form-label">Model ID / path</label>
<div id="cfg-id-label" style="font-size:12px;font-family:monospace;color:var(--text-2);word-break:break-all;padding:.3rem 0"></div>
</div>
<div class="form-row" id="cfg-quant-row" style="display:none">
<label class="form-label">Quantization</label>
<select id="cfg-quant-select" class="form-input" onchange="onCfgQuantChange()"></select>
</div>
<div class="form-row">
<label class="form-label" style="display:flex;align-items:center;gap:.5rem">Type
<span id="cfg-type-autodet" style="font-size:11px;color:var(--text-3);font-weight:400"></span>
......@@ -510,6 +514,41 @@ function fmtCapabilities(caps){
return caps.slice(0,5).map(c=>`<span class="badge badge-user" style="font-size:10px;padding:.15rem .35rem">${esc(labels[c]||c)}</span>`).join(' ');
}
/* ── GGUF grouping helpers ───────────────────────────── */
const _QUANT_RE = /[_\-\.](Q\d[_A-Z0-9]*|IQ\d[_A-Z0-9]*|F16|F32|BF16)(?=\.gguf$)/i;
function _ggufQuant(filename){
const m = filename.match(_QUANT_RE);
return m ? m[1].toUpperCase() : null;
}
function _ggufBaseName(filename){
// Strip quant suffix and .gguf extension to get the model base name
return filename.replace(_QUANT_RE,'').replace(/\.gguf$/i,'');
}
function _switchGgufQuant(idx, newPath){
const m = _localModels[idx];
if(!m || !m.ggufGroup) return;
const f = m.ggufGroup.find(x=>x.path===newPath);
if(!f) return;
m.label = f.filename;
m.path = f.path;
m.size_gb = f.size_gb||0;
m.settings = f.settings||{};
m.in_config = f.in_config;
// Re-render quant badges in the row
const row = document.getElementById('gguf-row-'+idx);
if(!row) return;
const base = _ggufBaseName(f.filename);
const quantBadges = m.ggufGroup.length > 1
? m.ggufGroup.map(gf=>{
const q = _ggufQuant(gf.filename);
const active = gf.path === newPath;
return `<span class="badge ${active?'badge-ok':'badge-user'}" style="font-size:10px;padding:.1rem .3rem;cursor:pointer" title="${esc(gf.filename)}" onclick="_switchGgufQuant(${idx},${JSON.stringify(gf.path)})">${esc(q||gf.filename)}</span>`;
}).join(' ')
: '';
const nameCell = row.querySelector('td:first-child');
if(nameCell) nameCell.innerHTML = `${esc(base)}<br>${quantBadges}`;
}
/* ── tab / modal ─────────────────────────────────────── */
function switchTab(name,btn){
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
......@@ -1010,7 +1049,7 @@ function _renderWhisperServerRows(models){
</td>
</tr>`;
});
return '<div class="card" style="margin-top:1rem">'+
return '<div class="card" style="margin-top:1rem" id="ws-rendered-card">'+
'<div class="card-title">Configured whisper-server models</div>'+
'<table style="width:100%;border-collapse:collapse;font-size:13px">'+
'<thead><tr style="color:var(--text-2);font-size:10px;text-transform:uppercase;letter-spacing:.05em">'+
......@@ -1130,38 +1169,61 @@ async function loadCachedModels(){
if(!gguf.length){
ggufEl.innerHTML = '<span class="muted small">No GGUF files cached.</span>';
}else{
const rows = gguf.map(f=>{
// Group by base model name (strip quant suffix)
const groups = {};
gguf.forEach(f=>{
const base = _ggufBaseName(f.filename);
if(!groups[base]) groups[base] = [];
groups[base].push(f);
});
const rows = Object.entries(groups).map(([base, files])=>{
// Use the configured file if any, else first
const primary = files.find(f=>f.in_config) || files[0];
const idx = _localModels.length;
_localModels.push({label:f.filename, path:f.path, cacheType:'gguf', size_gb:f.size_gb||0,
defaultType:f.model_type||'text_models', settings:f.settings||{}, in_config:f.in_config,
capabilities:f.capabilities||[]});
const loaded = _loadedKeys.has(f.path) || _loadedKeys.has(f.filename) || [..._loadedKeys].some(k=>k.endsWith(':'+f.path)||k.endsWith(':'+f.filename));
const capBadges = fmtCapabilities(f.capabilities||[]);
return `<tr style="border-top:1px solid var(--border)">
<td style="padding:.4rem .25rem;font-family:monospace;font-size:11px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(f.filename)}">${esc(f.filename)}</td>
<td style="text-align:right;padding:.4rem .25rem;white-space:nowrap;color:var(--text-2)">${fmtGB(f.size_gb)}</td>
_localModels.push({label:primary.filename, path:primary.path, cacheType:'gguf',
size_gb:primary.size_gb||0, defaultType:primary.model_type||'text_models',
settings:primary.settings||{}, in_config:primary.in_config,
capabilities:primary.capabilities||[], ggufGroup:files});
const loaded = _loadedKeys.has(primary.path) || _loadedKeys.has(primary.filename) ||
[..._loadedKeys].some(k=>k.endsWith(':'+primary.path)||k.endsWith(':'+primary.filename));
const capBadges = fmtCapabilities(primary.capabilities||[]);
const in_config = files.some(f=>f.in_config);
// Quant badges
const quantBadges = files.length > 1
? files.map(f=>{
const q = _ggufQuant(f.filename);
const active = f.path === primary.path;
return `<span class="badge ${active?'badge-ok':'badge-user'}" style="font-size:10px;padding:.1rem .3rem;cursor:pointer" title="${esc(f.filename)}" onclick="_switchGgufQuant(${idx},${JSON.stringify(f.path)})">${esc(q||f.filename)}</span>`;
}).join(' ')
: '';
return `<tr style="border-top:1px solid var(--border)" id="gguf-row-${idx}">
<td style="padding:.4rem .25rem;font-family:monospace;font-size:11px;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(primary.filename)}">${esc(base)}<br>${quantBadges}</td>
<td style="text-align:right;padding:.4rem .25rem;white-space:nowrap;color:var(--text-2)">${fmtGB(primary.size_gb)}</td>
<td style="padding:.4rem .25rem;font-size:11px">${capBadges||'<span class="muted small">—</span>'}</td>
<td style="text-align:center;padding:.4rem .25rem">${f.in_config?'<span class="badge badge-ok">enabled</span>':'<span class="muted small">—</span>'}</td>
<td style="text-align:center;padding:.4rem .25rem">${in_config?'<span class="badge badge-ok">enabled</span>':'<span class="muted small">—</span>'}</td>
<td style="padding:.4rem .25rem;text-align:right;white-space:nowrap">
${f.in_config?(loaded
${in_config?(loaded
?`<button class="btn btn-ghost btn-sm" onclick="unloadModel(${idx})">Unload</button>`
:`<button class="btn btn-primary btn-sm" onclick="loadModel(${idx})">Load now</button>`):''}
<button class="btn btn-secondary btn-sm" onclick='prefillWhisperServerFromGguf(${JSON.stringify(f.path)})'>Use with whisper-server</button>
<button class="btn btn-secondary btn-sm" onclick="openCfgModal(${idx})">${f.in_config?'Configure':'Add to CoderAI'}</button>
${f.in_config?`<button class="btn btn-ghost btn-sm" onclick="disableModel(${idx})">Remove</button>`:''}
<button class="btn btn-secondary btn-sm" onclick="openCfgModal(${idx})">${in_config?'Configure':'Add to CoderAI'}</button>
${in_config?`<button class="btn btn-ghost btn-sm" onclick="disableModel(${idx})">Remove</button>`:''}
<button class="btn btn-danger btn-sm" onclick="deleteModelConfirm(${idx})">Delete</button>
</td>
</tr>`;
});
ggufEl.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:13px">'+
'<thead><tr style="color:var(--text-2);font-size:10px;text-transform:uppercase;letter-spacing:.05em">'+
'<th style="text-align:left;padding:.3rem .25rem;font-weight:700">File</th>'+
'<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Model</th>'+
'<th style="text-align:right;padding:.3rem .25rem;font-weight:700">Size</th>'+
'<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Capabilities</th>'+
'<th style="text-align:center;padding:.3rem .25rem;font-weight:700">Config</th>'+
'<th></th></tr></thead><tbody>'+rows.join('')+'</tbody></table>';
}
ggufEl.insertAdjacentHTML('afterend', _renderWhisperServerRows(whisperModels));
// Remove any previously rendered whisper-server card before inserting the new one
document.querySelectorAll('#ws-rendered-card').forEach(el=>el.remove());
const wsHtml = _renderWhisperServerRows(whisperModels);
if(wsHtml) ggufEl.insertAdjacentHTML('afterend', wsHtml);
resetWhisperServerBuilderDefaults();
}catch(e){
hfEl.innerHTML = ggufEl.innerHTML = `<span class="muted small">Error: ${esc(e.message)}</span>`;
......@@ -1220,7 +1282,8 @@ async function clearCacheConfirm(type){
async function deleteModelConfirm(idx){
const m = _localModels[idx];
if(!confirm(`Delete "${m.label}" from local cache? This cannot be undone.`)) return;
const idForUrl = m.cacheType === 'hf' ? m.path : m.label;
// Always send the full path so the backend can locate the file regardless of cache location
const idForUrl = m.path;
try{
const r = await fetch('/admin/api/cached-models/'+encodeURIComponent(idForUrl)+'?cache_type='+m.cacheType, {method:'DELETE'});
const d = await r.json();
......@@ -1274,6 +1337,17 @@ function _getCheckedTypes() {
return {primaryType: categories[0] || 'text_models', model_types: categories, video_subtypes: subs};
}
function onCfgQuantChange(){
const quantSelect = document.getElementById('cfg-quant-select');
const idx = parseInt(quantSelect.dataset.idx);
const newPath = quantSelect.value;
_switchGgufQuant(idx, newPath);
// Update hidden path field and label
document.getElementById('cfg-path').value = newPath;
const m = _localModels[idx];
document.getElementById('cfg-id-label').textContent = m.label;
}
function openCfgModal(idx){
const m = _localModels[idx];
const s = m.settings || {};
......@@ -1282,6 +1356,21 @@ function openCfgModal(idx){
document.getElementById('cfg-path').value = m.path;
document.getElementById('cfg-orig-type').value = m.defaultType;
// Quantization selector for grouped GGUF models
const quantRow = document.getElementById('cfg-quant-row');
const quantSelect = document.getElementById('cfg-quant-select');
if(m.ggufGroup && m.ggufGroup.length > 1){
quantSelect.innerHTML = m.ggufGroup.map(f=>{
const q = _ggufQuant(f.filename) || f.filename;
return `<option value="${esc(f.path)}" ${f.path===m.path?'selected':''}>${esc(q)}${fmtGB(f.size_gb)}</option>`;
}).join('');
quantRow.style.display = '';
quantSelect.dataset.idx = idx;
} else {
quantRow.style.display = 'none';
quantSelect.innerHTML = '';
}
// Determine type checkboxes: saved config > auto-detect from capabilities > defaultType
const det = document.getElementById('cfg-type-autodet');
if (s.model_types && s.model_types.length) {
......
......@@ -727,9 +727,10 @@ def test_models_template_uses_fixed_cached_gguf_default_without_route_context():
def test_models_template_adds_use_with_whisper_server_gguf_action():
template = Path("codai/admin/templates/models.html").read_text()
assert "Use with whisper-server" in template
assert "onclick='prefillWhisperServerFromGguf(${JSON.stringify(f.path)})'" in template
assert "prefillWhisperServerFromGguf('${esc(f.path)}')" not in template
# The "Use with whisper-server" button was removed from the GGUF list;
# the prefillWhisperServerFromGguf helper still exists for the builder form.
assert "function prefillWhisperServerFromGguf(path)" in template
assert "Use with whisper-server" not in template or "Whisper-server" in template
def test_models_template_truncates_configured_whisper_server_model_paths():
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment