{% extends "base.html" %}
{% block wrapper_class %}container container--full{% endblock %}
{% block title %}Models — CoderAI{% endblock %}

{% block head %}
<style>
/* chips */
.chip{display:inline-flex;align-items:center;padding:.2rem .55rem;border-radius:20px;cursor:pointer;background:var(--raised);border:1px solid var(--border);font-size:11px;font-weight:500;user-select:none;white-space:nowrap;transition:background .1s,border-color .1s,color .1s}
.chip:hover{border-color:var(--border-2)}
.chip.on{background:var(--accent-s);border-color:rgba(99,102,241,.35);color:#A5B4FC}
.chip-row{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center}
/* gguf 3-way toggle */
.tog-grp{display:inline-flex;border:1px solid var(--border);border-radius:6px;overflow:hidden}
.tog-btn{border:none!important;border-radius:0!important;background:var(--raised)!important;color:var(--text-2)!important;font-size:11px!important;padding:.25rem .65rem!important;cursor:pointer;font-weight:500;transition:all .1s}
.tog-btn:hover{color:var(--text)!important}
.tog-btn.on{background:var(--accent-s)!important;color:#A5B4FC!important}
.tog-btn+.tog-btn{border-left:1px solid var(--border)!important}
/* filter label */
.fl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-2);white-space:nowrap}
/* info drawer */
#info-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:200}
#info-drawer{position:fixed;top:0;right:0;width:540px;max-width:96vw;height:100vh;background:var(--bg);border-left:1px solid var(--border);z-index:201;overflow-y:auto;transform:translateX(100%);transition:transform .22s cubic-bezier(.4,0,.2,1)}
#info-drawer.open{transform:translateX(0)}
#info-sticky{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--border);z-index:1;padding:1rem 1.25rem;display:flex;align-items:center;gap:.75rem}
#info-title{font-weight:600;font-size:14px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
._dl-quant-row:hover{background:var(--raised)}
</style>
{% endblock %}

{% block content %}
<script>
window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }};
</script>
<div class="page-header">
  <div>
    <h1>Models</h1>
    <p>Configure and download AI models</p>
  </div>
  <div class="header-actions">
    <button class="btn btn-secondary" onclick="openModal('upload-modal')">Upload GGUF</button>
    <button class="btn btn-primary" onclick="openDownloadFor('')">Download model</button>
  </div>
</div>

<div class="tabs">
  <button class="tab active" onclick="switchTab('local', this)">Local models</button>
  <button class="tab" onclick="switchTab('search', this)">Find on HuggingFace</button>
</div>

<!-- active downloads strip (all tabs) -->
<div id="dl-strip" style="display:none;background:var(--raised);border:1px solid var(--border);border-radius:8px;margin-bottom:1rem;padding:.5rem .875rem">
  <div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-2);margin-bottom:.4rem">Active downloads</div>
  <div id="dl-strip-list"></div>
</div>

<!-- LOCAL -->
<div id="tab-local" class="tab-panel active">

  <!-- cache stats -->
  <div class="card">
    <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem">
      <div class="card-title" style="margin:0">Storage</div>
      <div style="display:flex;gap:.35rem;flex-wrap:wrap">
        <button class="btn btn-ghost btn-sm" onclick="refreshLocal()">↺ Refresh</button>
        <button class="btn btn-danger btn-sm" onclick="clearCacheConfirm('hf')">Clear HF</button>
        <button class="btn btn-danger btn-sm" onclick="clearCacheConfirm('gguf')">Clear GGUF</button>
        <button class="btn btn-danger btn-sm" onclick="clearCacheConfirm('all')">Clear all</button>
      </div>
    </div>
    <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:.75rem" id="cache-stats-row">
      <div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.75rem 1rem">
        <div class="fl">HuggingFace</div>
        <div id="stat-hf-size" style="font-size:1.2rem;font-weight:600;margin:.3rem 0">—</div>
        <div id="stat-hf-count" class="muted small">— models</div>
        <div id="stat-hf-free" class="muted small" style="margin-top:.25rem">— free</div>
      </div>
      <div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.75rem 1rem">
        <div class="fl">GGUF cache</div>
        <div id="stat-gguf-size" style="font-size:1.2rem;font-weight:600;margin:.3rem 0">—</div>
        <div id="stat-gguf-count" class="muted small">— files</div>
        <div id="stat-gguf-free" class="muted small" style="margin-top:.25rem">— free</div>
      </div>
      <div style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.75rem 1rem">
        <div class="fl">Total used</div>
        <div id="stat-total-size" style="font-size:1.2rem;font-weight:600;margin:.3rem 0">—</div>
        <div class="muted small">on disk</div>
      </div>
    </div>
  </div>

  <!-- name filter (applies to HF models, GGUF files and components) -->
  <div class="card" style="padding-top:.75rem;padding-bottom:.75rem">
    <div style="display:flex;align-items:center;gap:.5rem">
      <span class="fl">Filter</span>
      <input type="text" id="local-filter" class="form-input" placeholder="Filter models by name…" style="flex:1" oninput="applyLocalFilter()">
      <button class="btn btn-ghost btn-sm" onclick="document.getElementById('local-filter').value='';applyLocalFilter()">Clear</button>
    </div>
  </div>

  <!-- HF models -->
  <div class="card">
    <div class="card-title">HuggingFace models <span id="hf-model-badge" class="muted small"></span></div>
    <div id="hf-models-list"><span class="muted small">Loading…</span></div>
  </div>

  <!-- GGUF files -->
  <div class="card mb-0">
    <div class="card-title">GGUF files <span id="gguf-file-badge" class="muted small"></span></div>
    <div id="gguf-models-list"><span class="muted small">Loading…</span></div>
  </div>

  <!-- Component files -->
  <div class="card" style="margin-top:1rem">
    <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:.5rem">
      <div class="card-title" style="margin:0">Component files <span id="comp-badge" class="muted small"></span></div>
      <button class="btn btn-secondary btn-sm" onclick="openDownloadFor('')">Download</button>
    </div>
    <p class="muted small" style="margin-top:0;margin-bottom:.75rem">VAEs, text encoders (T5-XXL, CLIP), and other standalone files used alongside main models.</p>
    <div id="comp-list"><span class="muted small">Loading…</span></div>
  </div>

  <div class="card mb-0" style="margin-top:1rem" id="ws-model-builder">
    <div class="card-title">Whisper-server simulated models</div>
    <p class="muted small" style="margin-top:0">Create local audio models backed by dedicated whisper-server subprocess configurations.</p>
    <div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem">
      <input id="ws-model-id" class="form-input" placeholder="whisper-vulkan-base">
      <input id="ws-server-path" class="form-input" placeholder="/usr/local/bin/whisper-server">
      <select id="ws-model-source" class="form-input" onchange="toggleWhisperModelSource()">
        <option value="cached-gguf" selected>Downloaded GGUF</option>
        <option value="manual-path">Manual path</option>
      </select>
      <select id="ws-gguf-select" class="form-input">
        <option value="">Select downloaded GGUF</option>
      </select>
      <input id="ws-model-path" class="form-input" placeholder="Manual path: /models/ggml-base.bin" style="display:none">
      <input id="ws-port" class="form-input" type="number" value="8744" min="1" max="65535">
      <input id="ws-gpu-device" class="form-input" type="number" value="0" min="0">
    </div>
    <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;margin-top:.75rem">
      <input id="ws-used-vram" class="form-input" type="number" min="0" step="0.1" placeholder="Used VRAM (optional)">
      <input id="ws-alias" class="form-input" placeholder="Alias (optional, e.g. whisper)">
    </div>
    <div class="form-actions" style="margin-top:.75rem">
      <button class="btn btn-primary" id="ws-submit-btn" onclick="addWhisperServerModel()">Add model</button>
    </div>
  </div>
</div>

<!-- SEARCH -->
<div id="tab-search" class="tab-panel">
  <div class="card">
    <div class="card-title">Search HuggingFace</div>

    <!-- query row -->
    <div class="search-bar" style="margin-bottom:.75rem">
      <input type="text" id="search-q" class="form-input" placeholder="Search models (e.g. llama, mistral, qwen…)" onkeydown="if(event.key==='Enter'){event.preventDefault();doSearch();}">
      <button class="btn btn-secondary" onclick="doSearch()">Search</button>
    </div>

    <!-- filter row 1: toggles + dropdowns -->
    <div style="display:flex;flex-wrap:wrap;gap:.625rem;align-items:center;margin-bottom:.625rem">
      <div style="display:flex;align-items:center;gap:.35rem">
        <span class="fl">Format</span>
        <div class="tog-grp">
          <button class="tog-btn" data-val="gguf">GGUF</button>
          <button class="tog-btn on" data-val="all">All</button>
          <button class="tog-btn" data-val="no-gguf">No GGUF</button>
        </div>
      </div>
      <div style="display:flex;align-items:center;gap:.35rem">
        <span class="fl">Type</span>
        <select id="filter-pipeline" class="form-input" style="padding:.25rem .4rem;font-size:12px">
          <option value="">All</option>
          <option value="text-generation">Text generation</option>
          <option value="text2text-generation">Text-to-text</option>
          <option value="text-to-image">Text-to-image</option>
          <option value="image-to-text">Image-to-text</option>
          <option value="automatic-speech-recognition">Speech recog.</option>
          <option value="text-to-speech">TTS</option>
          <option value="text-to-video">Text-to-video</option>
          <option value="image-to-video">Image-to-video</option>
          <option value="feature-extraction">Embeddings</option>
        </select>
      </div>
      <div style="display:flex;align-items:center;gap:.35rem">
        <span class="fl">Arch</span>
        <select id="filter-arch" class="form-input" style="padding:.25rem .4rem;font-size:12px">
          <option value="">Any</option>
          <option value="moe">MoE</option>
          <option value="lora">LoRA</option>
        </select>
      </div>
      <div style="display:flex;align-items:center;gap:.35rem">
        <span class="fl">Sort</span>
        <select id="filter-sort" class="form-input" style="padding:.25rem .4rem;font-size:12px">
          <option value="downloads">Most downloaded</option>
          <option value="likes">Most liked</option>
          <option value="lastModified">Recently updated</option>
        </select>
      </div>
    </div>

    <!-- filter row 2: size chips -->
    <div style="display:flex;align-items:flex-start;gap:.5rem;margin-bottom:.5rem">
      <span class="fl" style="padding-top:.25rem;min-width:32px">Size</span>
      <div class="chip-row" id="size-chips">
        <span class="chip" data-val="0.5b">0.5B</span>
        <span class="chip" data-val="1b">1B</span>
        <span class="chip" data-val="1.5b">1.5B</span>
        <span class="chip" data-val="3b">3B</span>
        <span class="chip" data-val="4b">4B</span>
        <span class="chip" data-val="7b">7B</span>
        <span class="chip" data-val="8b">8B</span>
        <span class="chip" data-val="9b">9B</span>
        <span class="chip" data-val="12b">12B</span>
        <span class="chip" data-val="13b">13B</span>
        <span class="chip" data-val="14b">14B</span>
        <span class="chip" data-val="22b">22B</span>
        <span class="chip" data-val="27b">27B</span>
        <span class="chip" data-val="32b">32B</span>
        <span class="chip" data-val="34b">34B</span>
        <span class="chip" data-val="40b">40B</span>
        <span class="chip" data-val="70b">70B</span>
        <span class="chip" data-val="72b">72B</span>
        <span class="chip" data-val="90b">90B</span>
        <span class="chip" data-val="123b">123B</span>
        <span class="chip" data-val="235b">235B</span>
        <span class="chip" data-val="671b">671B</span>
      </div>
    </div>

    <!-- filter row 3: capability chips -->
    <div style="display:flex;align-items:flex-start;gap:.5rem;margin-bottom:.625rem">
      <span class="fl" style="padding-top:.25rem;min-width:32px">Cap.</span>
      <div class="chip-row" id="cap-chips">
        <span class="chip" data-val="text_generation">Text</span>
        <span class="chip" data-val="image_generation">T2I</span>
        <span class="chip" data-val="image_to_text">I2T</span>
        <span class="chip" data-val="video_generation">T2V</span>
        <span class="chip" data-val="image_to_video">I2V</span>
        <span class="chip" data-val="audio_generation">T2A</span>
        <span class="chip" data-val="speech_to_text">STT</span>
        <span class="chip" data-val="text_to_speech">TTS</span>
        <span class="chip" data-val="embeddings">Embed</span>
        <span class="chip" data-val="depth_estimation">Depth</span>
        <span class="chip" data-val="image_segmentation">Segment</span>
        <span class="chip" data-val="object_detection">Detect</span>
        <span class="chip" data-val="function-calling">Tool calling</span>
        <span class="chip" data-val="vision">Vision</span>
        <span class="chip" data-val="reasoning">Reasoning</span>
        <span class="chip" data-val="code">Code</span>
        <span class="chip" data-val="multilingual">Multilingual</span>
        <span class="chip" data-val="roleplay">Roleplay</span>
        <span class="chip" data-val="math">Math</span>
      </div>
    </div>

    <!-- filter row 4: component type chips (single-select) -->
    <div style="display:flex;align-items:flex-start;gap:.5rem;margin-bottom:.5rem">
      <span class="fl" style="padding-top:.25rem;min-width:32px">Comp.</span>
      <div class="chip-row" id="comp-type-chips">
        <span class="chip" data-val="vae">VAE</span>
        <span class="chip" data-val="t5xxl">T5-XXL</span>
        <span class="chip" data-val="clip_l">CLIP-L</span>
        <span class="chip" data-val="clip_g">CLIP-G</span>
        <span class="chip" data-val="clip_vision">CLIP Vision</span>
        <span class="chip" data-val="lora">LoRA</span>
        <span class="chip" data-val="encoder">Encoder</span>
        <span class="chip" data-val="controlnet">ControlNet</span>
        <span class="chip" data-val="unet">UNet</span>
      </div>
    </div>

    <!-- filter row 5: quant chips (file-level filter) -->
    <div style="display:flex;align-items:flex-start;gap:.5rem;margin-bottom:1rem">
      <span class="fl" style="padding-top:.25rem;min-width:32px">Quant</span>
      <div class="chip-row" id="quant-chips">
        <span class="chip" data-val="Q2_K">Q2_K</span>
        <span class="chip" data-val="Q3_K_M">Q3_K_M</span>
        <span class="chip" data-val="Q4_K_S">Q4_K_S</span>
        <span class="chip" data-val="Q4_K_M">Q4_K_M ★</span>
        <span class="chip" data-val="Q5_K_S">Q5_K_S</span>
        <span class="chip" data-val="Q5_K_M">Q5_K_M</span>
        <span class="chip" data-val="Q6_K">Q6_K</span>
        <span class="chip" data-val="Q8_0">Q8_0</span>
        <span class="chip" data-val="F16">F16</span>
        <span class="chip" data-val="IQ4_XS">IQ4_XS</span>
        <span class="chip" data-val="IQ3_XXS">IQ3_XXS</span>
        <span class="chip" data-val="IQ2_XXS">IQ2_XXS</span>
      </div>
    </div>

    <div id="search-results"><span class="muted small">Enter a query above to search</span></div>
  </div>
</div>


<!-- Confirm / notice modal is provided globally by base.html -->

<!-- Download modal -->
<div id="dl-modal" class="modal">
  <div class="modal-box">
    <div class="modal-head">
      <span class="modal-title">Download model</span>
      <button class="modal-close" onclick="closeModal('dl-modal')">×</button>
    </div>
    <div class="modal-body">
      <div id="dl-form">
        <div class="form-row">
          <label class="form-label">HuggingFace repo ID or URL</label>
          <div style="display:flex;gap:.5rem">
            <input type="text" id="dl-id" class="form-input" style="flex:1" placeholder="e.g. bartowski/Llama-3.1-8B-Instruct-GGUF" onkeydown="if(event.key==='Enter'){event.preventDefault();browseHfFiles()}">
            <button type="button" class="btn btn-ghost btn-sm" id="dl-browse-btn" onclick="browseHfFiles()" style="white-space:nowrap" title="Fetch available files from HuggingFace">Browse</button>
          </div>
          <div id="dl-browse-status" style="font-size:11px;margin-top:.3rem;min-height:1em"></div>
        </div>
        <!-- Quant picker: shown when GGUF files are found via Browse -->
        <div id="dl-quant-row" style="display:none;margin-top:.5rem">
          <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.35rem">
            <label class="form-label" style="margin:0">Select quantizations to download</label>
            <span style="font-size:11px;display:flex;gap:.25rem">
              <button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.1rem .4rem" onclick="_dlSelectAll(true)">All</button>
              <button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.1rem .4rem" onclick="_dlSelectAll(false)">None</button>
            </span>
          </div>
          <div id="dl-quant-list" style="max-height:220px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;padding:.25rem .5rem;display:flex;flex-direction:column;gap:.1rem"></div>
          <div id="dl-quant-note" style="font-size:11px;color:var(--text-2);margin-top:.4rem"></div>
        </div>
        <!-- GGUF mode: manual file / pattern (fallback when Browse not used) -->
        <div id="dl-pattern-row" class="form-row">
          <label class="form-label">File / pattern <span class="muted">(or use Browse above)</span></label>
          <input type="text" id="dl-pattern" class="form-input" placeholder=".gguf">
          <span class="form-hint">Exact filename or suffix pattern. Leave blank to download all .gguf files.</span>
        </div>
        <!-- Snapshot mode: full repo -->
        <div id="dl-snapshot-note" class="alert alert-info" style="display:none">
          Will download the full repository using the HuggingFace snapshot API. This is the correct method for safetensors / non-GGUF models. Large repos may take a while.
        </div>
        <div class="form-actions">
          <button class="btn btn-primary" id="dl-start-btn" onclick="startDownloadFromForm()">Download</button>
          <button class="btn btn-secondary" id="dl-later-btn" onclick="markDownloadFromForm()" title="Add this model to your list as “to download” without fetching it now">Add to list (download later)</button>
          <button class="btn btn-ghost" onclick="closeModal('dl-modal')">Close</button>
        </div>
      </div>
      <div id="dl-progress" style="display:none;padding:.25rem 0">
        <div id="dl-filename" style="font-size:13px;font-weight:500;margin-bottom:.5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">Preparing…</div>
        <div class="progress" style="margin-top:0;margin-bottom:.35rem;height:5px;background:var(--border-2)"><div id="dl-bar" class="progress-fill" style="width:0%;height:100%"></div></div>
        <div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-2);margin-bottom:.75rem">
          <span id="dl-bytes">—</span>
          <span id="dl-speed">—</span>
          <span id="dl-eta">—</span>
          <span id="dl-pct">0%</span>
        </div>
        <div id="dl-log" style="display:none;background:var(--raised);border-radius:6px;padding:.4rem .6rem;font-size:11px;font-family:monospace;color:var(--text-2);max-height:72px;overflow-y:auto"></div>
        <div style="display:flex;justify-content:flex-end;margin-top:.75rem">
          <button class="btn btn-ghost btn-sm" onclick="minimizeDownload()" title="Close this window — the download continues in the background">Minimize ↓</button>
        </div>
      </div>
    </div>
  </div>
</div>

<!-- Upload modal -->
<div id="upload-modal" class="modal">
  <div class="modal-box">
    <div class="modal-head">
      <span class="modal-title">Upload GGUF model</span>
      <button class="modal-close" onclick="closeModal('upload-modal')">×</button>
    </div>
    <div class="modal-body">
      <div id="upload-form">
        <div class="form-row">
          <label class="form-label">Select GGUF file</label>
          <input type="file" id="upload-file" class="form-input" accept=".gguf">
        </div>
        <div class="form-actions">
          <button class="btn btn-primary" onclick="startUpload()">Upload</button>
          <button class="btn btn-ghost" onclick="closeModal('upload-modal')">Close</button>
        </div>
      </div>
      <div id="upload-progress" style="display:none;padding:.25rem 0">
        <div id="upload-filename" style="font-size:13px;font-weight:500;margin-bottom:.5rem">Uploading…</div>
        <div class="progress" style="margin-top:0;margin-bottom:.35rem;height:5px;background:var(--border-2)"><div id="upload-bar" class="progress-fill" style="width:0%;height:100%"></div></div>
        <div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-2)">
          <span id="upload-pct">0%</span>
          <span id="upload-status">—</span>
        </div>
      </div>
    </div>
  </div>
</div>

<!-- Whisper-server edit modal -->
<div id="ws-edit-modal" class="modal">
  <div class="modal-box" style="max-width:520px">
    <div class="modal-head">
      <span class="modal-title">Edit whisper-server model</span>
      <button class="modal-close" onclick="closeModal('ws-edit-modal')">×</button>
    </div>
    <div class="modal-body">
      <input type="hidden" id="wse-model-id">
      <div class="form-row">
        <label class="form-label">Model ID</label>
        <div id="wse-id-label" style="font-size:12px;font-family:monospace;color:var(--text-2);padding:.3rem 0"></div>
      </div>
      <div class="form-row">
        <label class="form-label">Alias <span class="muted small">(optional — shared by multiple instances for round-robin)</span></label>
        <input id="wse-alias" class="form-input" placeholder="e.g. whisper">
      </div>
      <div class="form-row">
        <label class="form-label">Server path</label>
        <input id="wse-server-path" class="form-input" placeholder="/usr/local/bin/whisper-server">
      </div>
      <div class="form-row">
        <label class="form-label">Model source</label>
        <select id="wse-model-source" class="form-input" onchange="toggleWseModelSource()">
          <option value="cached-gguf">Downloaded GGUF</option>
          <option value="manual-path">Manual path</option>
        </select>
      </div>
      <div class="form-row" id="wse-gguf-row">
        <label class="form-label">Downloaded GGUF</label>
        <select id="wse-gguf-select" class="form-input">
          <option value="">Select downloaded GGUF</option>
        </select>
      </div>
      <div class="form-row" id="wse-path-row" style="display:none">
        <label class="form-label">Model path</label>
        <input id="wse-model-path" class="form-input" placeholder="/path/to/ggml-model.bin">
      </div>
      <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.75rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">Port</label>
          <input id="wse-port" class="form-input" type="number" min="1" max="65535">
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">GPU device</label>
          <input id="wse-gpu-device" class="form-input" type="number" min="0">
        </div>
      </div>
      <div class="form-actions" style="margin-top:1rem">
        <button class="btn btn-primary" onclick="saveWhisperServerEdit()">Save</button>
        <button class="btn btn-ghost" onclick="closeModal('ws-edit-modal')">Cancel</button>
      </div>
    </div>
  </div>
</div>

<!-- Model configuration modal -->
<div id="cfg-modal" class="modal">
  <div class="modal-box" style="max-width:600px;max-height:92vh;overflow-y:auto">
    <div class="modal-head" style="position:sticky;top:0;background:var(--card);z-index:1;border-bottom:1px solid var(--border)">
      <span class="modal-title" id="cfg-modal-title">Configure model</span>
      <button class="modal-close" onclick="closeModal('cfg-modal')">×</button>
    </div>
    <div class="modal-body">
      <input type="hidden" id="cfg-path">
      <input type="hidden" id="cfg-orig-path">
      <input type="hidden" id="cfg-orig-type">
      <input type="hidden" id="cfg-config-id">

      <!-- identity -->
      <div class="form-row">
        <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 id="cfg-path-moved-notice" style="display:none;font-size:11px;color:#f59e0b;margin-top:.2rem;padding:.25rem .5rem;background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.25);border-radius:4px"></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>
        </label>
        <div style="display:grid;grid-template-columns:1fr 1fr;gap:.3rem .75rem;margin-top:.35rem">
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="text_models"> Text / LLM</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="vision_models"> Vision / VLM</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="image_models"> Image gen (T2I / I2I)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" data-sub="t2v" value="video_models"> Video gen (T2V)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" data-sub="i2v" value="video_models"> Image-to-Video (I2V)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" data-sub="v2v" value="video_models"> Video-to-Video (V2V)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="audio_models"> Audio transcription (STT)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="tts_models"> Text-to-Speech (TTS)</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="audio_gen_models"> Audio generation</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="embedding_models"> Embeddings</label>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="spatial_models"> Depth / Segmentation / Detection</label>
        </div>
      </div>

      <!-- Capabilities -->
      <div class="card-title" style="margin-top:1.25rem;display:flex;align-items:center;gap:.5rem">
        Capabilities
        <span id="cfg-caps-autodet" style="font-size:11px;color:var(--text-3);font-weight:400"></span>
      </div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.25rem .75rem">
        <div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-bottom:.15rem">Text / Language</div>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="text_generation"> Text generation</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="embeddings"> Embeddings</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_to_text"> Image-to-text (vision input)</label>
        <div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-top:.5rem;margin-bottom:.15rem">Image generation &amp; editing</div>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_generation"> Image generation (T2I)</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_to_image"> Image-to-image (I2I)</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="inpainting"> Inpainting</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="controlnet"> ControlNet</label>
        <div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-top:.5rem;margin-bottom:.15rem">Image analysis &amp; processing</div>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="depth_estimation"> Depth estimation</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_segmentation"> Image segmentation</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="object_detection"> Object detection</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_upscaling"> Image upscaling</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="face_restoration"> Face restoration</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="style_transfer"> Style transfer</label>
        <div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-top:.5rem;margin-bottom:.15rem">Video</div>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_generation"> Video generation (T2V)</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_to_video"> Image-to-video (I2V)</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_to_video"> Video-to-video (V2V)</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_interpolation"> Frame interpolation</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_upscaling"> Video upscaling</label>
        <div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-top:.5rem;margin-bottom:.15rem">Audio</div>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="speech_to_text"> Speech-to-text (STT)</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="text_to_speech"> Text-to-speech (TTS)</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="subtitle_generation"> Subtitle generation</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="audio_generation"> Audio generation</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="audio_to_audio"> Audio-to-audio</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="lip_sync"> Lip sync</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_dubbing"> Video dubbing</label>
        <div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-top:.5rem;margin-bottom:.15rem">3D</div>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_to_3d"> Image-to-3D</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_to_3d"> Video-to-3D</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="model_3d_generation"> 3D model generation</label>
        <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="model_3d_to_image"> 3D model to image</label>
      </div>
      <div class="form-row">
        <label class="form-label">Alias <span class="muted">(optional)</span></label>
        <input type="text" id="cfg-alias" class="form-input" placeholder="Friendly name">
      </div>
      <div class="form-row" id="cfg-config-name-row">
        <label class="form-label">Configuration name <span class="muted">(optional)</span></label>
        <input type="text" id="cfg-config-name" class="form-input" placeholder="Label for this config in the model list">
        <div style="font-size:11px;color:var(--text-2);margin-top:.25rem">Shown on the config pill. Falls back to alias, then "Config N".</div>
      </div>

      <!-- backend -->
      <div class="card-title" style="margin-top:1.25rem">Backend</div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">Compute backend</label>
          <select id="cfg-backend" class="form-input">
            <option value="auto">Auto-detect</option>
            <option value="nvidia">NVIDIA (CUDA)</option>
            <option value="vulkan">Vulkan</option>
            <option value="opencl">OpenCL</option>
            <option value="cpu">CPU only</option>
          </select>
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Load mode</label>
          <select id="cfg-load-mode" class="form-input" onchange="_updatePreloadAllVisibility()">
            <option value="load">Load (pre-load in VRAM)</option>
            <option value="on-request">On-request (load when needed)</option>
          </select>
        </div>
      </div>
      <div class="form-row" id="cfg-engine-row" style="margin-top:.75rem;display:none">
        <label class="form-label">Engine / card</label>
        <select id="cfg-engine" class="form-input" onchange="_updateGpuSplitVisibility()">
          <option value="">Auto (single GPU — by capability / free VRAM)</option>
        </select>
        <span class="form-hint" style="font-size:11px"><b>Auto</b> runs the model on ONE card (picked by capability / most free VRAM). Pin it to a specific engine, or pick a <b>Split — &lt;card&gt; first</b> option to spread one model across all GPUs (more total VRAM) with that card leading (it holds the output/KV anchor and the larger share). Only shown when multiple engines are running.</span>
        <label style="display:flex;align-items:center;gap:.4rem;margin-top:.4rem;font-size:12px">
          <input type="checkbox" id="cfg-engine-fallback" style="width:auto;margin:0">
          Fall back to another engine if the pinned one is unavailable
        </label>
        <span class="form-hint" style="font-size:11px">When off (default), a request fails if the pinned engine is down/busy-unreachable instead of running on a different card.</span>
      </div>
      <div class="form-row" id="cfg-gpu-split-row" style="margin-top:.75rem;display:none;border:1px solid var(--border,#333);border-radius:8px;padding:.6rem">
        <label class="form-label" style="font-weight:600">Split strategy <span class="muted" style="font-weight:400">(when ratio is auto)</span></label>
        <select id="cfg-split-strategy" class="form-input">
          <option value="vram">Maximize VRAM — proportional to free memory (biggest model)</option>
          <option value="performance">Maximize speed — fill the fast card first, least on the slow one</option>
        </select>
        <span class="form-hint" style="font-size:11px"><b>VRAM</b> packs the most onto every card (best for fitting a large model) but the slow card bottlenecks throughput. <b>Speed</b> keeps as much as possible on the lead/fast card and spills only the overflow to the slower one — faster, as long as it still fits.</span>
        <label class="form-label" style="font-weight:600;margin-top:.5rem">Secondary card VRAM cap <span class="muted" style="font-weight:400">— GB per non-lead card</span></label>
        <input type="number" step="0.5" min="0" id="cfg-split-secondary-cap" class="form-input" placeholder="no cap (uses global default)">
        <span class="form-hint" style="font-size:11px">Max VRAM the auto-split may put on each secondary (slow) card, e.g. <b>4</b> = at most 4 GB on the RX 580; the remainder stays on the lead card or spills to CPU. Lower = the slow card bottlenecks less. Blank = use the global default; 0 = no cap.</span>
        <label class="form-label" style="font-weight:600;margin-top:.5rem">Weight distribution <span class="muted" style="font-weight:400">— layers per GPU (manual override)</span></label>
        <input type="text" id="cfg-tensor-split" class="form-input" placeholder="auto (uses the strategy above)">
        <span class="form-hint" style="font-size:11px">Optional explicit ratio for the lead card then the rest, e.g. <b>0.8,0.2</b>. Overrides the strategy. Blank = automatic per the strategy above.</span>
      </div>
      <div class="form-row" id="cfg-ds4-row" style="margin-top:.75rem;display:none;border:1px solid var(--border,#333);border-radius:8px;padding:.6rem">
        <label class="form-label" style="font-weight:600">ds4 (DeepSeek V4) streaming <span class="muted" style="font-weight:400">— per-model overrides</span></label>
        <span class="form-hint" style="font-size:11px;margin-bottom:.4rem">These tune ds4-server for THIS model (they vary by quant/size/context). Leave blank to inherit the global ds4 settings. Context window is set by <b>n_ctx</b> above.</span>
        <div style="display:grid;grid-template-columns:1fr 1fr;gap:.6rem">
          <div class="form-row" style="margin:0">
            <label class="form-label">SSD expert streaming</label>
            <select id="cfg-ds4-ssd" class="form-input">
              <option value="">Inherit global</option>
              <option value="true">On — stream experts from disk</option>
              <option value="false">Off — full residency</option>
            </select>
            <span class="form-hint" style="font-size:11px">On for big models that don't fit VRAM; off for small ones that do.</span>
          </div>
          <div class="form-row" style="margin:0">
            <label class="form-label">Expert-cache VRAM reserve (GiB)</label>
            <input type="number" id="cfg-ds4-reserve" class="form-input" min="0" placeholder="0 = inherit / ds4 default">
            <span class="form-hint" style="font-size:11px">Set just above this model's resident weights (+~2 GiB). ds4 defaults to half the card, which starves the cache.</span>
          </div>
        </div>
        <div class="form-row" style="margin:.5rem 0 0">
          <label class="form-label">Extra ds4-server args</label>
          <input type="text" id="cfg-ds4-extra-args" class="form-input" placeholder="--ssd-streaming-cache-experts 300">
          <span class="form-hint" style="font-size:11px">Cap the expert cache (a count or NGB), prefill-chunk, kv-disk-space, etc. Blank = inherit global.</span>
        </div>
        <div class="form-row" style="margin:.5rem 0 0">
          <label class="form-label">Extra ds4-server env</label>
          <input type="text" id="cfg-ds4-extra-env" class="form-input" placeholder="DS4_CUDA_WEIGHT_ARENA_CHUNK_MB=512">
          <span class="form-hint" style="font-size:11px"><code>KEY=VALUE</code> pairs. e.g. <code>DS4_CUDA_WEIGHT_ARENA_CHUNK_MB=512</code> fixes weight-arena OOM under heavy expert caching. Blank = inherit global.</span>
        </div>
        <span class="form-hint" style="font-size:11px;line-height:1.5;margin-top:.4rem;display:block">
          <b>⚡ Performance:</b> put the GGUF on <b>NVMe/SSD</b> (≈10× prefill for a streamed MoE) ·
          cap the expert cache with <code>--ssd-streaming-cache-experts N</code> (a count, e.g. <code>400</code>) — higher helps decode but too high OOMs the weight arena ·
          set <b>reserve</b> ≈ weights + ~2 GiB · add <code>DS4_CUDA_WEIGHT_ARENA_CHUNK_MB=512</code> · <b>avoid</b> <code>DS4_CUDA_WEIGHT_CACHE</code> (slower). Decode of a model bigger than VRAM is streaming-bound (~0.5 tok/s) — the real fix is a smaller quant. Context = <b>n_ctx</b> above.
        </span>
      </div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-top:.75rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">Used VRAM <span class="muted">(GB)</span></label>
          <input type="number" id="cfg-used-vram" class="form-input" min="0" step="0.1" placeholder="auto-estimate">
          <span class="form-hint" id="cfg-used-vram-hint" style="font-size:11px;color:var(--text-3)"></span>
          <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;margin-top:.4rem">
            <input type="checkbox" id="cfg-force-vram-update">
            <span style="font-size:12px">Force VRAM update</span>
          </label>
          <span class="form-hint" style="font-size:11px;color:var(--text-3)">When on, the real measured VRAM is re-recorded every run and used for eviction even if "Used VRAM" is set above.</span>
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Max instances
            <span class="muted" title="How many copies of this model can be loaded simultaneously in VRAM to serve parallel requests. Requires enough free VRAM for each additional copy."> (?)</span>
          </label>
          <input type="number" id="cfg-max-instances" class="form-input" min="1" step="1" value="1" oninput="_updatePreloadAllVisibility()">
          <span style="font-size:11px;color:var(--text-3)">Parallel copies in VRAM</span>
          <span id="cfg-instances-status" style="font-size:11px;color:var(--accent);display:none;margin-top:.2rem"></span>
        </div>
      </div>
      <div id="cfg-preload-all-row" style="display:none;margin-top:.5rem">
        <label style="display:flex;align-items:center;gap:.5rem;font-size:13px;cursor:pointer">
          <input type="checkbox" id="cfg-preload-all-instances">
          Load all instances at boot
          <span class="muted" style="font-size:11px" title="Pre-load all N copies of this model into VRAM at server startup instead of loading each copy on demand.">(load all ${max_instances} copies on startup)</span>
        </label>
      </div>

      <!-- inference -->
      <div class="card-title" style="margin-top:1.25rem">Inference</div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
        <div class="form-row" style="margin:0" id="cfg-gpu-layers-row">
          <label class="form-label">GPU layers <span class="muted">(llama.cpp offload)</span></label>
          <input type="number" id="cfg-gpu-layers" class="form-input" min="-1" value="-1">
          <span class="form-hint" style="font-size:11px"><b>-1</b> = all on GPU (auto-offloads the overflow to CPU if it won't fit). <b>N</b> = N layers on GPU, the rest on CPU (slower, frees VRAM).</span>
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Context size</label>
          <input type="number" id="cfg-n-ctx" class="form-input" min="128" step="128" value="2048">
        </div>
        <div class="form-row" style="margin:0" id="cfg-n-batch-row">
          <label class="form-label">Batch size <span class="muted">(GGUF; llama.cpp -b)</span></label>
          <input type="number" id="cfg-n-batch" class="form-input" min="1" step="1" placeholder="auto (512)">
          <span class="form-hint" style="font-size:11px">Prompt-ingestion batch. Lowering it (e.g. <b>256</b>) shrinks the compute buffer so a big-context model fits at the VRAM ceiling — slower prefill. Blank = default (512).</span>
        </div>
        <div class="form-row" style="margin:0" id="cfg-n-seq-max-row">
          <label class="form-label">Parallel slots <span class="muted">(GGUF; llama.cpp -np / n_seq_max)</span></label>
          <input type="number" id="cfg-n-seq-max" class="form-input" min="1" step="1" placeholder="auto (1)">
          <span class="form-hint" style="font-size:11px">Concurrent sequence slots. Each slot reserves its own KV/compute VRAM; keep at <b>1</b> to avoid reserving for unused parallelism. Blank = default (1). Ignored by older llama-cpp-python builds.</span>
        </div>
        <div class="form-row" style="margin:0" id="cfg-kv-k-row">
          <label class="form-label">KV cache — Keys <span class="muted">(GGUF text; shrinks KV VRAM)</span></label>
          <select id="cfg-cache-type-k" class="form-input">
            <option value="">Default (f16)</option>
            <option value="q8_0">q8_0 (near-lossless, ~2×)</option>
            <option value="q5_1">q5_1</option>
            <option value="q5_0">q5_0</option>
            <option value="q4_1">q4_1 (smallest)</option>
            <option value="q4_0">q4_0 (smallest)</option>
          </select>
        </div>
        <div class="form-row" style="margin:0" id="cfg-kv-v-row">
          <label class="form-label">KV cache — Values <span class="muted">(sub-8-bit needs Flash Attn)</span></label>
          <select id="cfg-cache-type-v" class="form-input">
            <option value="">Default (f16)</option>
            <option value="q8_0">q8_0 (near-lossless, ~2×)</option>
            <option value="q5_1">q5_1</option>
            <option value="q5_0">q5_0</option>
            <option value="q4_1">q4_1 (smallest)</option>
            <option value="q4_0">q4_0 (smallest)</option>
          </select>
        </div>
        <div class="form-row" style="margin:0" id="cfg-mmproj-row">
          <label class="form-label">Multimodal projector <span class="muted">(mmproj → vision)</span></label>
          <select id="cfg-mmproj" class="form-input" onchange="_onMmprojChange()">
            <option value="">None</option>
          </select>
          <span class="form-hint" style="font-size:11px">Pairs an <b>mmproj</b> GGUF with this model to add image/vision input (passed to llama.cpp as <code>--mmproj</code>). Auto-selected when a matching projector is detected.</span>
        </div>
        <div class="form-row" style="margin:0">
          <p class="muted" style="margin:0;font-size:0.85em;line-height:1.4">
            <strong>Note:</strong> KV-cache quantization only applies to GGUF models on
            the llama.cpp backend. HF-transformers models (incl. <strong>gemma</strong> and
            other sliding-window / hybrid linear-attention architectures) ignore this and
            keep an fp16 KV cache — their quantized-cache path crashes during generation.
            For those, lower <strong>n_ctx</strong> to shrink the KV VRAM reserve instead.
          </p>
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Max GPU % <span class="muted">(optional)</span></label>
          <input type="number" id="cfg-max-gpu" class="form-input" min="1" max="100" placeholder="e.g. 90">
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Manual RAM GB <span class="muted">(optional)</span></label>
          <input type="number" id="cfg-ram-gb" class="form-input" min="0" step="0.5" placeholder="auto">
        </div>
      </div>

      <!-- Auto-compact: shrink an over-long conversation before generation -->
      <div class="card-title" style="margin-top:1.25rem">Auto-compact context</div>
      <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-autocompact" onchange="_toggleAutoCompact()"> Auto-compact when the prompt nears the context limit <span class="muted">(off by default)</span></label>
      <div id="cfg-autocompact-opts" style="display:none;grid-template-columns:1fr 1fr;gap:.75rem;margin-top:.5rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">Trigger at <span class="muted">(% of context)</span></label>
          <input type="number" id="cfg-autocompact-pct" class="form-input" min="50" max="99" step="1" value="85">
          <span class="form-hint" style="font-size:11px">Compacts to ~65% of the context window when the prompt reaches this %.</span>
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Strategy</label>
          <select id="cfg-autocompact-strategy" class="form-input" onchange="_toggleAutoCompact()">
            <option value="drop_oldest">Drop oldest — keep system + most recent turns</option>
            <option value="keep_head_tail">Keep head + tail — also keep the first turn, drop the middle</option>
            <option value="summarize">Summarize — replace the dropped middle with an LLM summary</option>
          </select>
        </div>
        <div class="form-row" id="cfg-autocompact-model-row" style="margin:0;grid-column:1/-1;display:none">
          <label class="form-label">Summarizer model <span class="muted">(summarize only)</span></label>
          <select id="cfg-autocompact-model" class="form-input">
            <option value="">Same as request model (default)</option>
          </select>
          <span class="form-hint" style="font-size:11px">A separate (e.g. smaller/faster) model can write the summary while the main model answers. The dropped history is chunked to fit the chosen model's own context. Empty = the request's own model summarizes. Leave empty to inherit the global default.</span>
        </div>
      </div>

      <!-- Reasoning channel: surface the model's <think> thought as a separate
           reasoning field by default; this suppresses it from the response. -->
      <div style="margin-top:1rem">
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-suppress-reasoning"> Suppress reasoning output <span class="muted">(drop the model's &lt;think&gt; thinking instead of returning it)</span></label>
        <span class="form-hint" style="font-size:11px">By default the model's thinking is returned as a separate <code>reasoning</code> / <code>reasoning_content</code> field (think/thinking/thought tags, including the bare <code>&lt;/think&gt;</code> Qwen pre-fill emits) and kept out of <code>content</code>. Enable this to discard it entirely. A request can override per-call with <code>reasoning:{exclude:true}</code>, <code>reasoning_effort:"none"</code>, or <code>suppress_reasoning</code>.</span>
      </div>

      <div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-top:.75rem">
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-4bit"> 4-bit quantization</label>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-8bit"> 8-bit quantization</label>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-flash"> Flash Attention 2</label>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-noram"> No RAM fallback</label>
      </div>

      <!-- Fast-kernel 4-bit (GPTQ/AWQ via Marlin) — HF transformers models only -->
      <div id="cfg-fastquant-wrap" style="margin-top:1rem;padding:.75rem;border:1px solid var(--border);border-radius:6px;background:var(--raised)">
        <div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
          <div style="flex:1;min-width:220px">
            <label class="form-label">Quant backend <span class="muted">(HF transformers)</span></label>
            <select id="cfg-quant-backend" class="form-input">
              <option value="auto">Auto — fast checkpoint if present, else bitsandbytes</option>
              <option value="bnb">bitsandbytes only (NF4)</option>
              <option value="gptq">GPTQ (Marlin/ExLlama) — needs quantized checkpoint</option>
              <option value="awq">AWQ (Marlin) — needs quantized checkpoint</option>
            </select>
          </div>
          <div style="display:flex;align-items:flex-end;gap:.5rem">
            <button type="button" class="btn btn-secondary btn-sm" id="cfg-quantize-btn" onclick="startQuantize()">Quantize to 4-bit</button>
          </div>
        </div>
        <div class="form-hint" id="cfg-quant-status" style="margin-top:.5rem">
          bitsandbytes NF4 is the slowest 4-bit option. Quantizing to GPTQ/AWQ uses
          Marlin kernels (2–4× faster) but is a heavy one-time background job; the
          produced checkpoint is used automatically on the next load. Falls back to
          bitsandbytes if unavailable or the architecture is unsupported.
        </div>
      </div>

      <!-- Per-component quantization (diffusers image/video pipelines) -->
      <div id="cfg-compquant-wrap">
        <div class="card-title" style="margin-top:1.25rem">Component Quantization <span class="muted" style="font-weight:normal">(image / video pipelines)</span></div>
        <div style="font-size:12px;color:var(--text-muted,#888);margin:.25rem 0 .5rem">
          Quantize individual pipeline components. <b>Default</b> uses the global 4-bit/8-bit checkboxes above.
          <b>2-bit</b> needs optimum-quanto; <b>GGUF file</b> (Q5_K/Q6_K…) needs a pre-quantized .gguf path/URL.
          Tip: backbone <code>4-bit</code> + text encoder <code>8-bit</code> + VAE <code>None</code> = best quality-per-GB.
        </div>
        <div id="cfg-compquant-rows" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:.5rem"></div>
      </div>

      <!-- offload -->
      <div class="card-title" style="margin-top:1.25rem" id="cfg-offload-title">Offload</div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem" id="cfg-offload-grid">
        <div class="form-row" style="margin:0">
          <label class="form-label">Strategy</label>
          <select id="cfg-offload-strategy" class="form-input">
            <option value="auto">Auto (over-VRAM → straight to model offload)</option>
            <option value="auto-borderline">Auto borderline-aware (try full-GPU if marginally over)</option>
            <option value="none">None (GPU only)</option>
            <option value="model">CPU offload (model — active component on GPU)</option>
            <option value="group">Group offload + stream (block-level, prefetched)</option>
            <option value="balanced">Balanced (fill GPU, spill to CPU → disk)</option>
            <option value="sequential">CPU offload (sequential — most aggressive)</option>
            <option value="cpu">CPU RAM (legacy)</option>
            <option value="disk">Disk</option>
          </select>
          <span class="form-hint"><b>Auto</b> goes straight to <b>model</b> offload when the model's peak exceeds free VRAM (no full-GPU gamble). <b>Auto borderline-aware</b> is the same, except when the model only marginally overshoots (≤3 GB) it tries full-GPU first to keep both experts resident and use the free VRAM, falling back to model offload on OOM. <b>model</b> keeps only the active component on GPU (best for two-expert Wan2.2 — near full-GPU speed). <b>group</b> streams blocks with prefetch (lowest VRAM, fast; incompatible with bitsandbytes 4-bit → falls back to sequential). <b>balanced</b> fills GPU to the % below then spills.</span>
        </div>
        <div class="form-row" style="margin:0">
          <label class="form-label">Offload directory</label>
          <input type="text" id="cfg-offload-dir" class="form-input" placeholder="./offload">
        </div>
      </div>

      <!-- sd.cpp video options -->
      <div class="card-title" style="margin-top:1.25rem">sd.cpp Video Options</div>
      <div class="form-row" style="margin-bottom:.5rem">
        <label class="form-label">Max VRAM budget (MB) <span class="muted">graph-cut execution limit, 0 = disabled</span></label>
        <input type="number" id="cfg-max-vram" class="form-input" min="0" step="512" placeholder="0">
      </div>
      <div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-top:.5rem">
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-sdcpp-flash"> Flash Attention</label>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-sdcpp-diff-flash"> Diffusion Flash Attention</label>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-vae-tiling"> VAE Tiling <span class="muted">(reduces VRAM at decode)</span></label>
      </div>

      <!-- video output quality (CRF) — only relevant for video models -->
      <div class="form-row" style="margin-top:.75rem;max-width:340px">
        <label class="form-label">Video output quality — CRF
          <span class="muted">(video models; blank = default)</span></label>
        <input type="number" id="cfg-output-crf" class="form-input" min="0" max="51" step="1"
               placeholder="e.g. 20 (lower = better quality, bigger file)">
      </div>
      <div class="form-row" style="margin-top:.75rem;max-width:340px">
        <label class="form-label">Balanced GPU % <span class="muted">(video models; default 80)</span></label>
        <input type="number" id="cfg-balanced-gpu-pct" class="form-input" min="10" max="99" step="1"
               placeholder="80">
        <span class="form-hint">When the model won't fit entirely in VRAM, fill this % of free VRAM then spill the rest to CPU RAM. Also the cap used by auto-balanced fallback.</span>
      </div>

      <!-- Acceleration / distillation (Lightning / Turbo / LCM / Hyper-SD) — image & video -->
      <div id="cfg-accel-section" style="display:none">
        <div class="card-title" style="margin-top:1.25rem">Acceleration / Distillation
          <span class="muted" style="font-weight:normal">(Lightning / Turbo / LCM / Hyper-SD — image &amp; video)</span></div>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px;margin:.4rem 0">
          <input type="checkbox" id="cfg-accel-enabled" onchange="onAccelToggle()"> Enable acceleration
          <span class="muted">fuse a distill LoRA and run in few steps at low guidance</span></label>
        <div id="cfg-accel-fields" style="display:none">
          <div class="form-row" style="max-width:420px">
            <label class="form-label">Preset</label>
            <select id="cfg-accel-preset" class="form-input" onchange="onAccelPreset()">
              <option value="custom">Custom (set fields manually)</option>
            </select>
          </div>
          <div class="form-row" style="max-width:560px">
            <label class="form-label">Distill model <span class="muted">(cached — pick to fill the LoRA fields below)</span></label>
            <select id="cfg-accel-distill" class="form-input" onchange="onAccelDistill()">
              <option value="">— manual / pick a preset —</option>
            </select>
            <span class="form-hint" id="cfg-accel-distill-hint">Lists distill-LoRA repos found in the local cache. Choose one, then pick its high/low (or single) LoRA below.</span>
          </div>
          <div class="form-row" style="max-width:560px">
            <label class="form-label">Distill LoRA <span class="muted">(single — path or repo:weight_name.safetensors; blank for turbo full-models)</span></label>
            <select id="cfg-accel-lora-sel" class="form-input" style="display:none;margin-bottom:.4rem" onchange="onAccelLoraPick('lora')"></select>
            <input type="text" id="cfg-accel-lora" class="form-input" placeholder="e.g. ByteDance/SDXL-Lightning:sdxl_lightning_4step_lora.safetensors">
          </div>
          <div class="form-row" style="max-width:560px">
            <label class="form-label">Distill LoRA — high/low noise <span class="muted">(Wan2.2 A14B two-expert only; overrides the single LoRA per expert)</span></label>
            <select id="cfg-accel-lora-high-sel" class="form-input" style="display:none;margin-bottom:.3rem" onchange="onAccelLoraPick('high')"></select>
            <input type="text" id="cfg-accel-lora-high" class="form-input" placeholder="high-noise → transformer  (e.g. repo:..._high_noise_..._4step.safetensors)">
            <select id="cfg-accel-lora-low-sel" class="form-input" style="display:none;margin:.4rem 0 .3rem" onchange="onAccelLoraPick('low')"></select>
            <input type="text" id="cfg-accel-lora-low" class="form-input" style="margin-top:.4rem" placeholder="low-noise → transformer_2  (e.g. repo:..._low_noise_..._4step.safetensors)">
            <span class="form-hint">Wan2.2 A14B has two experts; the distill LoRA must be fused into <b>both</b> or the clip collapses to a solid colour at 4 steps. Leave blank to apply the single LoRA above to both.</span>
          </div>
          <div style="display:flex;gap:1rem;flex-wrap:wrap">
            <div class="form-row" style="max-width:130px">
              <label class="form-label">LoRA weight</label>
              <input type="number" id="cfg-accel-weight" class="form-input" min="0" max="2" step="0.05" placeholder="1.0">
            </div>
            <div class="form-row" style="max-width:120px">
              <label class="form-label">Steps</label>
              <input type="number" id="cfg-accel-steps" class="form-input" min="1" max="50" step="1" placeholder="4">
            </div>
            <div class="form-row" style="max-width:130px">
              <label class="form-label">Guidance</label>
              <input type="number" id="cfg-accel-guidance" class="form-input" min="0" max="15" step="0.5" placeholder="1.0">
            </div>
            <div class="form-row" style="max-width:150px">
              <label class="form-label">Flow shift <span class="muted">(Wan)</span></label>
              <input type="number" id="cfg-accel-flowshift" class="form-input" min="0" max="20" step="0.5" placeholder="(none)">
            </div>
            <div class="form-row" style="max-width:200px">
              <label class="form-label">Scheduler <span class="muted">(optional)</span></label>
              <input type="text" id="cfg-accel-scheduler" class="form-input" placeholder="e.g. LCMScheduler">
            </div>
          </div>
        </div>
      </div>

      <!-- TurboQuant embedding vector quantization (embedding models) -->
      <div id="cfg-turboquant-section" style="display:none">
        <div class="card-title" style="margin-top:1.25rem">TurboQuant <span class="muted" style="font-weight:normal">(embedding vector quantization — data-free, inner-product preserving)</span></div>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px;margin:.4rem 0">
          <input type="checkbox" id="cfg-tq-enabled" onchange="onTurboQuantToggle()"> Enable TurboQuant
          <span class="muted">compress embeddings to 2–8 bits/coord for smaller vector stores</span></label>
        <div id="cfg-tq-fields" style="display:none">
          <div style="display:flex;gap:1rem;flex-wrap:wrap">
            <div class="form-row" style="max-width:260px">
              <label class="form-label">Backend</label>
              <select id="cfg-tq-backend" class="form-input">
                <option value="builtin">Built-in (NumPy, always available)</option>
                <option value="library">turboquant-py library (QJL)</option>
              </select>
              <span class="form-hint" id="cfg-tq-backend-hint"></span>
            </div>
            <div class="form-row" style="max-width:200px">
              <label class="form-label">Default bits</label>
              <select id="cfg-tq-bits" class="form-input">
                <option value="8">8-bit (near-lossless, 3×)</option>
                <option value="6">6-bit</option>
                <option value="4">4-bit (6×)</option>
                <option value="2">2-bit (12×)</option>
              </select>
            </div>
          </div>
          <span class="form-hint">A request's <code>quantization</code> field (e.g. <code>turbo4</code>) overrides the default bits. With <code>encoding_format:"base64"</code> the response is compact packed bytes; otherwise it returns the lossy reconstruction as floats.</span>
        </div>
      </div>

      <!-- components -->
      <div class="card-title" style="margin-top:1.25rem">Components</div>
      <div class="form-row">
        <label class="form-label">Model template <span class="muted">(architecture hint for sd.cpp)</span></label>
        <select id="cfg-template" class="form-input">
          <option value="">Auto-detect</option>
          <optgroup label="WAN">
            <option value="wan_t2v_1.3b">WAN 1.3B T2V</option>
            <option value="wan_t2v_14b">WAN 14B T2V</option>
            <option value="wan_i2v_480p">WAN 1.3B I2V 480p</option>
            <option value="wan_i2v_720p">WAN 14B I2V 720p</option>
            <option value="wan_vace">WAN VACE</option>
          </optgroup>
          <optgroup label="FLUX">
            <option value="flux_dev">FLUX.1-dev</option>
            <option value="flux_schnell">FLUX.1-schnell</option>
          </optgroup>
          <optgroup label="Stable Diffusion">
            <option value="sd15">Stable Diffusion 1.5</option>
            <option value="sdxl">Stable Diffusion XL</option>
            <option value="sd3">Stable Diffusion 3</option>
          </optgroup>
          <optgroup label="Other">
            <option value="auraflow">AuraFlow</option>
            <option value="chroma">Chroma</option>
            <option value="hunyuan_video">HunyuanVideo</option>
            <option value="mochi">Mochi</option>
            <option value="ltxv">LTX-Video</option>
            <option value="cogvideox">CogVideoX</option>
          </optgroup>
        </select>
      </div>
      <div class="form-row" style="margin-top:.5rem">
        <label class="form-label">VAE <span class="muted">(optional)</span></label>
        <select id="cfg-vae-sel" class="form-input" onchange="_onCompPick('vae')"></select>
        <input type="text" id="cfg-vae-path" class="form-input" style="display:none;margin-top:.3rem" placeholder="Custom VAE path…">
      </div>
      <div class="form-row">
        <label class="form-label">T5-XXL <span class="muted">(optional)</span></label>
        <select id="cfg-t5xxl-sel" class="form-input" onchange="_onCompPick('t5xxl')"></select>
        <input type="text" id="cfg-t5xxl-path" class="form-input" style="display:none;margin-top:.3rem" placeholder="Custom T5-XXL path…">
      </div>
      <div class="form-row">
        <label class="form-label">CLIP-L <span class="muted">(optional)</span></label>
        <select id="cfg-clip-l-sel" class="form-input" onchange="_onCompPick('clip-l')"></select>
        <input type="text" id="cfg-clip-l-path" class="form-input" style="display:none;margin-top:.3rem" placeholder="Custom CLIP-L path…">
      </div>
      <div class="form-row">
        <label class="form-label">CLIP-G <span class="muted">(optional)</span></label>
        <select id="cfg-clip-g-sel" class="form-input" onchange="_onCompPick('clip-g')"></select>
        <input type="text" id="cfg-clip-g-path" class="form-input" style="display:none;margin-top:.3rem" placeholder="Custom CLIP-G path…">
      </div>
      <div class="form-row">
        <label class="form-label">CLIP Vision <span class="muted">(optional)</span></label>
        <select id="cfg-clip-vision-sel" class="form-input" onchange="_onCompPick('clip-vision')"></select>
        <input type="text" id="cfg-clip-vision-path" class="form-input" style="display:none;margin-top:.3rem" placeholder="Custom CLIP Vision path…">
      </div>
      <div class="form-row">
        <label class="form-label">LoRA file <span class="muted">(optional — applied at load)</span></label>
        <select id="cfg-lora-sel" class="form-input" onchange="_onCompPick('lora')"></select>
        <input type="text" id="cfg-lora-path" class="form-input" style="display:none;margin-top:.3rem" placeholder="Custom LoRA path…">
        <span class="form-hint" style="font-size:11px;color:var(--text-3)">Tip: use &lt;lora:name:strength&gt; in prompts to apply at inference time</span>
      </div>
      <div class="form-row">
        <label class="form-label">LoRA directory <span class="muted">(optional — for prompt-based loras)</span></label>
        <input type="text" id="cfg-lora-dir" class="form-input" placeholder="e.g. /models/loras">
      </div>
      <div class="form-row">
        <label class="form-label">LoRA training base model <span class="muted">(optional — SD1.x/SDXL to train LoRAs against)</span></label>
        <input type="text" id="cfg-lora-train-base" class="form-input"
          placeholder="e.g. stabilityai/stable-diffusion-xl-base-1.0">
        <div class="muted small" style="margin-top:.25rem">
          When this image model is a transformer/DiT (Z-Image, Flux, SD3) the in-process LoRA
          trainer can't target it. Set a UNet-based SD1.x/SDXL model here to train identity LoRAs
          against, while generation still uses this model. Leave empty to train on this model.
        </div>
      </div>

      <!-- generation -->
      <div class="card-title" style="margin-top:1.25rem">Generation</div>
      <div class="form-row">
        <label class="form-label">System prompt <span class="muted">(optional)</span></label>
        <textarea id="cfg-sysprompt" class="form-input" rows="3"
          placeholder="You are a helpful assistant…"
          style="resize:vertical;font-family:var(--font-mono,monospace);font-size:12px;line-height:1.5"></textarea>
      </div>
      <div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
        <div class="form-row" style="margin:0">
          <label class="form-label">Chat template parser</label>
          <select id="cfg-parser" class="form-input">
            <option value="auto">Auto-detect</option>
            <option value="llama">Llama</option>
            <option value="mistral">Mistral</option>
            <option value="chatml">ChatML</option>
            <option value="phi">Phi</option>
            <option value="gemma">Gemma</option>
            <option value="qwen">Qwen</option>
          </select>
        </div>
      </div>
      <div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-top:.75rem">
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-tools"> Tools closer prompt</label>
        <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-grammar"> Grammar-guided generation</label>
      </div>

      <div class="form-actions" style="margin-top:1.5rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
        <button class="btn btn-primary" onclick="saveModelConfig()">Save</button>
        <button class="btn btn-secondary" id="cfg-save-new-btn" onclick="saveNewModelConfig()" style="display:none" title="Save as a separate configuration for the same model file">Save as new config</button>
        <button class="btn btn-ghost" onclick="closeModal('cfg-modal')">Cancel</button>
        <button class="btn btn-danger btn-sm" id="cfg-remove-config-btn" onclick="removeThisConfig()" style="display:none;margin-left:auto" title="Remove only this configuration (keeps other configs and the model file)">Remove this config</button>
      </div>
    </div>
  </div>
</div>

<!-- Model info drawer -->
<div id="info-overlay" onclick="closeInfo()"></div>
<div id="info-drawer">
  <div id="info-sticky">
    <div id="info-title">—</div>
    <button class="btn btn-ghost btn-sm" onclick="closeInfo()">✕</button>
  </div>
  <div id="info-body" style="padding:1.25rem 1.5rem"></div>
</div>
{% endblock %}

{% block scripts %}
<script>
/* ── helpers ─────────────────────────────────────────── */
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function jq(v){return JSON.stringify(v).replace(/"/g,'&quot;')}
function fmtNum(n){if(!n)return'0';return n>=1e6?(n/1e6).toFixed(1)+'M':n>=1000?(n/1000).toFixed(1)+'k':String(n)}
function fmtGB(gb){if(!gb)return'—';return gb>=1?gb.toFixed(1)+' GB':(gb*1024).toFixed(0)+' MB'}
function fmtDate(s){try{return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})}catch{return s}}

function fmtCapabilities(caps){
  if(!caps||!caps.length)return'';
  const labels={
    text_generation:'Text',image_generation:'T2I',image_to_text:'I2T',
    video_generation:'T2V',image_to_video:'I2V',audio_generation:'T2A',
    speech_to_text:'STT',text_to_speech:'TTS',embeddings:'Embed',
    image_to_image:'I2I',video_to_video:'V2V',audio_to_audio:'A2A',
    inpainting:'Inpaint',controlnet:'ControlNet',depth_estimation:'Depth',
    image_segmentation:'Segment',image_upscaling:'Upscale',face_restoration:'Face',
    object_detection:'Detect',video_interpolation:'Interp',video_upscaling:'V-Upscale',
    lip_sync:'Lip-sync',subtitle_generation:'Subs',video_dubbing:'Dub',
    style_transfer:'Style',image_to_3d:'I→3D',video_to_3d:'V→3D',
    model_3d_generation:'3D-Gen',model_3d_to_image:'3D→Img'
  };
  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,'');
}

/* ── tab / modal ─────────────────────────────────────── */
function switchTab(name,btn){
  document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
  document.querySelectorAll('.tab').forEach(b=>b.classList.remove('active'));
  document.getElementById('tab-'+name).classList.add('active');
  btn.classList.add('active');
}
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}

// showConfirm() and showAlert() are provided globally by base.html.

/* ── Global settings ─────────────────────────────────── */
let _defaultOffloadDir = './offload';
let _highlightCap = null;  // capability to highlight in local models list (from ?local_cap= param)
let _ds4Enabled = false;   // whether the ds4 (DeepSeek V4) engine is enabled globally

async function loadGlobalSettings(){
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/settings');
    if(r.ok){
      const d = await r.json();
      _defaultOffloadDir = d.offload?.directory || './offload';
      _ds4Enabled = !!(d.ds4 && d.ds4.enabled);
    }
  }catch{}
}

/* ── GGUF format toggle ──────────────────────────────── */
let _ggufMode = 'all';
document.querySelectorAll('.tog-btn').forEach(btn=>{
  btn.addEventListener('click',()=>{
    document.querySelectorAll('.tog-btn').forEach(b=>b.classList.remove('on'));
    btn.classList.add('on');
    _ggufMode = btn.dataset.val;
  });
});

/* ── chip toggles ────────────────────────────────────── */
document.querySelectorAll('.chip').forEach(c=>{
  c.addEventListener('click',()=>c.classList.toggle('on'));
});
// Component type chips are single-select: clicking one deselects the rest.
// Also switch the format toggle to "All" when a component is selected,
// since most components are safetensors and not GGUF.
document.querySelectorAll('#comp-type-chips .chip').forEach(c=>{
  c.addEventListener('click',()=>{
    const wasOn = c.classList.contains('on');
    document.querySelectorAll('#comp-type-chips .chip').forEach(x=>x.classList.remove('on'));
    if(!wasOn){
      c.classList.add('on');
      if(_ggufMode === 'gguf'){
        document.querySelectorAll('.tog-btn').forEach(b=>{
          b.classList.toggle('on', b.dataset.val === 'all');
        });
        _ggufMode = 'all';
      }
    }
  });
});
function getChips(id){return[...document.querySelectorAll('#'+id+' .chip.on')].map(c=>c.dataset.val)}

/* ── search ──────────────────────────────────────────── */
let _results   = [];
let _filesCache = {};
let _activeQuants = new Set();
let _cachedSearchIds = new Set();  // HF repo IDs (and GGUF source_repos) cached locally

function estimateModelSize(modelId, safetensorsSizeGb){
  // 1. Safetensors metadata from HF API (most accurate)
  if(safetensorsSizeGb != null) return safetensorsSizeGb;

  const id = modelId.toLowerCase();

  // 2. Xb parameter count in model name
  const match = id.match(/(\d+\.?\d*)b/);
  if(match){
    const params = parseFloat(match[1]);
    if(id.includes('q4') || id.includes('4bit')) return params * 0.5;
    if(id.includes('q8') || id.includes('8bit')) return params * 1.0;
    return params * 2; // assume FP16
  }

  // 3. Keyword heuristics for models without a param count in their name
  if(/\b(tiny|nano|micro)\b/.test(id)) return 0.1;
  if(/\bsmall\b/.test(id))             return 0.3;
  if(/\bbase\b/.test(id))              return 0.5;
  if(/\bmedium\b/.test(id))            return 1.0;
  if(/\blarge[-_v]/.test(id) && /v[23]/.test(id)) return 3.0; // large-v2/v3
  if(/\blarge\b/.test(id))             return 2.0;
  if(/\bxxl\b/.test(id))               return 10.0;
  if(/\bxl\b/.test(id))                return 5.0;
  if(/\bhuge\b/.test(id))              return 7.0;

  return null;
}

document.getElementById('search-q').addEventListener('keydown',e=>{if(e.key==='Enter')doSearch()});

async function doSearch(){
  const q   = document.getElementById('search-q').value.trim();
  const out = document.getElementById('search-results');
  const pipeline    = document.getElementById('filter-pipeline').value;
  const arch        = document.getElementById('filter-arch').value;
  const sort        = document.getElementById('filter-sort').value;
  const sizes       = getChips('size-chips').join(',');
  const compType    = getChips('comp-type-chips')[0] || '';
  _activeQuants  = new Set(getChips('quant-chips').map(v=>v.toUpperCase().split(' ')[0])); // strip ★

  // Get selected capability filters (from our custom chips)
  const selectedCaps = getChips('cap-chips');
  const capFilters = selectedCaps.filter(c=>!['function-calling','vision','reasoning','code','multilingual','roleplay','math'].includes(c));

  _filesCache = {};
  _results    = [];
  out.innerHTML = '<span class="muted small">Searching HuggingFace…</span>';

  const params = new URLSearchParams({q:q||' ', gguf_mode:_ggufMode, sort});
  if(pipeline)  params.append('pipeline_tag', pipeline);
  if(sizes)     params.append('sizes', sizes);
  if(arch)      params.append('arch', arch);
  if(compType)  params.append('component_type', compType);
  const caps = getChips('cap-chips');
  if(caps.length) params.append('capabilities', caps.join(','));

  try{
    const r = await fetch(ROOT_PATH + '/admin/api/hf-search?'+params);
    if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}
    _results = await r.json();
    
    // Client-side filter by detected capabilities if any custom caps selected
    if(capFilters.length > 0){
      _results = _results.filter(m=>{
        const modelCaps = m.capabilities || [];
        return capFilters.some(cf=>modelCaps.includes(cf));
      });
    }
    
    if(!_results.length){out.innerHTML='<span class="muted small">No results. Try different keywords or fewer filters.</span>';return}
    
    const vramTotal = _results[0]?.vram_total;
    const vramFree  = _results[0]?.vram_free;

    out.innerHTML = _results.map((m,i)=>{
      let vramDot = '';
      if(vramTotal){
        const estSize = estimateModelSize(m.id, m.safetensors_size_gb ?? null);
        if(estSize !== null){
          const color = estSize <= vramTotal*0.8 ? '#10b981' : estSize <= vramTotal*0.95 ? '#f59e0b' : '#ef4444';
          const freeHint = vramFree != null ? ` — ${vramFree}GB free now` : '';
          vramDot = `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:.35rem" title="Est. ~${estSize}GB / ${vramTotal}GB total${freeHint}"></span>`;
        }
      }
      const capBadges = fmtCapabilities(m.capabilities||[]);
      const isDownloaded = _cachedSearchIds.has(m.id);
      const downloadedBadge = isDownloaded ? `<span class="badge badge-ok" style="font-size:10px;padding:.1rem .35rem;margin-left:.4rem;vertical-align:middle">✓ local</span>` : '';
      const detectedRole = _detectCompRole(m.id.split('/').pop() || m.id);
      const compBadge = detectedRole ? `<span class="badge badge-user" style="font-size:10px;padding:.1rem .35rem;margin-left:.4rem;vertical-align:middle">${esc(detectedRole.label)}</span>` : '';
      return `
      <div style="padding:.75rem 0;border-bottom:1px solid var(--border)">
        <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:.5rem">
          <div style="min-width:0;flex:1">
            <div style="font-weight:500;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:flex;align-items:center"
                 title="${esc(m.id)}">${vramDot}${esc(m.id)}${downloadedBadge}${compBadge}</div>
            <div style="font-size:11px;color:var(--text-3);margin-top:.25rem;display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
              ${m.pipeline_tag?`<span class="badge badge-user">${esc(m.pipeline_tag)}</span>`:''}
              ${capBadges}
              <span>↓ ${fmtNum(m.downloads)}</span>
              <span>♥ ${fmtNum(m.likes)}</span>
            </div>
          </div>
          <div style="display:flex;gap:.3rem;flex-shrink:0">
            <button class="btn btn-ghost btn-sm" onclick="openInfo(${i})">Info</button>
            <button class="btn btn-ghost btn-sm" onclick="toggleFiles(${i})">▾ Files</button>
            <button class="btn btn-ghost btn-sm" onclick="addKnownFromResult(${i},this)" title="Add to your model list without downloading now — fetch it later with Re-download">+ Add known</button>
            <button class="btn btn-secondary btn-sm" onclick="dlFromResult(${i})">Download</button>
          </div>
        </div>
        <div id="fp-${i}" style="display:none;margin-top:.625rem;padding:.5rem .625rem;background:var(--raised);border-radius:6px">
          <span class="muted small">Loading…</span>
        </div>
      </div>`;
    }).join('');
  }catch(e){
    out.innerHTML='<span class="muted small">Error: '+esc(e.message)+'</span>';
  }
}

function dlFromResult(i){
  const m = _results[i];
  if(looksLikeGguf(m.id, '')){
    const selQuants = [..._activeQuants];
    const pattern = selQuants.length===1 ? selQuants[0]+'.gguf' : '.gguf';
    openDownloadFor(m.id, pattern);
  } else {
    openDownloadFor(m.id, '');  // triggers snapshot download
  }
}

// Best-effort model_type for a search result, from its detected capabilities.
function _resultModelType(m){
  if(looksLikeGguf(m.id, '')) return 'gguf_models';
  const {categories} = _capabilitiesToTypes(m.capabilities||[]);
  if(categories.size) return [...categories][0];
  return 'text_models';
}

// Register a searched model in config without downloading it now (same end state
// as "Free disk": appears as missing, with a working Re-download button).
async function addKnownFromResult(i, btn){
  const m = _results[i];
  if(!m) return;
  const isGguf = looksLikeGguf(m.id, '');
  if(!await showConfirm('Add without downloading',
      `Add "${m.id}" to your model list without downloading it now? You can fetch it later with the Re-download button.`,
      'Add known')) return;
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/model-add-known', {
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({model_id:m.id, source_repo:m.id, model_type:_resultModelType(m), is_gguf:isGguf})
    });
    const d = await r.json().catch(()=>({}));
    if(!r.ok || !d.success){ showAlert('Error: '+(d.detail||d.error||('HTTP '+r.status))); return; }
    _cachedSearchIds.add(m.id);
    if(btn){ btn.textContent = d.already ? '✓ Known' : '✓ Added'; btn.disabled = true; btn.style.color = 'var(--ok,#16a34a)'; }
    loadCachedModels();  // refresh the Local tab in the background
  }catch(e){ showAlert('Error: '+e.message); }
}

/* ── files panel ─────────────────────────────────────── */
async function toggleFiles(i){
  const panel = document.getElementById('fp-'+i);
  if(panel.style.display!=='none'){panel.style.display='none';return}
  panel.style.display='block';
  const modelId = _results[i].id;
  if(_filesCache[modelId]){renderFiles(panel,modelId,_filesCache[modelId]);return}
  panel.innerHTML='<span class="muted small">Fetching file list…</span>';
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/hf-model-files?model_id='+encodeURIComponent(modelId));
    if(!r.ok)throw new Error((await r.json()).detail||r.statusText);
    const files = await r.json();
    _filesCache[modelId]=files;
    renderFiles(panel,modelId,files);
  }catch(e){panel.innerHTML='<span class="muted small">Error: '+esc(e.message)+'</span>'}
}

function renderFiles(panel,modelId,files){
  const shown = _activeQuants.size>0
    ? files.filter(f=>{
        const q=(f.quant||'').toUpperCase();
        return _activeQuants.has(q)||[..._activeQuants].some(aq=>f.name.toUpperCase().includes(aq));
      })
    : files;
  if(!shown.length){
    panel.innerHTML=`<span class="muted small">${_activeQuants.size?'No matching quant files — deselect quant chips to see all.':'No GGUF files found.'}</span>`;
    return;
  }
  panel.innerHTML=
    '<table style="width:100%;border-collapse:collapse;font-size:12px">'+
    '<thead><tr style="color:var(--text-3);font-size:10px;text-transform:uppercase;letter-spacing:.04em">'+
    '<th style="text-align:left;padding:.2rem .25rem">File</th>'+
    '<th style="text-align:center;padding:.2rem .25rem">Quant</th>'+
    '<th style="text-align:right;padding:.2rem .25rem">Size</th>'+
    '<th style="text-align:right;padding:.2rem .25rem">≈ VRAM</th>'+
    '<th></th></tr></thead><tbody>'+
    shown.map(f=>
      '<tr style="border-top:1px solid var(--border)">'+
      `<td style="padding:.3rem .25rem;font-family:monospace;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(f.name)}">${esc(f.name)}</td>`+
      `<td style="text-align:center;padding:.3rem .25rem">${f.quant?'<span class="badge badge-admin">'+esc(f.quant)+'</span>':''}</td>`+
      `<td style="text-align:right;padding:.3rem .25rem;white-space:nowrap;color:var(--text-2)">${fmtGB(f.size_gb)}</td>`+
      `<td style="text-align:right;padding:.3rem .25rem;white-space:nowrap;color:var(--text-3)">${f.vram_gb?'~'+fmtGB(f.vram_gb):''}</td>`+
      `<td style="padding:.3rem .25rem;text-align:right"><button class="btn btn-secondary btn-sm" onclick="openDownloadFor('${esc(modelId)}','${esc(f.name)}')">↓</button></td>`+
      '</tr>'
    ).join('')+
    '</tbody></table>';
}

/* ── info drawer ─────────────────────────────────────── */
function openInfo(i){
  const m = _results[i];
  document.getElementById('info-title').textContent = m.id;
  document.getElementById('info-body').innerHTML = '<span class="muted small">Loading…</span>';
  document.getElementById('info-overlay').style.display='block';
  document.getElementById('info-drawer').classList.add('open');
  document.body.style.overflow='hidden';
  loadInfo(m.id);
}
function closeInfo(){
  document.getElementById('info-overlay').style.display='none';
  document.getElementById('info-drawer').classList.remove('open');
  document.body.style.overflow='';
}

async function loadInfo(modelId){
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/hf-model-info?model_id='+encodeURIComponent(modelId));
    if(!r.ok)throw new Error((await r.json()).detail||r.statusText);
    renderInfo(await r.json());
  }catch(e){
    document.getElementById('info-body').innerHTML='<span class="muted small">Error: '+esc(e.message)+'</span>';
  }
}

function renderInfo(d){
  const escId = esc(d.id);
  let html=`
    <div style="display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:1.25rem">
      <a href="https://huggingface.co/${escId}" target="_blank" class="btn btn-ghost btn-sm">HuggingFace ↗</a>
      <button class="btn btn-secondary btn-sm" onclick="openDownloadFor('${escId}','${d.gguf_files&&d.gguf_files.length?'.gguf':''}')">Download repo</button>
      ${d.gated?'<span class="badge badge-admin" style="align-self:center">Gated</span>':''}
      ${d.private?'<span class="badge badge-user" style="align-self:center">Private</span>':''}
    </div>
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:.625rem 1.25rem;font-size:13px;margin-bottom:1.25rem">
      ${d.pipeline_tag?`<div><div class="fl" style="margin-bottom:.2rem">Type</div>${esc(d.pipeline_tag)}</div>`:''}
      ${d.params_label?`<div><div class="fl" style="margin-bottom:.2rem">Parameters</div>${esc(d.params_label)}</div>`:''}
      <div><div class="fl" style="margin-bottom:.2rem">Downloads</div>↓ ${fmtNum(d.downloads)}</div>
      <div><div class="fl" style="margin-bottom:.2rem">Likes</div>♥ ${fmtNum(d.likes)}</div>
      ${d.license?`<div><div class="fl" style="margin-bottom:.2rem">License</div>${esc(d.license)}</div>`:''}
      ${d.last_modified?`<div><div class="fl" style="margin-bottom:.2rem">Updated</div>${fmtDate(d.last_modified)}</div>`:''}
      ${d.author?`<div><div class="fl" style="margin-bottom:.2rem">Author</div>${esc(d.author)}</div>`:''}
      ${d.file_count?`<div><div class="fl" style="margin-bottom:.2rem">Files in repo</div>${d.file_count}</div>`:''}
      ${d.base_model?`<div style="grid-column:span 2"><div class="fl" style="margin-bottom:.2rem">Base model</div><code style="font-size:11px">${esc(d.base_model)}</code></div>`:''}
      ${d.language&&d.language.length?`<div style="grid-column:span 2"><div class="fl" style="margin-bottom:.2rem">Languages</div>${d.language.slice(0,8).map(esc).join(', ')}</div>`:''}
    </div>`;

  const relevantTags = (d.tags||[]).filter(t=>!['transformers','safetensors','gguf','pytorch','jax'].includes(t));
  if(relevantTags.length){
    html+=`<div style="margin-bottom:1.25rem">
      <div class="fl" style="margin-bottom:.4rem">Tags</div>
      ${relevantTags.slice(0,30).map(t=>`<span class="badge badge-user" style="margin:.1rem">${esc(t)}</span>`).join('')}
    </div>`;
  }

  if(d.gguf_files&&d.gguf_files.length){
    html+=`<div class="card-title">GGUF files (${d.gguf_files.length})</div>
    <table style="width:100%;border-collapse:collapse;font-size:12px">
      <thead><tr style="color:var(--text-3);font-size:10px;text-transform:uppercase;letter-spacing:.04em">
        <th style="text-align:left;padding:.25rem .25rem">File</th>
        <th style="text-align:center;padding:.25rem .25rem">Quant</th>
        <th style="text-align:right;padding:.25rem .25rem">Size</th>
        <th style="text-align:right;padding:.25rem .25rem">≈ VRAM</th>
        <th></th>
      </tr></thead><tbody>
      ${d.gguf_files.map(f=>`
        <tr style="border-top:1px solid var(--border)">
          <td style="padding:.35rem .25rem;font-family:monospace;font-size:11px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(f.name)}">${esc(f.name)}</td>
          <td style="text-align:center;padding:.35rem .25rem">${f.quant?`<span class="badge badge-admin">${esc(f.quant)}</span>`:''}</td>
          <td style="text-align:right;padding:.35rem .25rem;white-space:nowrap;color:var(--text-2)">${fmtGB(f.size_gb)}</td>
          <td style="text-align:right;padding:.35rem .25rem;white-space:nowrap;color:var(--text-3)">${f.vram_gb?'~'+fmtGB(f.vram_gb):''}</td>
          <td style="padding:.35rem .25rem;text-align:right"><button class="btn btn-secondary btn-sm" onclick="openDownloadFor('${escId}','${esc(f.name)}')">↓</button></td>
        </tr>`).join('')}
      </tbody>
    </table>`;
  } else {
    html+=`<div class="muted small">No GGUF files in this repo.</div>`;
  }

  document.getElementById('info-title').textContent = d.id;
  document.getElementById('info-body').innerHTML = html;
}

/* ── download modal ──────────────────────────────────── */
function looksLikeGguf(modelId, filePattern){
  if(filePattern && filePattern.toLowerCase().includes('.gguf')) return true;
  if(_ggufMode==='gguf') return true;
  if(modelId.toLowerCase().includes('gguf')) return true;
  return false;
}

function openDownloadFor(modelId, filePattern){
  // Always reset to form view so the user can start a new download even while
  // one is already running. The existing download continues on the server; the
  // strip tracks it via polling independently of the EventSource.
  if(_dlEs){ _dlEs.close(); _dlEs=null; }
  _dlSessionId=null;
  _dlQuantFiles=[];
  document.getElementById('dl-form').style.display='block';
  document.getElementById('dl-progress').style.display='none';
  document.getElementById('dl-quant-row').style.display='none';
  document.getElementById('dl-start-btn').textContent='Download';
  const _browseBtn = document.getElementById('dl-browse-btn');
  if(_browseBtn){ _browseBtn.textContent='Browse'; _browseBtn.disabled=false; }
  _dlStatus('');

  document.getElementById('dl-id').value = modelId || '';
  document.getElementById('dl-pattern').value = filePattern || '';

  const isGguf = looksLikeGguf(modelId, filePattern);
  if(!modelId){
    // Blank form — show pattern row as default
    document.getElementById('dl-pattern-row').style.display = 'block';
    document.getElementById('dl-snapshot-note').style.display = 'none';
  } else if(!isGguf && modelId.includes('/')){
    // Looks like a full HF non-GGUF repo
    document.getElementById('dl-pattern-row').style.display = 'none';
    document.getElementById('dl-snapshot-note').style.display = 'flex';
  } else {
    document.getElementById('dl-pattern-row').style.display = 'block';
    document.getElementById('dl-snapshot-note').style.display = 'none';
  }
  openModal('dl-modal');
  // Auto-browse if we have a specific HF repo ID so the quant picker appears immediately
  if(modelId && modelId.includes('/')){
    browseHfFiles();
  }
}

/* progress helpers */
function fmtBytes(n){if(!n&&n!==0)return'—';if(n>=1e9)return(n/1e9).toFixed(2)+' GB';if(n>=1e6)return(n/1e6).toFixed(1)+' MB';if(n>=1e3)return(n/1e3).toFixed(0)+' KB';return n+' B'}
function fmtRate(bps){return bps?(fmtBytes(bps)+'/s'):''}
function fmtEta(s){if(s===null||s===undefined)return'';s=Math.round(s);if(s<60)return s+'s left';const m=Math.floor(s/60),ss=s%60;if(m<60)return m+':'+String(ss).padStart(2,'0')+' left';return Math.floor(m/60)+'h '+(m%60)+'m left'}

let _dlEs = null;
let _dlDone = false;
let _dlSessionId = null;
let _dlQuantFiles = [];

function _dlReset(){
  const bar = document.getElementById('dl-bar');
  bar.style.transition = 'none';
  bar.style.width = '0%';
  document.getElementById('dl-filename').textContent='Preparing…';
  document.getElementById('dl-bytes').textContent='—';
  document.getElementById('dl-speed').textContent='—';
  document.getElementById('dl-eta').textContent='—';
  document.getElementById('dl-pct').textContent='0%';
  document.getElementById('dl-log').style.display='none';
  document.getElementById('dl-log').innerHTML='';
  requestAnimationFrame(()=>{ bar.style.transition=''; });
}

function _dlLog(msg){
  const el=document.getElementById('dl-log');
  el.style.display='block';
  const d=document.createElement('div');
  d.textContent=msg;
  el.appendChild(d);
  el.scrollTop=el.scrollHeight;
}

function showDownloadError(msg){
  if(_dlEs){_dlEs.close();_dlEs=null}
  document.getElementById('dl-form').style.display='block';
  document.getElementById('dl-progress').style.display='none';
  showAlert('Download error: '+msg);
}

function handleProgressEvent(evt){
  if(evt.type==='start'){
    document.getElementById('dl-filename').textContent=evt.filename||'Downloading…';
    document.getElementById('dl-bytes').textContent='0 / '+fmtBytes(evt.total);
    document.getElementById('dl-bar').style.width='0%';
  }else if(evt.type==='progress'){
    const pct=evt.percent||0;
    document.getElementById('dl-bar').style.width=pct+'%';
    document.getElementById('dl-pct').textContent=pct.toFixed(1)+'%';
    document.getElementById('dl-bytes').textContent=fmtBytes(evt.downloaded)+' / '+fmtBytes(evt.total);
    document.getElementById('dl-speed').textContent=fmtRate(evt.rate);
    document.getElementById('dl-eta').textContent=fmtEta(evt.eta);
    if(evt.filename) document.getElementById('dl-filename').textContent=evt.filename;
  }else if(evt.type==='info'){
    _dlLog(evt.message);
  }else if(evt.type==='done'){
    _dlDone=true;
    if(_dlEs){_dlEs.close();_dlEs=null}
    document.getElementById('dl-bar').style.width='100%';
    document.getElementById('dl-pct').textContent='100%';
    document.getElementById('dl-filename').textContent='Download complete!';
    refreshLocal();
    setTimeout(()=>{
      closeModal('dl-modal');
      document.getElementById('dl-form').style.display='block';
      document.getElementById('dl-progress').style.display='none';
      document.getElementById('dl-quant-row').style.display='none';
      document.getElementById('dl-pattern-row').style.display='block';
      document.getElementById('dl-start-btn').textContent='Download';
      _dlQuantFiles=[];
      _dlReset();
    },1800);
  }else if(evt.type==='error'){
    _dlDone=true;
    showDownloadError(evt.message);
  }else if(evt.type==='cancelled'){
    _dlDone=true;
    showDownloadCancelled();
  }
  // keepalive: ignore
}

function minimizeDownload(){
  closeModal('dl-modal');
}

async function reopenDownload(session_id){
  openModal('dl-modal');
  document.getElementById('dl-form').style.display='none';
  document.getElementById('dl-progress').style.display='block';

  // Already tracking this session with a live EventSource — just show the modal.
  if(_dlSessionId===session_id && _dlEs) return;

  // Different session or page reload: seed state from status API, then reconnect.
  if(_dlEs){_dlEs.close();_dlEs=null;}
  _dlSessionId=session_id;
  _dlDone=false;
  _dlReset();

  try{
    const r=await fetch(ROOT_PATH + '/admin/api/downloads');
    if(r.ok){
      const all=await r.json();
      const s=all.find(d=>d.session_id===session_id);
      if(s){
        document.getElementById('dl-filename').textContent=s.filename||s.model_id||'Downloading…';
        const pct=s.percent||0;
        const bar=document.getElementById('dl-bar');
        bar.style.transition='none';
        bar.style.width=pct+'%';
        requestAnimationFrame(()=>{bar.style.transition='';});
        document.getElementById('dl-pct').textContent=pct.toFixed(1)+'%';
        if(s.downloaded!=null&&s.total!=null)
          document.getElementById('dl-bytes').textContent=fmtBytes(s.downloaded)+' / '+fmtBytes(s.total);
        if(s.rate) document.getElementById('dl-speed').textContent=fmtRate(s.rate);
        if(s.eta!=null) document.getElementById('dl-eta').textContent=fmtEta(s.eta);
        if(s.status==='done'){handleProgressEvent({type:'done'});return;}
        if(s.status==='cancelled'){_dlDone=true;showDownloadCancelled();return;}
        if(s.status==='error'){showDownloadError(s.error||'Download failed');return;}
      }
    }
  }catch{}

  // Connect a fresh EventSource to stream remaining progress events.
  _dlEs=new EventSource(ROOT_PATH + '/admin/api/download-stream/'+session_id);
  _dlEs.onmessage=function(e){
    try{handleProgressEvent(JSON.parse(e.data))}catch{}
  };
  _dlEs.onerror=function(){
    if(_dlDone) return;
    if(_dlEs&&_dlEs.readyState===EventSource.CLOSED) return;
    showDownloadError('Connection to download stream lost');
  };
}

function showDownloadCancelled(){
  if(_dlEs){_dlEs.close();_dlEs=null}
  document.getElementById('dl-form').style.display='block';
  document.getElementById('dl-progress').style.display='none';
}

async function stopDownload(session_id){
  if(!confirm('Cancel this download?')) return;
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/download-cancel/'+session_id, {method:'POST'});
    if(!r.ok){
      let detail = r.status+' '+r.statusText;
      try{ const j = await r.json(); if(j&&j.detail) detail = j.detail; }catch{}
      showAlert('Could not cancel download: '+detail);
      return;
    }
    if(_dlSessionId===session_id){
      _dlDone=true;
      showDownloadCancelled();
    }
    pollDownloads(); // refresh the active-downloads strip immediately
  }catch(e){
    showAlert('Could not cancel download: '+e.message);
  }
}

/* ── quant picker ──────────────────────────────────────── */

function _quantLabel(fname){
  // Reuse the quant extractor; fall back to the bare filename
  const q = _ggufQuant(fname);
  return q || fname.replace(/\.gguf$/i,'');
}

const _RECOMMENDED_QUANTS = new Set(['Q4_K_M','Q5_K_M','Q6_K','Q4_K_S','Q5_K_S','IQ4_NL','IQ4_XS']);

function _dlSelectAll(val){
  document.querySelectorAll('#dl-quant-list input[type=checkbox]').forEach(cb=>cb.checked=val);
}

function renderQuantPicker(ggufFiles, initialPattern){
  _dlQuantFiles = ggufFiles;
  // Sort: recommended first, then by size descending
  const sorted = [...ggufFiles].sort((a,b)=>{
    const ar = _RECOMMENDED_QUANTS.has(_quantLabel(a.name).toUpperCase());
    const br = _RECOMMENDED_QUANTS.has(_quantLabel(b.name).toUpperCase());
    if(ar!==br) return br-ar;
    return (b.size||0)-(a.size||0);
  });
  const pat = (initialPattern||'').toLowerCase();
  const list = document.getElementById('dl-quant-list');
  list.innerHTML = sorted.map((f,i)=>{
    const label = _quantLabel(f.name);
    const upper = label.toUpperCase();
    const isRec = _RECOMMENDED_QUANTS.has(upper);
    const sz = f.size ? fmtBytes(f.size) : '?';
    // Pre-check: recommended ones by default, or whatever matches the initial pattern
    const checked = pat ? f.name.toLowerCase().includes(pat.replace('*','')) : isRec;
    return `<label style="display:flex;align-items:center;gap:.6rem;padding:.3rem .25rem;border-radius:4px;cursor:pointer;font-size:13px" class="_dl-quant-row">
      <input type="checkbox" data-fname="${esc(f.name)}" ${checked?'checked':''}>
      <span style="flex:1;font-family:monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(f.name)}">${esc(label)}</span>
      ${isRec?'<span class="badge badge-user" style="font-size:9px;padding:.1rem .3rem">rec</span>':''}
      <span style="color:var(--text-2);font-size:11px;white-space:nowrap;flex-shrink:0">${esc(sz)}</span>
    </label>`;
  }).join('');
  // Show picker, hide manual pattern row
  document.getElementById('dl-quant-row').style.display = 'block';
  document.getElementById('dl-pattern-row').style.display = 'none';
  document.getElementById('dl-snapshot-note').style.display = 'none';
  const note = document.getElementById('dl-quant-note');
  const rec = sorted.filter(f=>_RECOMMENDED_QUANTS.has(_quantLabel(f.name).toUpperCase())).length;
  note.textContent = rec
    ? `${rec} recommended quantization${rec>1?'s':''} pre-selected (marked "rec"). Larger Q = better quality, more VRAM.`
    : `${ggufFiles.length} quantization${ggufFiles.length!==1?'s':''} available. Select which to download.`;
  document.getElementById('dl-start-btn').textContent = 'Download selected';
}

function _dlStatus(msg, color){
  const el = document.getElementById('dl-browse-status');
  if(!el) return;
  el.textContent = msg;
  el.style.color = color || 'var(--text-2)';
}

async function browseHfFiles(){
  const id = document.getElementById('dl-id').value.trim();
  if(!id){
    _dlStatus('Enter a HuggingFace repo ID first, e.g. bartowski/Llama-3.1-8B-Instruct-GGUF', 'var(--red,#f87171)');
    document.getElementById('dl-id').focus();
    return;
  }
  if(!id.includes('/')){
    _dlStatus('Use the format owner/model-name (e.g. bartowski/Llama-3.1-8B-Instruct-GGUF)', 'var(--red,#f87171)');
    document.getElementById('dl-quant-row').style.display='none';
    const isGguf = looksLikeGguf(id,'');
    document.getElementById('dl-pattern-row').style.display = isGguf?'block':'none';
    document.getElementById('dl-snapshot-note').style.display = isGguf?'none':'flex';
    return;
  }
  const btn = document.getElementById('dl-browse-btn');
  btn.textContent='Fetching…'; btn.disabled=true;
  _dlStatus('Fetching file list from HuggingFace…');
  const controller = new AbortController();
  const _browseTimeout = setTimeout(()=>controller.abort(), 20000);
  try{
    const r = await fetch(ROOT_PATH+'/admin/api/hf-files?repo_id='+encodeURIComponent(id), {signal:controller.signal});
    if(!r.ok){
      let detail='Request failed (HTTP '+r.status+')';
      try{ detail=(await r.json()).detail||detail; }catch(_){}
      throw new Error(detail);
    }
    const {files, error} = await r.json();
    if(error){ throw new Error(error); }
    const gguf = (files||[]).filter(f=>f.name.toLowerCase().endsWith('.gguf'));
    if(gguf.length){
      _dlStatus(gguf.length+' GGUF file'+(gguf.length!==1?'s':'')+' found — select quantizations below');
      renderQuantPicker(gguf, document.getElementById('dl-pattern').value);
    } else {
      _dlStatus('No GGUF files — will download as full repository snapshot');
      _dlQuantFiles=[];
      document.getElementById('dl-quant-row').style.display='none';
      document.getElementById('dl-pattern-row').style.display='none';
      document.getElementById('dl-snapshot-note').style.display='flex';
      document.getElementById('dl-start-btn').textContent='Download';
    }
  }catch(e){
    const errMsg = e.name==='AbortError' ? 'Timed out — HuggingFace did not respond in 20s' : e.message;
    _dlStatus('Error: '+errMsg, 'var(--red,#f87171)');
    _dlQuantFiles=[];
    document.getElementById('dl-quant-row').style.display='none';
    document.getElementById('dl-pattern-row').style.display='block';
    document.getElementById('dl-snapshot-note').style.display='none';
  }finally{
    clearTimeout(_browseTimeout);
    btn.textContent='Browse'; btn.disabled=false;
  }
}

async function _startSingleDownload(modelId, filePattern){
  /* Starts one download session, returns the session_id or null on error. */
  const r = await fetch(ROOT_PATH+'/admin/api/model-download',{
    method:'POST', headers:{'Content-Type':'application/json'},
    body: JSON.stringify({model_id:modelId, file_pattern:filePattern||null})
  });
  if(!r.ok){ const e=await r.json(); throw new Error(e.detail||'Request failed'); }
  return (await r.json()).session_id;
}

function _attachEventSource(session_id){
  if(_dlEs){_dlEs.close();_dlEs=null;}
  _dlSessionId=session_id;
  _dlEs=new EventSource(ROOT_PATH+'/admin/api/download-stream/'+session_id);
  _dlEs.onmessage=function(e){ try{handleProgressEvent(JSON.parse(e.data))}catch{} };
  _dlEs.onerror=function(){
    if(_dlDone) return;
    if(_dlEs&&_dlEs.readyState===EventSource.CLOSED) return;
    showDownloadError('Connection to download stream lost');
  };
}

// Add the model in the download form to the "to download" wishlist without
// fetching it now. It then appears in the model list as non-configured / to be
// downloaded, with a Download button to fetch it later.
async function markDownloadFromForm(){
  const id = document.getElementById('dl-id').value.trim();
  if(!id){ document.getElementById('dl-id').focus(); return; }
  const patEl = document.getElementById('dl-pattern');
  const pattern = patEl ? patEl.value.trim() : '';
  const isGguf = looksLikeGguf(id, pattern);
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/model-mark-download', {
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({model_id:id, source_repo:id, is_gguf:isGguf})
    });
    const d = await r.json().catch(()=>({}));
    if(d.success){ closeModal('dl-modal'); refreshLocal(); }
    else showAlert('Error: '+(d.detail||d.error||'could not add to list'));
  }catch(e){ showAlert('Error: '+e.message); }
}

async function startDownloadFromForm(){
  const id = document.getElementById('dl-id').value.trim();
  if(!id){ document.getElementById('dl-id').focus(); return; }

  // For HF repo IDs: always browse first so the user can pick specific files.
  // Skip if the quant picker is already visible (Browse was already done).
  const quantRowVisible = document.getElementById('dl-quant-row').style.display!=='none';
  const looksLikeHfRepo = id.includes('/') && !id.startsWith('http');
  if(!quantRowVisible && looksLikeHfRepo){
    await browseHfFiles();
    // If Browse produced a quant picker, stop here — let the user select files.
    if(document.getElementById('dl-quant-row').style.display!=='none'){
      return;
    }
    // No GGUF files found (non-GGUF repo or browse error) — fall through to snapshot download.
  }

  const pickerVisible = document.getElementById('dl-quant-row').style.display!=='none';
  if(pickerVisible){
    // Multi-file download from quant picker
    const checked = [...document.querySelectorAll('#dl-quant-list input[type=checkbox]:checked')];
    if(!checked.length){ showAlert('Select at least one quantization to download.'); return; }
    _dlDone=false; _dlSessionId=null; _dlReset();

    if(checked.length===1){
      // Single selection → show progress in modal
      document.getElementById('dl-form').style.display='none';
      document.getElementById('dl-progress').style.display='block';
      try{
        const sid = await _startSingleDownload(id, checked[0].dataset.fname);
        startPolling();
        _attachEventSource(sid);
      }catch(e){ showDownloadError(e.message); }
    } else {
      // Multiple selections → start all, show in strip
      document.getElementById('dl-form').style.display='none';
      document.getElementById('dl-progress').style.display='block';
      document.getElementById('dl-filename').textContent=`Starting ${checked.length} downloads…`;
      try{
        const sids = [];
        for(const cb of checked){
          const sid = await _startSingleDownload(id, cb.dataset.fname);
          sids.push(sid);
        }
        startPolling();
        // Attach EventSource to the first one, rest tracked in strip
        _attachEventSource(sids[0]);
        document.getElementById('dl-filename').textContent=`Downloading ${checked.length} files — see strip below`;
      }catch(e){ showDownloadError(e.message); }
    }
  } else {
    // Manual / snapshot mode
    await startDownload();
  }
}

async function startDownload(){
  const id=document.getElementById('dl-id').value.trim();
  if(!id){document.getElementById('dl-id').focus();return}
  _dlDone=false;
  _dlSessionId=null;
  _dlReset();
  document.getElementById('dl-form').style.display='none';
  document.getElementById('dl-progress').style.display='block';
  try{
    const sid = await _startSingleDownload(id, document.getElementById('dl-pattern').value||null);
    _dlSessionId=sid;
    startPolling();
    _attachEventSource(sid);
  }catch(e){showDownloadError(e.message)}
}

/* ── active downloads strip ──────────────────────────── */
let _pollTimer = null;

async function pollDownloads(){
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/downloads');
    if(!r.ok) return;
    const all = await r.json();
    const active = all.filter(d=>d.status!=='done'&&d.status!=='error'&&d.status!=='cancelled');
    const strip = document.getElementById('dl-strip');
    const list  = document.getElementById('dl-strip-list');
    if(!active.length){
      strip.style.display='none';
      stopPolling();
      return;
    }
    startPolling(); // keep the timer alive (handles page-load case where timer was never set)
    strip.style.display='block';
    list.innerHTML = active.map(d=>{
      const pct = d.percent||0;
      const name = d.filename||d.model_id||'';
      const spd  = d.rate?fmtRate(d.rate):'';
      const eta  = d.eta!=null?fmtEta(d.eta):'';
      const sid  = d.session_id||'';
      return `<div style="display:flex;align-items:center;gap:.75rem;padding:.2rem 0">
        <div style="flex:1;min-width:0">
          <div style="font-size:12px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.model_id)}</div>
          <div style="font-size:11px;color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(name)}</div>
          <div class="progress" style="margin-top:.3rem;margin-bottom:0;height:3px"><div class="progress-fill" style="width:${pct}%"></div></div>
        </div>
        <div style="font-size:11px;color:var(--text-2);text-align:right;white-space:nowrap;flex-shrink:0">
          <div>${pct.toFixed(1)}%</div>
          ${spd?`<div>${esc(spd)}</div>`:''}
          ${eta?`<div class="muted">${esc(eta)}</div>`:''}
          ${sid?`<div style="margin-top:.25rem;display:flex;gap:.3rem">
            <button class="btn btn-ghost" style="font-size:10px;padding:.1rem .35rem;line-height:1.4" onclick="reopenDownload('${sid}')" title="Open download progress">↗ View</button>
            <button class="btn btn-ghost" style="font-size:10px;padding:.1rem .35rem;line-height:1.4;color:var(--error,#e55)" onclick="stopDownload('${sid}')" title="Cancel this download">✕ Stop</button>
          </div>`:''}
        </div>
      </div>`;
    }).join('<div style="border-top:1px solid var(--border);margin:.3rem 0"></div>');
  }catch{}
}

function startPolling(){
  if(_pollTimer) return;
  _pollTimer = setInterval(pollDownloads, 2000);
  pollDownloads();
}

function stopPolling(){
  if(_pollTimer){ clearInterval(_pollTimer); _pollTimer=null; }
}

// Single one-shot check on page load; polling only runs while downloads are active.
pollDownloads();

/* ── cache stats & local models ──────────────────────── */
async function loadCacheStats(){
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/cache-stats');
    if(!r.ok) return;
    const s = await r.json();
    document.getElementById('stat-hf-size').textContent  = fmtBytes(s.hf_bytes);
    document.getElementById('stat-hf-count').textContent = s.hf_models+' model'+(s.hf_models!==1?'s':'');
    document.getElementById('stat-hf-free').textContent = s.hf_disk_free_bytes!=null ? fmtBytes(s.hf_disk_free_bytes)+' free' : '—';
    document.getElementById('stat-gguf-size').textContent = fmtBytes(s.gguf_bytes);
    document.getElementById('stat-gguf-count').textContent = s.gguf_files+' file'+(s.gguf_files!==1?'s':'');
    document.getElementById('stat-gguf-free').textContent = s.gguf_disk_free_bytes!=null ? fmtBytes(s.gguf_disk_free_bytes)+' free' : '—';
    document.getElementById('stat-total-size').textContent = fmtBytes((s.hf_bytes||0)+(s.gguf_bytes||0));
  }catch{}
}

let _localModels = [];
let _ggufFiles = [];
let _hfModels = [];

// Engine/card hardware info (fetched once); used to tag models with the card they
// run on when more than one engine is configured.
let _engineNames = [];
let _defaultEngine = '';
async function _loadEngineInfo(){
  // Live engine names from the front (covers auto-detected engines, not just those
  // declared in engine_specs); default_engine still comes from settings.
  try {
    const er = await fetch(ROOT_PATH + '/admin/api/engines');
    if (er.ok) _engineNames = ((await er.json()).engines || []).map(e => e.name);
  } catch(e) {}
  try {
    const d = await (await fetch(ROOT_PATH + '/admin/api/settings')).json();
    if (!_engineNames.length) _engineNames = (d.server && d.server.engine_names) || [];
    _defaultEngine = (d.server && d.server.default_engine) || '';
  } catch(e) {}
}
// Compact card tag for a model config. Pinned engines show as-is (with 📌);
// otherwise the engine is inferred from the model's format (transformers/ds4 →
// nvidia; gguf/whisper → the default engine, or "any"). Hidden when ≤1 engine, so
// it never widens single-card setups.
function _engineTagHtml(m, s){
  if(!_engineNames || _engineNames.length < 2) return '';
  let eng = ((s && s.engine) || '').trim();
  let pinned = !!eng;
  if(!eng){
    const path = (((m && (m.path || m.id || m.filename)) || '') + '').toLowerCase();
    const isGguf = path.endsWith('.gguf') || path.includes('gguf');
    const isWhisper = ((s && s.backend) || '') === 'whisper-server';
    const isDs4 = path.includes('deepseek-v4');
    if(isDs4 || (!isGguf && !isWhisper)) eng = 'nvidia';   // ds4/transformers → nvidia
    else eng = _defaultEngine || 'any';                     // gguf/whisper → default
  }
  const lc = eng.toLowerCase();
  const color = (lc.includes('nv')) ? '#76b900'
              : (lc.includes('rad') || lc.includes('amd')) ? '#ed1c24'
              : 'var(--text-3)';
  // Cross-GPU split: the model runs on `eng` (its lead card) but spills onto the
  // other GPU(s) for extra VRAM. Show it in the tag so the list makes the split
  // visible, e.g. "nvidia ⇄split 📌".
  const split = !!(s && s.gpu_split);
  if(split){
    const ratio = ((s && s.tensor_split) || '').trim();
    const title = 'Loads on ' + eng + ', split across all GPUs ('
                + (ratio ? ('ratio ' + ratio) : 'auto by free VRAM') + ')';
    return `<span class="badge" title="${esc(title)}" style="font-size:9px;padding:.05rem .3rem;margin:.1rem .1rem 0 0;vertical-align:middle;border:1px solid ${color};color:${color};background:transparent">${esc(eng)} ⇄split${pinned?' 📌':''}</span>`;
  }
  const title = pinned ? ('Pinned to engine: ' + eng) : ('Runs on: ' + eng + ' (auto)');
  return `<span class="badge" title="${esc(title)}" style="font-size:9px;padding:.05rem .3rem;margin:.1rem .1rem 0 0;vertical-align:middle;border:1px solid ${color};color:${color};background:transparent">${esc(eng)}${pinned?' 📌':''}</span>`;
}

function _renderConfigPills(idx, m) {
  const configs = m.configs || [];
  if (!configs.length) return '';
  const pills = configs.map((c, cfgIdx) => {
    const label = (c.settings && (c.settings.config_name || c.settings.alias)) || `Config ${cfgIdx + 1}`;
    return `<span class="badge badge-user" style="font-size:10px;cursor:pointer;vertical-align:middle;margin:.1rem .1rem 0 0" onclick="openCfgModal(${idx},${cfgIdx})" title="Edit this configuration">${esc(label)}</span>${_engineTagHtml(m, c.settings)}`;
  }).join('');
  const addPill = `<span class="badge" style="font-size:10px;cursor:pointer;vertical-align:middle;margin:.1rem 0 0 0;background:var(--raised);border:1px dashed var(--border);color:var(--text-2)" onclick="openCfgModalNew(${idx})" title="Add another configuration for this model">+ Config</span>`;
  return `<br style="line-height:.5rem">${pills}${addPill}`;
}

// ── Component file helpers ────────────────────────────────────────────────────
const _COMP_ROLES = [
  {key:'mmproj',      label:'mmproj (vision)', pat:/mm[._\-]?proj/i},
  {key:'vae',         label:'VAE',         pat:/vae|(^|[^a-z])ae\.(gguf|safetensors)/i},
  {key:'t5xxl',       label:'T5-XXL',      pat:/t5.?xxl|t5.?v1_1/i},
  {key:'clip_l',      label:'CLIP-L',      pat:/clip[_\-]l(?![_\-g])/i},
  {key:'clip_g',      label:'CLIP-G',      pat:/clip[_\-]g(?![_\-]v)/i},
  {key:'clip_vision', label:'CLIP Vision', pat:/clip[_\-]vi(?:s|t)/i},
  {key:'lora',        label:'LoRA',        pat:/\blora\b/i},
  {key:'encoder',     label:'Encoder',     pat:/encoder|text.?enc/i},
];

function _detectCompRole(filename) {
  for(const r of _COMP_ROLES) if(r.pat.test(filename)) return r;
  return null;
}

function _isCompFile(filename) {
  return _COMP_ROLES.some(r => r.pat.test(filename));
}

function renderComponentsList(ggufFiles) {
  // Files from the local cache (GGUF cache dir — any extension: .gguf, .safetensors, etc.)
  const fileComps = ggufFiles.filter(f => _isCompFile(f.filename));
  // HF repos whose repo-name looks like a component
  const hfComps = _hfModels.filter(m => _isCompFile(m.id.split('/').pop() || m.id));
  const total = fileComps.length + hfComps.length;
  const badge = document.getElementById('comp-badge');
  if(badge) badge.textContent = total ? `(${total})` : '';
  const el = document.getElementById('comp-list');
  if(!el) return;
  if(!total){
    el.innerHTML = '<span class="muted small">No component files detected. Use Download to fetch VAEs, text encoders, CLIP models or LoRAs.</span>';
    return;
  }
  const fileRows = fileComps.map(f => {
    const role = _detectCompRole(f.filename);
    const ext = f.filename.includes('.') ? f.filename.split('.').pop().toUpperCase() : '';
    return `<tr style="border-top:1px solid var(--border)">
      <td style="padding:.4rem .25rem;font-family:monospace;font-size:11px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(f.filename)}">${esc(f.filename)}</td>
      <td style="padding:.4rem .25rem;white-space:nowrap">
        <span class="badge badge-user" style="font-size:10px">${role?esc(role.label):'component'}</span>
        ${ext && ext !== 'GGUF' ? `<span class="badge" style="font-size:10px;background:var(--raised)">${esc(ext)}</span>` : ''}
      </td>
      <td style="text-align:right;padding:.4rem .25rem;white-space:nowrap;color:var(--text-2)">${fmtGB(f.size_gb)}</td>
      <td style="padding:.4rem .25rem;font-family:monospace;font-size:10px;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px" title="${esc(f.path)}">${esc(f.path)}</td>
      <td style="padding:.4rem .25rem;text-align:right;white-space:nowrap">
        <button class="btn btn-danger btn-sm" onclick="_deleteCompFile(${jq(f.path)},'gguf')">Delete</button>
      </td>
    </tr>`;
  });
  const hfRows = hfComps.map(m => {
    const role = _detectCompRole(m.id.split('/').pop() || m.id);
    return `<tr style="border-top:1px solid var(--border)">
      <td style="padding:.4rem .25rem;font-family:monospace;font-size:11px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(m.id)}">${esc(m.id)}</td>
      <td style="padding:.4rem .25rem;white-space:nowrap">
        <span class="badge badge-user" style="font-size:10px">${role?esc(role.label):'component'}</span>
        <span class="badge" style="font-size:10px;background:var(--raised)">HF</span>
      </td>
      <td style="text-align:right;padding:.4rem .25rem;white-space:nowrap;color:var(--text-2)">${fmtGB(m.size_gb)}</td>
      <td style="padding:.4rem .25rem;font-size:10px;color:var(--text-3)">${m.file_count} file${m.file_count!==1?'s':''}</td>
      <td style="padding:.4rem .25rem;text-align:right;white-space:nowrap">
        <button class="btn btn-danger btn-sm" onclick="_deleteCompFile(${jq(m.id)},'hf')">Delete</button>
      </td>
    </tr>`;
  });
  el.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 / Repo</th>'+
    '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Type</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">Path / Info</th>'+
    '<th></th></tr></thead><tbody>'+fileRows.join('')+hfRows.join('')+'</tbody></table>';
}

async function _deleteCompFile(id, cacheType) {
  if(!await showConfirm('Delete component file', `Delete "${id}" from local cache? This cannot be undone.`, 'Delete')) return;
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/cached-models/' + encodeURIComponent(id) + '?cache_type=' + (cacheType||'gguf'), {method:'DELETE'});
    const d = await r.json();
    if(d.success) refreshLocal();
    else await showConfirm('Delete failed', d.detail || 'Unknown error', 'OK');
  }catch(e){ await showConfirm('Error', e.message, 'OK'); }
}

// ── Component select combos ───────────────────────────────────────────────────
// role slug → input IDs: sel = 'cfg-{role}-sel', text = 'cfg-{role}-path'
// role slugs used: vae  t5xxl  clip-l  clip-g  clip-vision
const _COMP_FIELDS = [
  {slug:'vae',        pathId:'cfg-vae-path'},
  {slug:'t5xxl',      pathId:'cfg-t5xxl-path'},
  {slug:'clip-l',     pathId:'cfg-clip-l-path'},
  {slug:'clip-g',     pathId:'cfg-clip-g-path'},
  {slug:'clip-vision',pathId:'cfg-clip-vision-path'},
  {slug:'lora',       pathId:'cfg-lora-path'},
];

function _populateCompSelects(){
  const loraRol = _COMP_ROLES.find(r=>r.key==='lora');
  const loras   = _ggufFiles.filter(f=>loraRol?.pat.test(f.filename));
  const other   = _ggufFiles.filter(f=>_isCompFile(f.filename) && !loraRol?.pat.test(f.filename));
  const rest    = _ggufFiles.filter(f=>!_isCompFile(f.filename));
  const hfComps = _hfModels.filter(m=>_isCompFile(m.id.split('/').pop()||m.id));
  const hfRest  = _hfModels.filter(m=>!_isCompFile(m.id.split('/').pop()||m.id));
  const mkOpt = (val, text) => `<option value="${esc(val)}">${esc(text)}</option>`;
  const otherOpts = other.length
    ? `<optgroup label="Component files">${other.map(f=>mkOpt(f.path, f.filename+' ('+fmtGB(f.size_gb)+')')).join('')}</optgroup>`
    : '';
  const loraOpts  = loras.length
    ? `<optgroup label="LoRA files">${loras.map(f=>mkOpt(f.path, f.filename+' ('+fmtGB(f.size_gb)+')')).join('')}</optgroup>`
    : '';
  const restOpts  = rest.length
    ? `<optgroup label="Local files">${rest.map(f=>mkOpt(f.path, f.filename+' ('+fmtGB(f.size_gb)+')')).join('')}</optgroup>`
    : '';
  const hfCompOpts = hfComps.length
    ? `<optgroup label="HuggingFace component repos">${hfComps.map(m=>mkOpt(m.id, m.id+' ('+fmtGB(m.size_gb)+')')).join('')}</optgroup>`
    : '';
  const hfRestOpts = hfRest.length
    ? `<optgroup label="HuggingFace repos">${hfRest.map(m=>mkOpt(m.id, m.id+' ('+fmtGB(m.size_gb)+')')).join('')}</optgroup>`
    : '';
  _COMP_FIELDS.forEach(({slug}) => {
    const sel = document.getElementById('cfg-'+slug+'-sel');
    if(!sel) return;
    const html = slug === 'lora'
      ? `<option value="">None</option>${loraOpts}${otherOpts}${hfCompOpts}${restOpts}${hfRestOpts}<option value="__custom__">Custom path…</option>`
      : `<option value="">None</option>${otherOpts}${hfCompOpts}${loraOpts}${restOpts}${hfRestOpts}<option value="__custom__">Custom path…</option>`;
    sel.innerHTML = html;
  });
}

function _setCompField(slug, savedPath){
  const sel  = document.getElementById('cfg-'+slug+'-sel');
  const inp  = document.getElementById('cfg-'+slug+'-path');
  if(!sel) return;
  if(!savedPath){
    sel.value = '';
    if(inp){ inp.value=''; inp.style.display='none'; }
    return;
  }
  const match = Array.from(sel.options).find(o=>o.value===savedPath);
  if(match){
    sel.value = savedPath;
    if(inp){ inp.value=''; inp.style.display='none'; }
  } else {
    sel.value = '__custom__';
    if(inp){ inp.value=savedPath; inp.style.display=''; }
  }
}

function _onCompPick(slug){
  const sel   = document.getElementById('cfg-'+slug+'-sel');
  const pathId= 'cfg-'+slug+'-path';
  const inp   = document.getElementById(pathId);
  if(!sel || !inp) return;
  inp.style.display = sel.value === '__custom__' ? '' : 'none';
  if(sel.value !== '__custom__') inp.value = '';
}

function _readCompField(slug){
  const sel = document.getElementById('cfg-'+slug+'-sel');
  const inp = document.getElementById('cfg-'+slug+'-path');
  if(!sel) return '';
  if(sel.value === '__custom__') return inp ? inp.value.trim() : '';
  return sel.value || '';
}

// Per-component quantization (diffusers image/video pipelines).
// Common component names across SD/SDXL/Flux/Wan; 'default' = use global flag.
const COMPONENT_QUANT_NAMES = [
  'transformer', 'transformer_2', 'unet',
  'text_encoder', 'text_encoder_2', 'text_encoder_3', 'vae'
];
function _isGgufVal(v){ return typeof v==='string' && v.trim().toLowerCase().endsWith('.gguf'); }
function _renderComponentQuant(current){
  current = current || {};
  const host = document.getElementById('cfg-compquant-rows');
  if(!host) return;
  host.innerHTML = COMPONENT_QUANT_NAMES.map(name=>{
    const raw = (current[name] || 'default').toString();
    const isGguf = _isGgufVal(raw);
    const mode = isGguf ? 'gguf'
      : (['2bit','4bit','8bit','none'].includes(raw.toLowerCase()) ? raw.toLowerCase() : 'default');
    const opt = (val,lbl)=>`<option value="${val}"${mode===val?' selected':''}>${lbl}</option>`;
    const ggufPath = isGguf ? raw : '';
    return `<div style="display:flex;flex-direction:column;gap:2px;font-size:12px">
      <label style="display:flex;align-items:center;justify-content:space-between;gap:.5rem">
        <span style="font-family:monospace">${name}</span>
        <select class="form-input cfg-compquant" data-comp="${name}" style="width:120px;padding:2px 6px"
          onchange="_toggleGgufInput(this)">
          ${opt('default','Default')}${opt('2bit','2-bit (quanto)')}${opt('4bit','4-bit')}${opt('8bit','8-bit')}${opt('gguf','GGUF file…')}${opt('none','None')}
        </select>
      </label>
      <input type="text" class="form-input cfg-compquant-gguf" data-comp="${name}"
        placeholder="path or URL to .gguf (Q5_K, Q6_K…)" value="${ggufPath}"
        style="padding:2px 6px;${mode==='gguf'?'':'display:none'}">
    </div>`;
  }).join('');
}
function _toggleGgufInput(sel){
  const inp = sel.closest('div').querySelector('.cfg-compquant-gguf');
  if(inp) inp.style.display = (sel.value==='gguf') ? '' : 'none';
}
function _collectComponentQuant(){
  const out = {};
  document.querySelectorAll('.cfg-compquant').forEach(sel=>{
    const comp = sel.dataset.comp;
    if(sel.value === 'gguf'){
      const inp = document.querySelector('.cfg-compquant-gguf[data-comp="'+comp+'"]');
      const path = inp ? inp.value.trim() : '';
      if(path) out[comp] = path;            // store the .gguf path/URL
    } else if(sel.value && sel.value !== 'default'){
      out[comp] = sel.value;                // '2bit' | '4bit' | '8bit' | 'none'
    }
  });
  return Object.keys(out).length ? out : null;
}

// ---- Fast-kernel (GPTQ/AWQ) quantization ----
let _quantPollTimer = null;
let _quantCaps = null;

async function _quantGetCaps(){
  if(_quantCaps) return _quantCaps;
  try{
    const r = await fetch('/admin/api/quantize-capabilities');
    const j = await r.json();
    _quantCaps = j;
  }catch(e){ _quantCaps = {available:false, capabilities:{error:String(e)}}; }
  return _quantCaps;
}

function _renderQuantStatus(job, caps){
  const el = document.getElementById('cfg-quant-status');
  const btn = document.getElementById('cfg-quantize-btn');
  if(!el) return;
  if(caps && !caps.available){
    btn.disabled = true;
    const err = (caps.capabilities && caps.capabilities.error) || 'GPTQModel not installed';
    el.innerHTML = `<span style="color:var(--warn,#d97706)">Fast-kernel quantization unavailable — using bitsandbytes. (${esc(err)})</span>`;
    return;
  }
  const kernels = (caps && caps.capabilities && caps.capabilities.backends || []).join(', ');
  if(job && job.status === 'running'){
    btn.disabled = true;
    const pct = Math.round((job.progress||0)*100);
    el.innerHTML = `<span style="color:var(--accent,#6366f1)">Quantizing… ${pct}% — ${esc(job.message||'')}</span>`;
  } else if(job && job.status === 'done'){
    btn.disabled = false;
    el.innerHTML = `<span style="color:var(--ok,#16a34a)">✓ Quantized checkpoint ready — used automatically on next load.</span> <span class="muted">Kernels: ${esc(kernels)}</span>`;
  } else if(job && job.status === 'failed'){
    btn.disabled = false;
    el.innerHTML = `<span style="color:var(--danger,#dc2626)">Quantization failed: ${esc(job.error||'')}</span> <span class="muted">Falls back to bitsandbytes. Click to retry.</span>`;
  } else if(job && job.status === 'interrupted'){
    btn.disabled = false;
    el.innerHTML = `<span style="color:var(--warn,#d97706)">Quantization was interrupted by a server restart.</span> <span class="muted">Click to restart.</span>`;
  } else {
    btn.disabled = false;
    el.innerHTML = `Marlin/ExLlama 4-bit (2–4× faster than bitsandbytes). One-time background job; checkpoint auto-used on next load. <span class="muted">Kernels: ${esc(kernels||'detecting…')}</span>`;
  }
}

async function _refreshQuantStatus(modelPath){
  if(_quantPollTimer){ clearInterval(_quantPollTimer); _quantPollTimer = null; }
  const caps = await _quantGetCaps();
  const path = modelPath || (document.getElementById('cfg-path')||{}).value || '';
  let job = (caps.jobs && caps.jobs[path]) || null;
  _renderQuantStatus(job, caps);
  // Poll while a job is running.
  if(caps.available){
    _quantPollTimer = setInterval(async ()=>{
      try{
        const r = await fetch('/admin/api/quantize-status?model_id='+encodeURIComponent(path));
        const j = await r.json();
        _renderQuantStatus(j.job, caps);
        if(!j.job || (j.job.status !== 'running')){ clearInterval(_quantPollTimer); _quantPollTimer = null; }
      }catch(e){ clearInterval(_quantPollTimer); _quantPollTimer = null; }
    }, 2000);
  }
}

async function startQuantize(){
  const path = (document.getElementById('cfg-path')||{}).value || '';
  if(!path){ showAlert('No model selected.'); return; }
  const method = (document.getElementById('cfg-quant-backend').value === 'awq') ? 'awq' : 'gptq';
  const el = document.getElementById('cfg-quant-status');
  if(el) el.innerHTML = '<span class="muted">Starting quantization…</span>';
  try{
    const r = await fetch('/admin/api/model-quantize', {
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({path, method})
    });
    const j = await r.json();
    if(!j.success && j.job && j.job.status === 'unavailable'){
      _renderQuantStatus(null, {available:false, capabilities:j.job.caps||{}});
      return;
    }
    _refreshQuantStatus(path);
  }catch(e){
    if(el) el.innerHTML = '<span style="color:var(--danger,#dc2626)">Failed to start: '+esc(String(e))+'</span>';
  }
}

function _renderWhisperServerRows(models){
  if(!models.length) return '';
  const rows = models.map(m=>{
    const idx = _localModels.length;
    _localModels.push({
      label:m.id,
      path:m.id,
      cacheType:'whisper-server',
      size_gb:0,
      defaultType:'audio_models',
      settings:{
        backend: m.backend || 'whisper-server',
        load_mode: m.load_mode || 'on-request',
        model_type: 'audio_models',
        model_path: m.model_path || '',
        server_path: m.server_path || '',
        alias: m.alias || '',
        port: m.port ?? 8744,
        gpu_device: m.gpu_device ?? 0,
      },
      in_config:true,
      capabilities:m.capabilities || ['speech_to_text']
    });
    const loaded = _loadedKeys.has(`audio:${m.id}`) || _loadedKeys.has(m.id);
    return `<tr style="border-top:1px solid var(--border)">
      <td style="padding:.4rem .25rem;font-family:monospace;font-size:12px">${esc(m.id)}${m.alias?`<br><span style="color:var(--text-2);font-size:10px">alias: ${esc(m.alias)}</span>`:''}</td>
      <td style="padding:.4rem .25rem;font-size:11px;color:var(--text-2);max-width:160px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.25;max-height:2.5em" title="${esc(m.model_path || "—")}">${esc(m.model_path || "—")}</td>
      <td style="padding:.4rem .25rem;font-size:11px;color:var(--text-2)">${m.port ?? '—'} / GPU ${m.gpu_device ?? 0}</td>
      <td style="padding:.4rem .25rem;font-size:11px;color:var(--text-2)">${esc(m.load_mode || 'on-request')}</td>
      <td style="padding:.4rem .25rem;text-align:center">${loaded?'<span class="badge badge-ok">loaded</span>':'<span class="muted small">idle</span>'}</td>
      <td style="padding:.4rem .25rem;text-align:right;white-space:nowrap">
        ${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="openCfgModal(${idx})">Edit</button>
        <button class="btn btn-ghost btn-sm" onclick="disableModel(${idx})">Remove</button>
      </td>
    </tr>`;
  });
  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">'+
    '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Model / Alias</th>'+
    '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Model path</th>'+
    '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Port / GPU</th>'+
    '<th style="text-align:left;padding:.3rem .25rem;font-weight:700">Load mode</th>'+
    '<th style="text-align:center;padding:.3rem .25rem;font-weight:700">Status</th>'+
    '<th></th></tr></thead><tbody>'+rows.join('')+'</tbody></table></div>';
}

function refreshWhisperGgufOptions(){
  const ggufSelect = document.getElementById('ws-gguf-select');
  if(!ggufSelect) return;
  const selectedValue = ggufSelect.value;
  ggufSelect.innerHTML = '<option value="">Select downloaded GGUF</option>'+
    _ggufFiles.map(f=>`<option value="${esc(f.path)}">${esc(f.filename)}</option>`).join('');
  if(selectedValue && _ggufFiles.some(f => f.path === selectedValue)){
    ggufSelect.value = selectedValue;
  }
}

function prefillWhisperServerFromGguf(path){
  const sourceSelect = document.getElementById('ws-model-source');
  const ggufSelect = document.getElementById('ws-gguf-select');
  const builder = document.getElementById('ws-model-builder');
  resetWhisperServerBuilderDefaults();
  if(sourceSelect){
    sourceSelect.value = 'cached-gguf';
  }
  toggleWhisperModelSource();
  refreshWhisperGgufOptions();
  if(ggufSelect){
    ggufSelect.value = path;
  }
  if(builder){
    builder.scrollIntoView({behavior:'smooth', block:'start'});
  }
}

function nextWhisperServerModelId(){
  const whisperModels = _localModels.filter(m => m.cacheType === 'whisper-server');
  const existingIds = new Set(whisperModels.map(m => m.id || ''));
  let suffix = 0;
  while(existingIds.has(`whisper${suffix}`)) suffix += 1;
  return `whisper${suffix}`;
}

function defaultWhisperServerPath(){
  return window.__DEFAULT_WHISPER_SERVER_PATH__ || '';
}

function resetWhisperServerBuilderDefaults(){
  const modelIdInput = document.getElementById('ws-model-id');
  const serverPathInput = document.getElementById('ws-server-path');
  if(modelIdInput && !modelIdInput.value.trim()) modelIdInput.value = nextWhisperServerModelId();
  if(serverPathInput && !serverPathInput.value.trim()) serverPathInput.value = defaultWhisperServerPath();
}

async function loadCachedModels(){
  _localModels = [];
  const hfEl   = document.getElementById('hf-models-list');
  const ggufEl = document.getElementById('gguf-models-list');
  // Preserve scroll position across the refresh. Only blank the lists on the
  // first (empty) load — on a refresh (e.g. after load/unload) keep the existing
  // rows in place so the page doesn't collapse and jump the viewport to the end.
  const _scrollY = window.scrollY;
  const _firstLoad = !hfEl.dataset.loaded;
  if(_firstLoad) hfEl.innerHTML = ggufEl.innerHTML = '<span class="muted small">Loading…</span>';
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/cached-models');
    if(!r.ok) throw new Error((await r.json()).detail||r.statusText);
    const d = await r.json();
    // Only whisper-server RUNNERS (they carry a model_path) belong in this card.
    // A whisper MODEL config (a .gguf entry with backend=whisper-server but no
    // model_path) is shown on the GGUF row instead, as that file's model config.
    const whisperModels = (await fetch(ROOT_PATH + '/admin/api/models').then(r=>r.ok?r.json():[]))
      .filter(m => m.backend === 'whisper-server' && m.model_path);

    // HF models
    const hf = d.hf||[];
    _hfModels = hf;
    const hfModelList = hf.filter(m => !_isCompFile(m.id.split('/').pop() || m.id));
    document.getElementById('hf-model-badge').textContent = hfModelList.length ? `(${hfModelList.length})` : '';
    if(!hfModelList.length){
      hfEl.innerHTML = '<span class="muted small">No HuggingFace models cached.</span>';
    }else{
      const rows = hfModelList.map(m=>{
        const idx = _localModels.length;
        _localModels.push({label:m.id, path:m.id, cacheType:'hf', size_gb:m.size_gb||0,
          defaultType:m.model_type||'text_models', settings:m.settings||{}, in_config:m.in_config,
          capabilities:m.capabilities||[], incomplete:!!m.incomplete, configs:m.configs||[],
          missing:!!m.missing, to_download:!!m.to_download, source_repo:m.source_repo||m.id});
        const loaded = _loadedKeys.has(m.id) || [..._loadedKeys].some(k=>k.endsWith(':'+m.id)||k===m.id);
        const capBadges = fmtCapabilities(m.capabilities||[]);
        const _hfLm = _localModels[idx];
        const _hfConfigPills = _renderConfigPills(idx, _hfLm);
        const _hfConfigs = _hfLm.configs || [];
        const _hfCfgMax = _hfConfigs.length === 1 ? ((_hfConfigs[0].settings||{}).max_instances||1) : 1;
        const instBadgeHf = m.in_config ? _instanceBadge([m.id], _hfCfgMax) : '';
        const hlHf = _highlightCap && (m.capabilities||[]).includes(_highlightCap);
        const incompleteBadgeHf = m.incomplete ? '<span class="badge" style="background:rgba(255,160,0,.18);color:#b87200;font-size:10px;margin-left:.3rem" title="Download may be incomplete — some files are missing or truncated">⚠ incomplete</span>' : '';
        const toDlHf = !!m.to_download;
        const missingBadgeHf = toDlHf
          ? ' <span class="badge" style="background:rgba(80,140,220,.18);color:#4a86c7;font-size:10px" title="Listed for download — not yet downloaded or configured">⬇ to download</span>'
          : (m.missing ? ' <span class="badge" style="background:rgba(220,50,50,.18);color:#e05555;font-size:10px" title="Files removed from disk to free space — re-download to restore">✕ on disk: freed</span>' : '');
        const _hfConfigCount = _hfConfigs.length;
        const rowBgHf = m.missing ? ';background:rgba(220,50,50,.04);outline:1px solid rgba(220,50,50,.2);outline-offset:-1px' : (m.incomplete?';background:rgba(255,160,0,.04)':'');
        return `<tr${hlHf?' class="local-cap-highlight"':''} style="border-top:1px solid var(--border)${hlHf?';background:rgba(110,207,126,.07);outline:2px solid rgba(110,207,126,.25);outline-offset:-1px':''}${rowBgHf}">
          <td style="padding:.4rem .25rem;font-family:monospace;font-size:12px;max-width:260px;overflow:hidden;text-overflow:ellipsis" title="${esc(m.id)}">${esc(m.id)}${missingBadgeHf}${incompleteBadgeHf}${_quantBadge(m.id)}${_hfConfigPills}</td>
          <td style="text-align:right;padding:.4rem .25rem;white-space:nowrap;color:var(--text-2)">${m.missing?'<span class="muted small">—</span>':fmtGB(m.size_gb)}</td>
          <td style="text-align:right;padding:.4rem .25rem;color:var(--text-2)">${m.missing?'<span class="muted small">—</span>':m.file_count}</td>
          <td style="padding:.4rem .25rem;font-size:11px">${capBadges||'<span class="muted small">—</span>'}</td>
          <td style="text-align:center;padding:.4rem .25rem">${toDlHf?'<span class="badge" style="background:rgba(80,140,220,.18);color:#4a86c7">to download</span>':(m.in_config?`<span class="badge ${m.missing?'badge-err':'badge-ok'}">enabled${_hfConfigCount>1?` ×${_hfConfigCount}`:''}</span>${instBadgeHf?'<br>'+instBadgeHf:''}`:' <span class="muted small">—</span>')}</td>
          <td style="padding:.4rem .25rem;text-align:right;white-space:nowrap">
            ${m.in_config&&!m.missing?(loaded
              ?`<button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="unloadModel(${idx})">Unload</button>`
              :`<button class="btn btn-primary btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="loadModel(${idx})">Load now</button>`):''}
            <button class="btn ${toDlHf?'btn-warn':'btn-ghost'} btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="openDownloadFor('${esc(m.id)}')" title="${m.missing?'Re-download to restore this model':'Download from HuggingFace'}">⬇ ${toDlHf?'Download':'Re-download'}</button>
            <button class="btn btn-secondary btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="openCfgModal(${idx},0)">${m.in_config?'Configure':'Add'}</button>
            ${m.in_config?`<button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="disableModel(${idx})">Remove</button>`:''}
            ${toDlHf?`<button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="unmarkDownload(${idx})" title="Remove this model from the download list">✕ Remove from list</button>`:''}
            ${!m.missing&&!toDlHf?`<button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="freeDiskConfirm(${idx})" title="Delete files from disk to save space, but keep this model in the list so you can re-download it later">🗑 Free disk</button>`:''}
            ${!m.missing&&!toDlHf?`<button class="btn btn-danger btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="deleteModelConfirm(${idx})">Delete</button>`:''}
          </td>
        </tr>`;
      });
      hfEl.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">Model</th>'+
        '<th style="text-align:right;padding:.3rem .25rem;font-weight:700">Size</th>'+
        '<th style="text-align:right;padding:.3rem .25rem;font-weight:700">Files</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>';
    }

    // GGUF files
    const gguf = d.gguf||[];
    _ggufFiles = gguf;
    refreshWhisperGgufOptions();
    renderComponentsList(gguf);
    const ggufModelFiles = gguf.filter(f => !_isCompFile(f.filename));
    document.getElementById('gguf-file-badge').textContent = ggufModelFiles.length ? `(${ggufModelFiles.length})` : '';
    if(!ggufModelFiles.length){
      ggufEl.innerHTML = '<span class="muted small">No GGUF files cached.</span>';
    }else{
      // One row per file — no grouping. Each quantization is independently
      // visible and can be individually configured, removed, and deleted.
      const rows = ggufModelFiles.map(f=>{
        const base  = _ggufBaseName(f.filename);
        const quant = _ggufQuant(f.filename);
        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||[], ggufGroup:[f],
          source_repo:f.source_repo||'',
          incomplete:!!f.incomplete,
          missing:!!f.missing, to_download:!!f.to_download,
          configured_path:f.configured_path||'',
          configs:f.configs||[]});
        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||[]);
        const lm          = _localModels[idx];
        const _cfgMax     = lm.configs && lm.configs.length === 1 ? ((lm.configs[0].settings||{}).max_instances||1) : 1;
        const instBadge   = f.in_config ? _instanceBadge([f.path, f.filename], _cfgMax) : '';
        const quantBadge  = quant ? `<span class="badge badge-user" style="font-size:10px;padding:.1rem .3rem;margin-left:.3rem">${esc(quant)}</span>` : '';
        const hl          = _highlightCap && (f.capabilities||[]).includes(_highlightCap);
        const toDlGg = !!f.to_download;
        const missingBadge = toDlGg
          ? ' <span class="badge" style="background:rgba(80,140,220,.18);color:#4a86c7;font-size:10px" title="Listed for download — not yet downloaded or configured">⬇ to download</span>'
          : (f.missing ? ' <span class="badge" style="background:rgba(220,50,50,.18);color:#e05555;font-size:10px" title="File not found at configured path — re-download or remove this configuration">✕ file missing</span>' : '');
        const incompleteBadge = f.incomplete ? ' <span class="badge" style="background:rgba(255,160,0,.18);color:#b87200;font-size:10px" title="Download may be incomplete">⚠ incomplete</span>' : '';
        const redownloadTarget  = f.source_repo || '';
        // Only pass a file pattern when it's a concrete .gguf filename; for
        // known-but-never-downloaded entries (repo id only) leave it blank so
        // Re-download opens the browse modal to pick a quant.
        const redownloadPattern = (f.source_repo && /\.gguf$/i.test(f.filename||'')) ? f.filename : '';
        const configPills = _renderConfigPills(idx, lm);
        const configCount = (lm.configs||[]).length;
        const rowBg = f.missing ? ';background:rgba(220,50,50,.04);outline:1px solid rgba(220,50,50,.2);outline-offset:-1px'
                    : f.incomplete ? ';background:rgba(255,160,0,.04)' : '';
        return `<tr${hl?' class="local-cap-highlight"':''} id="gguf-row-${idx}" style="border-top:1px solid var(--border)${hl?';background:rgba(110,207,126,.07);outline:2px solid rgba(110,207,126,.25);outline-offset:-1px':''}${rowBg}">
          <td style="padding:.4rem .25rem;font-family:monospace;font-size:11px;max-width:280px;overflow:hidden;text-overflow:ellipsis" title="${esc(f.path)}">${esc(base)}${quantBadge}${missingBadge}${incompleteBadge}${configPills}</td>
          <td style="text-align:right;padding:.4rem .25rem;white-space:nowrap;color:var(--text-2)">${f.missing?'<span class="muted small">—</span>':fmtGB(f.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">${toDlGg?'<span class="badge" style="background:rgba(80,140,220,.18);color:#4a86c7">to download</span>':(f.in_config?`<span class="badge ${f.missing?'badge-err':'badge-ok'}">enabled${configCount>1?` ×${configCount}`:''}</span>${instBadge?'<br>'+instBadge:''}`:' <span class="muted small">—</span>')}</td>
          <td style="padding:.4rem .25rem;text-align:right;white-space:nowrap">
            ${f.in_config&&!f.missing?(loaded
              ?`<button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="unloadModel(${idx})">Unload</button>`
              :`<button class="btn btn-primary btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="loadModel(${idx})">Load now</button>`):''}
            ${f.missing
              ?`<button class="btn btn-warn btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="openDownloadFor('${esc(redownloadTarget)}','${esc(redownloadPattern)}')" title="${toDlGg?'Download this model':'Re-download to restore this model'}">⬇ ${toDlGg?'Download':'Re-download'}</button>`
              :`<button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="openDownloadFor('${esc(redownloadTarget)}','${esc(redownloadPattern)}')" title="${redownloadTarget?'Re-download from HuggingFace':'Download a replacement'}">⬇ Re-download</button>`}
            <button class="btn btn-secondary btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="openCfgModal(${idx},0)">${f.in_config?'Configure':'Add'}</button>
            ${f.in_config?`<button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="disableModel(${idx})">Remove</button>`:''}
            ${toDlGg?`<button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="unmarkDownload(${idx})" title="Remove this model from the download list">✕ Remove from list</button>`:''}
            ${!f.missing&&!toDlGg?`<button class="btn btn-ghost btn-sm" style="font-size:10px;padding:.15rem .4rem" onclick="freeDiskConfirm(${idx})" title="Delete this file from disk to save space, but keep it in the list (as “to download”) so you can fetch it again later">🗑 Free disk</button>`:''}
            ${!f.missing&&!toDlGg?`<button class="btn btn-danger btn-sm" style="font-size:10px;padding:.15rem .4rem" 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">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>';
    }
    // Scroll to first highlighted model if a local_cap deep-link is active
    if (_highlightCap) {
      const first = document.querySelector('.local-cap-highlight');
      if (first) first.scrollIntoView({behavior:'smooth', block:'center'});
    }

    // Rebuild set of locally cached model IDs for search results indicator
    _cachedSearchIds.clear();
    (d.hf||[]).forEach(m => _cachedSearchIds.add(m.id));
    (d.gguf||[]).forEach(f => { if (f.source_repo) _cachedSearchIds.add(f.source_repo); });

    // 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();
    applyLocalFilter();
    hfEl.dataset.loaded = '1';
    // Restore the pre-refresh scroll position (the lists were rebuilt in place).
    if(!_firstLoad) window.scrollTo(0, _scrollY);
  }catch(e){
    if(_firstLoad) hfEl.innerHTML = ggufEl.innerHTML = `<span class="muted small">Error: ${esc(e.message)}</span>`;
  }
}

// Filter HF models, GGUF files and component rows by name match (first cell).
function applyLocalFilter(){
  const inp = document.getElementById('local-filter');
  const q = (inp ? inp.value : '').trim().toLowerCase();
  [['hf-models-list','hf-model-badge'],
   ['gguf-models-list','gguf-file-badge'],
   ['comp-list','comp-badge']].forEach(([listId,badgeId])=>{
    const el = document.getElementById(listId);
    if(!el) return;
    const rows = el.querySelectorAll('tbody > tr');
    let shown = 0;
    rows.forEach(tr=>{
      const cell = tr.querySelector('td');
      const name = (cell ? cell.textContent : tr.textContent).toLowerCase();
      const match = !q || name.includes(q);
      tr.style.display = match ? '' : 'none';
      if(match) shown++;
    });
    const badge = document.getElementById(badgeId);
    if(badge && rows.length){ badge.textContent = q ? `(${shown}/${rows.length})` : `(${rows.length})`; }
  });
}

function toggleWhisperModelSource(){
  const sourceSelect = document.getElementById('ws-model-source');
  const source = sourceSelect ? sourceSelect.value : 'cached-gguf';
  const ggufSelect = document.getElementById('ws-gguf-select');
  const modelPath = document.getElementById('ws-model-path');
  if(!ggufSelect || !modelPath) return;
  const useCached = source === 'cached-gguf';
  ggufSelect.style.display = useCached ? '' : 'none';
  modelPath.style.display = useCached ? 'none' : '';
}

function getWhisperServerModelPath(){
  const modelSource = document.getElementById('ws-model-source').value;
  return (modelSource === 'cached-gguf'
    ? document.getElementById('ws-gguf-select').value
    : document.getElementById('ws-model-path').value.trim()) || null;
}

let _loadedKeys = new Set();
let _instanceInfo = {};   // loaded_key -> {loaded: N, max: N}

async function refreshLoadedStatus(){
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/model-loaded-status');
    if(r.ok){
      const d = await r.json();
      _loadedKeys = new Set(d.loaded||[]);
      _instanceInfo = d.instances || {};
    }
  }catch{}
}

function _findLoadedKey(paths){
  for(const p of paths){
    if(_instanceInfo[p]) return p;
    for(const k of Object.keys(_instanceInfo)){
      if(k === p || k.endsWith(':'+p)) return k;
    }
  }
  return null;
}

function _instanceBadge(lookupPaths, cfgMax){
  const instKey = _findLoadedKey(lookupPaths);
  const info = instKey ? _instanceInfo[instKey] : null;
  if(info && info.max > 1){
    // Live data: show actual loaded/max counts
    const cls = info.loaded >= info.max ? 'badge-ok' : 'badge-warn';
    return `<span class="badge ${cls}" style="font-size:10px" title="${info.loaded} of ${info.max} instances loaded">${info.loaded}/${info.max} inst.</span>`;
  }
  if(cfgMax && cfgMax > 1){
    // Configured but not yet loaded — show the configured count
    return `<span class="badge badge-user" style="font-size:10px" title="${cfgMax} instances configured">×${cfgMax} inst.</span>`;
  }
  return '';
}

// ---- Quant-job badges on the model list (polled; re-renders only on change) ----
let _quantJobs = {};
let _quantSig = '';
async function refreshQuantJobs(){
  try{
    const r = await fetch('/admin/api/quantize-status');
    _quantJobs = (await r.json()).jobs || {};
  }catch(e){ /* keep last-known on transient error */ }
  // Signature buckets progress to 5% so we only re-render on meaningful change.
  return Object.entries(_quantJobs)
    .map(([k,v])=>`${k}:${v.status}:${Math.round((v.progress||0)*20)}`).sort().join('|');
}
function _quantBadge(modelId){
  const j = _quantJobs[modelId];
  if(!j) return '';
  const m = esc((j.method||'gptq').toUpperCase());
  if(j.status==='running'){
    const pct = Math.round((j.progress||0)*100);
    return `<span class="badge" style="background:rgba(99,102,241,.18);color:#A5B4FC;font-size:10px;margin-left:.3rem" title="Quantizing to ${m}: ${esc(j.message||'')}">⏳ quantizing ${pct}%</span>`;
  }
  if(j.status==='failed') return `<span class="badge badge-err" style="font-size:10px;margin-left:.3rem" title="${esc(j.error||'')} — falls back to bitsandbytes">quant failed</span>`;
  if(j.status==='interrupted') return `<span class="badge" style="background:rgba(255,160,0,.18);color:#b87200;font-size:10px;margin-left:.3rem" title="Interrupted by a server restart — re-run from the model config">quant interrupted</span>`;
  if(j.status==='done') return `<span class="badge badge-ok" style="font-size:10px;margin-left:.3rem" title="Fast-kernel ${m} checkpoint ready — used automatically on next load">4-bit ready</span>`;
  return '';
}

async function refreshLocal(){
  await refreshLoadedStatus();
  _quantSig = await refreshQuantJobs();
  loadCacheStats();
  loadCachedModels();
}

// Poll quant jobs; re-render the list only when a job's state/progress changes.
setInterval(async () => {
  const sig = await refreshQuantJobs();
  if (sig !== _quantSig) { _quantSig = sig; loadCachedModels(); }
}, 5000);

loadGlobalSettings();
// Load engine/card info first so the per-model card tags render on the first paint,
// then re-render once it's available (covers the fetch resolving after the list).
_loadEngineInfo().then(() => loadCachedModels());
refreshLocal();

// Toggle the acceleration / TurboQuant sections as model types are checked/unchecked.
document.querySelectorAll('.cfg-type-cb').forEach(cb =>
  cb.addEventListener('change', () => { _refreshAccelVisibility(); _refreshTurboQuantVisibility(); }));

// ── Deep-link from Studio: /admin/models?tab=search&q=...&pipeline=...&gguf=...
// ── or: /admin/models?local_cap=CAPABILITY — highlight local models with that capability
(function applyDeepLink(){
  const p = new URLSearchParams(location.search);

  // Local capability highlight mode
  const localCap = p.get('local_cap');
  if (localCap) {
    _highlightCap = localCap;
    const banner = document.createElement('div');
    banner.id = 'local-cap-banner';
    banner.style.cssText = 'background:var(--accent-s);border:1px solid rgba(99,102,241,.35);border-radius:8px;padding:.5rem .875rem;margin-bottom:.75rem;font-size:13px;display:flex;align-items:center;gap:.5rem;flex-wrap:wrap';
    banner.innerHTML = `<span style="color:#A5B4FC;font-weight:600">Looking for capability:</span>`
      + `<span class="badge badge-user" style="font-size:12px">${localCap.replace(/_/g,' ')}</span>`
      + `<span style="color:var(--text-2)">— models highlighted below can be configured to enable it</span>`
      + `<button onclick="document.getElementById('local-cap-banner').remove();_highlightCap=null" style="margin-left:auto;background:none;border:none;color:var(--text-2);cursor:pointer;font-size:16px;line-height:1">×</button>`;
    const tabLocal = document.getElementById('tab-local');
    tabLocal.insertBefore(banner, tabLocal.firstChild);
    // local tab is already active by default — no tab switch needed
    return;
  }

  if (p.get('tab') !== 'search') return;
  // Switch to the HF search tab
  const tabBtn = document.querySelector('.tab[onclick*="search"]');
  if (tabBtn) switchTab('search', tabBtn);
  // Fill query
  const q = p.get('q') || '';
  const searchQ = document.getElementById('search-q');
  if (searchQ) searchQ.value = q;
  // Set pipeline filter
  const pipeline = p.get('pipeline') || '';
  const pipelineEl = document.getElementById('filter-pipeline');
  if (pipelineEl && pipeline) pipelineEl.value = pipeline;
  // Set gguf mode toggle
  const gguf = p.get('gguf') || 'all';
  document.querySelectorAll('.tog-btn').forEach(btn => {
    btn.classList.toggle('on', btn.dataset.val === gguf);
    if (btn.dataset.val === gguf) btn.click();  // fires the existing toggle handler
  });
  // Trigger search automatically
  doSearch();
})();

async function clearCacheConfirm(type){
  const labels = {hf:'HuggingFace', gguf:'GGUF', all:'ALL'};
  if(!await showConfirm('Clear cache', `Delete ${labels[type]} model cache? This cannot be undone.`, 'Delete')) return;
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/cache?cache_type='+type, {method:'DELETE'});
    const d = await r.json();
    if(d.success) refreshLocal();
    else await showConfirm('Error', 'Error clearing cache', 'OK');
  }catch(e){ await showConfirm('Error', e.message, 'OK'); }
}

async function deleteModelConfirm(idx){
  const m = _localModels[idx];
  if(!m){ showAlert('Error: model not found — please refresh the page.'); return; }
  const extra = m.in_config ? ' It will also be removed from your configuration.' : '';
  if(!await showConfirm('Delete model', `Delete "${m.label}" from local cache? This cannot be undone.${extra}`, 'Delete')) return;
  try{
    // De-configure first if the model is currently enabled, to avoid dangling config entries
    if(m.in_config){
      const paths = m.ggufGroup
        ? m.ggufGroup.filter(f=>f.in_config).map(f=>f.path)
        : [m.path];
      for(const p of paths.length ? paths : [m.path]){
        await fetch(ROOT_PATH + '/admin/api/model-disable', {
          method:'POST', headers:{'Content-Type':'application/json'},
          body: JSON.stringify({path: p})
        });
      }
    }
    // Remove from cache
    const idForUrl = m.path;
    const r = await fetch(ROOT_PATH + '/admin/api/cached-models/'+encodeURIComponent(idForUrl)+'?cache_type='+m.cacheType, {method:'DELETE'});
    const d = await r.json();
    if(d.success) refreshLocal();
    else showAlert('Error: '+(d.detail||d.error||'Unknown'));
  }catch(e){showAlert('Error: '+e.message)}
}

// Delete a model's files from disk to reclaim space while keeping its config
// entry, so it can be re-downloaded later. The model then shows as "freed" with
// a Re-download button.
async function freeDiskConfirm(idx){
  const m = _localModels[idx];
  if(!m){ showAlert('Error: model not found — please refresh the page.'); return; }
  // Re-download target: stored source repo, or the model id itself for HF repos.
  const repo = m.source_repo || (m.cacheType==='hf' ? m.path : '');
  const warn = repo ? '' : ' Note: no source repository is known for this file, so you may need to locate it manually to re-download.';
  const extra = m.in_config ? '' : ' It will stay in your model list as “to download” so you can fetch it again later.';
  if(!await showConfirm('Free disk space',
      `Delete "${m.label}" from disk to save space, but keep it in your model list so you can re-download it when needed?${extra}${warn}`,
      'Free disk')) return;
  try{
    // Unconfigured models only appear in the list because they are on disk; record
    // them in the "to download" wishlist first so the entry survives the file
    // deletion and stays listed (as non-configured / to be downloaded).
    if(!m.in_config){
      const er = await fetch(ROOT_PATH + '/admin/api/model-mark-download', {
        method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({model_id: repo || m.path, source_repo: repo || m.path,
          model_type:m.defaultType||'text_models', is_gguf: m.cacheType==='gguf'})
      });
      const ed = await er.json().catch(()=>({}));
      if(!ed.success){ showAlert('Error: '+(ed.detail||ed.error||'could not record model for re-download')); return; }
    }
    const r = await fetch(ROOT_PATH + '/admin/api/model-free-disk', {
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({path:m.path, cache_type:m.cacheType, source_repo:repo})
    });
    const d = await r.json();
    if(d.success) refreshLocal();
    else showAlert('Error: '+(d.detail||d.error||'Unknown'));
  }catch(e){showAlert('Error: '+e.message)}
}

function _toggleAutoCompact(){
  const on = document.getElementById('cfg-autocompact').checked;
  document.getElementById('cfg-autocompact-opts').style.display = on ? 'grid' : 'none';
  const strat = document.getElementById('cfg-autocompact-strategy').value;
  const mrow = document.getElementById('cfg-autocompact-model-row');
  if(mrow) mrow.style.display = (on && strat === 'summarize') ? 'block' : 'none';
}

// Populate the summarizer-model dropdown from the configured text/LLM models,
// preserving the current selection.
function _populateCompactModelSelect(selected){
  const sel = document.getElementById('cfg-autocompact-model');
  if(!sel) return;
  const cur = selected != null ? selected : sel.value;
  const seen = new Set();
  let html = '<option value="">Same as request model (default)</option>';
  (_localModels||[]).forEach(m=>{
    const caps = m.capabilities||[];
    const type = m.defaultType||'text_models';
    const isText = type==='text_models' || type==='gguf_models' || type==='vision_models'
      || caps.includes('chat') || caps.includes('text') || caps.includes('tools');
    if(!isText) return;
    const id = m.path || m.label;
    if(!id || seen.has(id)) return;
    seen.add(id);
    html += `<option value="${esc(id)}">${esc(m.label||id)}</option>`;
  });
  // Keep a previously-saved value even if its model isn't in the local list.
  if(cur && !seen.has(cur)){
    html += `<option value="${esc(cur)}">${esc(cur)}</option>`;
  }
  sel.innerHTML = html;
  sel.value = cur || '';
}

/* ── type checkbox helpers ─────────────────────────────── */
function _autoDetectParser(path) {
  const n = (path || '').toLowerCase();
  if (n.includes('llama') || n.includes('codellama')) return 'llama';
  if (n.includes('mistral') || n.includes('mixtral')) return 'mistral';
  if (/phi[-_.]?\d|phi.*instruct/i.test(n)) return 'phi';
  if (n.includes('gemma')) return 'gemma';
  if (n.includes('qwen')) return 'qwen';
  return 'auto';
}

function _capabilitiesToTypes(caps) {
  const categories = new Set(), subs = new Set();
  if (!caps || !caps.length) return {categories, subs};
  if (caps.includes('image_to_video'))  { categories.add('video_models'); subs.add('i2v'); }
  if (caps.includes('video_generation')){ categories.add('video_models'); subs.add('t2v'); }
  if (caps.includes('video_to_video'))  { categories.add('video_models'); subs.add('v2v'); }
  if (caps.includes('image_generation') || caps.includes('image_to_image') ||
      caps.includes('inpainting')       || caps.includes('controlnet') ||
      caps.includes('image_upscaling')  || caps.includes('face_restoration') ||
      caps.includes('style_transfer')) categories.add('image_models');
  if (caps.includes('depth_estimation') || caps.includes('image_segmentation') ||
      caps.includes('object_detection')) categories.add('spatial_models');
  if (caps.includes('image_to_text') && caps.includes('text_generation')) {
    categories.add('vision_models');
  } else if (caps.includes('text_generation') &&
             !categories.has('video_models') && !categories.has('image_models') &&
             !categories.has('spatial_models')) {
    categories.add('text_models');
  }
  if (caps.includes('speech_to_text')) categories.add('audio_models');
  if (caps.includes('text_to_speech')) categories.add('tts_models');
  if (caps.includes('audio_generation')) categories.add('audio_gen_models');
  if (caps.includes('embeddings')) categories.add('embedding_models');
  return {categories, subs};
}

function _setTypeCheckboxes(categories, subs) {
  document.querySelectorAll('.cfg-type-cb').forEach(cb => {
    const sub = cb.dataset.sub;
    if (!categories.has(cb.value)) { cb.checked = false; return; }
    if (sub) {
      // Sub-typed checkbox (T2V / I2V / V2V): check only if this sub is in subs
      cb.checked = subs.has(sub);
    } else if (cb.value === 'video_models') {
      // Non-sub video checkbox: only relevant when subs is empty (legacy/no-sub)
      cb.checked = subs.size === 0;
    } else {
      cb.checked = true;
    }
  });
}

function _getCheckedTypes() {
  const checked = [...document.querySelectorAll('.cfg-type-cb:checked')];
  const categories = [...new Set(checked.map(cb => cb.value))];
  const subs = checked.filter(cb => cb.dataset.sub).map(cb => cb.dataset.sub);
  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 toggleWseModelSource() {
  const source = document.getElementById('wse-model-source').value;
  document.getElementById('wse-gguf-row').style.display = source === 'cached-gguf' ? '' : 'none';
  document.getElementById('wse-path-row').style.display = source === 'manual-path' ? '' : 'none';
}

function _getWseModelPath() {
  const source = document.getElementById('wse-model-source').value;
  return source === 'cached-gguf'
    ? document.getElementById('wse-gguf-select').value
    : document.getElementById('wse-model-path').value.trim();
}

function _openWhisperServerEdit(m) {
  const s = m.settings || {};
  document.getElementById('wse-model-id').value = m.path;
  document.getElementById('wse-id-label').textContent = m.path;
  document.getElementById('wse-alias').value = s.alias || '';
  document.getElementById('wse-server-path').value = s.server_path || defaultWhisperServerPath();
  document.getElementById('wse-port').value = s.port ?? 8744;
  document.getElementById('wse-gpu-device').value = s.gpu_device ?? 0;

  // Populate the GGUF select with current options from the add-form's list
  const srcSelect = document.getElementById('ws-gguf-select');
  const dstSelect = document.getElementById('wse-gguf-select');
  dstSelect.innerHTML = srcSelect ? srcSelect.innerHTML : '<option value="">Select downloaded GGUF</option>';

  const modelPath = s.model_path || '';
  const inGguf = modelPath && _ggufFiles.some(f => f.path === modelPath);
  if (inGguf) {
    document.getElementById('wse-model-source').value = 'cached-gguf';
    dstSelect.value = modelPath;
  } else {
    document.getElementById('wse-model-source').value = 'manual-path';
    document.getElementById('wse-model-path').value = modelPath;
  }
  toggleWseModelSource();
  openModal('ws-edit-modal');
}

async function saveWhisperServerEdit() {
  const model_id = document.getElementById('wse-model-id').value;
  const model_source = document.getElementById('wse-model-source').value;
  const model_path = _getWseModelPath();
  if (!model_path) { showAlert('Model path is required'); return; }
  const payload = {
    model_id,
    backend: 'whisper-server',
    model_source,
    server_path: document.getElementById('wse-server-path').value.trim(),
    model_path,
    port: parseInt(document.getElementById('wse-port').value, 10) || 8744,
    gpu_device: parseInt(document.getElementById('wse-gpu-device').value, 10) || 0,
    load_mode: _deriveWhisperLoadMode(model_path),
    alias: document.getElementById('wse-alias').value.trim() || null,
  };
  try {
    const r = await fetch(ROOT_PATH + '/admin/api/model-configure', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(payload)
    });
    const d = await r.json();
    if (!r.ok) throw new Error(d.detail || 'Failed to save');
    closeModal('ws-edit-modal');
    refreshLocal();
  } catch(e) { showAlert('Error: ' + e.message); }
}

function openCfgModalNew(idx) {
  // Open modal for a brand-new config (no config_id) on an existing model
  openCfgModal(idx, -1);
}

// GGUF (llama.cpp) vs transformers models use different memory controls. GGUF
// offloads via "GPU layers" (n_gpu_layers); the transformers "Offload" strategy
// (device_map / disk offload) and GPTQ/AWQ fast-quant don't apply to it. Show
// only the relevant controls so the config can't be set in a way the backend
// ignores.
function _applyBackendCfgVisibility(m){
  const isGguf = (m && (m.cacheType === 'gguf'
      || /\.gguf(\?|$)/i.test(m.path || m.id || '')
      || (typeof looksLikeGguf === 'function' && looksLikeGguf(m.path || m.id || '', ''))));
  const show = (id, on) => { const el = document.getElementById(id); if (el) el.style.display = on ? '' : 'none'; };
  // transformers-only sections — hidden for GGUF/llama.cpp models
  show('cfg-offload-title', !isGguf);
  show('cfg-offload-grid', !isGguf);
  show('cfg-fastquant-wrap', !isGguf);
  // GGUF/llama.cpp-only controls — hidden for transformers models
  show('cfg-gpu-layers-row', isGguf);
  show('cfg-kv-k-row', isGguf);
  show('cfg-kv-v-row', isGguf);
  // mmproj pairing only applies to GGUF text models on llama.cpp.
  show('cfg-mmproj-row', isGguf);
}

// Populate the mmproj (multimodal projector) select from the available component
// GGUF files. Defaults to None and only auto-pairs a projector when its name is a
// strong, unambiguous match for this model (e.g. gemma vision) — never on a lone
// shared family token, which previously paired the wrong-size projector.
function _populateMmprojSelect(m, s){
  const sel = document.getElementById('cfg-mmproj');
  if(!sel) return;
  // All cached GGUF component files classified as mmproj projectors.
  const projFiles = (_ggufFiles || []).filter(f => {
    const role = _detectCompRole(f.filename);
    return role && role.key === 'mmproj';
  });
  const cur = (s && s.mmproj) || '';
  // Score each projector by shared *distinctive* name tokens with the model, so
  // the gemma projector pairs with the gemma model — not an unrelated one, and
  // not the wrong-size sibling. Generic tokens (quant level, format, "mmproj"…)
  // are dropped so they can't manufacture a false match.
  const GENERIC = new Set(['gguf','mmproj','proj','model','instruct','chat','text',
    'vision','base','tuned','finetune','merge','fp16','bf16','f16','f32']);
  const isQuant = (t) => /^(iq|q)\d/.test(t) || /^\d+bpw$/.test(t)
    || ['k','km','ks','kl','kxl','km','kss','xs','xss'].includes(t);
  const distinctive = (name) => name.split(/[^a-z0-9]+/)
    .filter(t => t.length >= 3 && !GENERIC.has(t) && !isQuant(t));
  const modelName = (_ggufBaseName(m.label || m.path || '') || '').toLowerCase();
  const tokens = distinctive(modelName);
  const score = (fname) => {
    const n = fname.toLowerCase();
    return tokens.reduce((acc, t) => acc + (n.includes(t) ? 1 : 0), 0);
  };
  // A real match must cover at least half the model's distinctive tokens (floor
  // 1) — a lone shared family token among several isn't enough.
  const need = Math.max(1, Math.ceil(tokens.length / 2));
  let best = '', bestScore = 0, tie = false;
  const opts = ['<option value="">None</option>'];
  for(const f of projFiles){
    const sc = score(f.filename);
    if(sc > bestScore){ bestScore = sc; best = f.path; tie = false; }
    else if(sc === bestScore && sc > 0){ tie = true; }
    opts.push(`<option value="${esc(f.path)}">${esc(f.filename)} (${fmtGB(f.size_gb)})</option>`);
  }
  sel.innerHTML = opts.join('');
  // Auto-pair only on a strong, unambiguous match; otherwise default to None.
  const auto = (bestScore >= need && !tie) ? best : '';
  // Selection priority: explicit config value → strong name match → None.
  sel.value = cur || auto;
  // If the stored value isn't among the current options (file removed), fall back.
  if(cur && sel.value !== cur) sel.value = auto;
  _onMmprojChange();
}

// Selecting an mmproj projector adds image/vision input, so reflect that in the
// model's capabilities (so vision requests route to it). Only auto-checks; never
// auto-unchecks a capability the user set for other reasons.
function _onMmprojChange(){
  const sel = document.getElementById('cfg-mmproj');
  if(!sel || !sel.value) return;
  const cap = document.querySelector('.cfg-cap-cb[value="image_to_text"]');
  if(cap && !cap.checked){ cap.checked = true; }
}

function openCfgModal(idx, cfgIdx){
  const m = _localModels[idx];
  if (m.cacheType === 'whisper-server') {
    _openWhisperServerEdit(m);
    return;
  }
  // Determine which config's settings to load
  const configs = m.configs || [];
  // Normalize cfgIdx: undefined/null → 0, -1 → "new"
  const _cfgIdx = (cfgIdx == null) ? 0 : cfgIdx;
  let s, configId, isNewConfig;
  if (_cfgIdx === -1 || (configs.length === 0 && !m.in_config)) {
    // New config: explicitly requested (-1) or truly unconfigured model
    s = {};
    configId = '';
    isNewConfig = true;
  } else if (_cfgIdx >= 0 && configs[_cfgIdx]) {
    s = configs[_cfgIdx].settings || {};
    configId = s.config_id || '';
    isNewConfig = false;
  } else {
    // Fallback to legacy m.settings
    s = m.settings || {};
    configId = s.config_id || '';
    isNewConfig = !configId;
  }
  document.getElementById('cfg-config-id').value = configId;

  // Show/hide "Save as new config" and "Remove this config" buttons
  const saveNewBtn = document.getElementById('cfg-save-new-btn');
  saveNewBtn.style.display = (!isNewConfig && m.in_config) ? '' : 'none';
  const removeConfigBtn = document.getElementById('cfg-remove-config-btn');
  // Only show remove-this-config when the model has multiple configs — removing the last one is handled by the row-level Remove button
  removeConfigBtn.style.display = (!isNewConfig && configId && configs.length > 1) ? '' : 'none';

  const cfgLabel = isNewConfig
    ? (m.in_config ? 'Add configuration' : 'Add')
    : (configs.length > 1 ? `Configure: ${s.alias || ('Config ' + (_cfgIdx + 1))}` : 'Configure model');
  document.getElementById('cfg-modal-title').textContent = cfgLabel;
  document.getElementById('cfg-id-label').textContent = m.label;
  document.getElementById('cfg-path').value = m.path;
  // If the file was re-downloaded to a new path, use the old configured path as orig_path
  // so saveModelConfig removes the stale entry and writes one at the new location.
  document.getElementById('cfg-orig-path').value = m.configured_path || m.path;
  document.getElementById('cfg-orig-type').value = m.defaultType;
  // Show a notice when the file moved (re-downloaded to a different location)
  const pathMovedEl = document.getElementById('cfg-path-moved-notice');
  if(pathMovedEl){
    if(m.configured_path && m.configured_path !== m.path){
      pathMovedEl.textContent = `File re-downloaded to new path. Saving will update the configuration.`;
      pathMovedEl.style.display = '';
    } else {
      pathMovedEl.style.display = 'none';
    }
  }

  // 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) {
    // Previously saved multi-type config
    const savedSubs = new Set(s.video_subtypes || []);
    _setTypeCheckboxes(new Set(s.model_types), savedSubs);
    det.textContent = '';
  } else if (s.model_type) {
    // Single saved type
    const normType = s.model_type === 'gguf_models' ? 'text_models' : s.model_type;
    let savedSubs = new Set(s.video_subtypes || []);
    // Legacy video_models with no sub-types: infer from capabilities or default T2V
    if (normType === 'video_models' && savedSubs.size === 0) {
      const caps = m.capabilities || [];
      if (caps.includes('image_to_video')) savedSubs.add('i2v');
      if (caps.includes('video_to_video')) savedSubs.add('v2v');
      if (caps.includes('video_generation') || savedSubs.size === 0) savedSubs.add('t2v');
    }
    _setTypeCheckboxes(new Set([normType]), savedSubs);
    det.textContent = '';
  } else {
    // Auto-detect from capabilities
    const caps = m.capabilities || [];
    const {categories, subs} = _capabilitiesToTypes(caps);
    if (categories.size > 0) {
      _setTypeCheckboxes(categories, subs);
      det.textContent = '(auto-detected)';
    } else {
      const rawType = m.defaultType === 'gguf_models' ? 'text_models' : (m.defaultType || 'text_models');
      let fallbackSubs = new Set();
      if (rawType === 'video_models') {
        // No capabilities matched; default to T2V for unclassified video models
        fallbackSubs.add('t2v');
      }
      _setTypeCheckboxes(new Set([rawType]), fallbackSubs);
      det.textContent = m.cacheType === 'gguf' ? '(auto-detected: GGUF text model)' : '';
    }
  }

  // Capabilities checkboxes: saved config > auto-detected from model
  const capsdet = document.getElementById('cfg-caps-autodet');
  const savedCaps = new Set(s.capabilities && s.capabilities.length ? s.capabilities : (m.capabilities || []));
  const capsFromConfig = !!(s.capabilities && s.capabilities.length);
  document.querySelectorAll('.cfg-cap-cb').forEach(cb => { cb.checked = savedCaps.has(cb.value); });
  capsdet.textContent = capsFromConfig ? '' : '(auto-detected)';

  document.getElementById('cfg-alias').value = s.alias || '';
  document.getElementById('cfg-config-name').value = s.config_name || '';
  // Show config-name field only when multiple configs exist or a new one is being added to an existing model
  document.getElementById('cfg-config-name-row').style.display =
    (configs.length > 1 || (isNewConfig && m.in_config)) ? '' : 'none';
  document.getElementById('cfg-backend').value = s.backend || 'auto';
  document.getElementById('cfg-load-mode').value = s.load_mode || 'on-request';
  // Used VRAM
  const usedVram = s.used_vram_gb != null ? s.used_vram_gb : null;
  document.getElementById('cfg-used-vram').value = usedVram != null ? usedVram : '';
  // Show estimate hint (weights × 1.15 + KV cache ~0.5 MB per 1 K ctx)
  const nCtxForEst = s.n_ctx || 2048;
  const estVram = _estimateVram(m, nCtxForEst);
  document.getElementById('cfg-used-vram-hint').textContent = estVram ? `Estimated: ~${estVram.toFixed(1)} GB` : '';
  document.getElementById('cfg-force-vram-update').checked = !!s.force_vram_update;
  document.getElementById('cfg-gpu-layers').value = s.n_gpu_layers !== undefined ? s.n_gpu_layers : -1;
  document.getElementById('cfg-n-ctx').value = nCtxForEst;
  document.getElementById('cfg-n-batch').value = s.n_batch != null ? s.n_batch : '';
  document.getElementById('cfg-n-seq-max').value = s.n_seq_max != null ? s.n_seq_max : '';
  document.getElementById('cfg-cache-type-k').value = s.cache_type_k || '';
  document.getElementById('cfg-cache-type-v').value = s.cache_type_v || '';
  _populateMmprojSelect(m, s);
  document.getElementById('cfg-max-instances').value = s.max_instances != null ? s.max_instances : 1;
  document.getElementById('cfg-preload-all-instances').checked = !!s.preload_all_instances;
  _updatePreloadAllVisibility();
  const instStatusEl = document.getElementById('cfg-instances-status');
  if(instStatusEl){
    const instKey = _findLoadedKey([m.path]);
    const info = instKey ? _instanceInfo[instKey] : null;
    if(info && info.max > 1){
      instStatusEl.textContent = `Currently loaded: ${info.loaded}/${info.max} instances`;
      instStatusEl.style.display = 'block';
    } else {
      instStatusEl.textContent = '';
      instStatusEl.style.display = 'none';
    }
  }
  document.getElementById('cfg-max-gpu').value = s.max_gpu_percent != null ? s.max_gpu_percent : '';
  document.getElementById('cfg-ram-gb').value = s.manual_ram_gb != null ? s.manual_ram_gb : '';
  document.getElementById('cfg-autocompact').checked = !!s.auto_compact;
  document.getElementById('cfg-autocompact-pct').value = s.auto_compact_pct != null ? s.auto_compact_pct : 85;
  document.getElementById('cfg-autocompact-strategy').value = s.auto_compact_strategy || 'drop_oldest';
  _populateCompactModelSelect(s.auto_compact_model || '');
  document.getElementById('cfg-suppress-reasoning').checked = !!s.suppress_reasoning;
  _toggleAutoCompact();
  document.getElementById('cfg-4bit').checked = !!s.load_in_4bit;
  document.getElementById('cfg-8bit').checked = !!s.load_in_8bit;
  document.getElementById('cfg-quant-backend').value = s.quant_backend || 'auto';
  _refreshQuantStatus(m.path);
  _renderComponentQuant(s.component_quantization || {});
  document.getElementById('cfg-flash').checked = !!s.flash_attention;
  document.getElementById('cfg-noram').checked = !!s.no_ram;
  document.getElementById('cfg-offload-strategy').value = s.offload_strategy || 'auto';
  document.getElementById('cfg-offload-dir').value = s.offload_dir || _defaultOffloadDir;
  _applyBackendCfgVisibility(m);
  // gpu_split → "Split — <lead engine> first" (lead = the pinned engine, or the
  // first engine if none); otherwise show the single-card pin. Ratio fills the field.
  _populateEnginePin(s.gpu_split ? ('__split__:' + ((s.engine || '').trim() || (_engineNames[0] || ''))) : (s.engine || ''));
  document.getElementById('cfg-engine-fallback').checked = !!s.engine_fallback;
  document.getElementById('cfg-tensor-split').value = s.tensor_split || '';
  document.getElementById('cfg-split-strategy').value = s.split_strategy || 'vram';
  document.getElementById('cfg-split-secondary-cap').value =
    (s.split_secondary_cap_gb != null ? s.split_secondary_cap_gb : '');
  _populateDs4(m, s);
  document.getElementById('cfg-sysprompt').value = s.system_prompt || '';
  document.getElementById('cfg-parser').value = s.parser || (!m.in_config ? _autoDetectParser(m.path) : 'auto');
  document.getElementById('cfg-tools').checked = !!s.tools_closer_prompt;
  document.getElementById('cfg-grammar').checked = !!s.grammar_guided;
  document.getElementById('cfg-template').value = s.model_template || '';
  document.getElementById('cfg-max-vram').value = s.max_vram != null ? s.max_vram : '';
  document.getElementById('cfg-sdcpp-flash').checked = !!s.sdcpp_flash_attn;
  document.getElementById('cfg-sdcpp-diff-flash').checked = !!s.sdcpp_diffusion_flash_attn;
  document.getElementById('cfg-vae-tiling').checked = !!s.vae_tiling;
  document.getElementById('cfg-output-crf').value = (s.output_crf != null ? s.output_crf : '');
  document.getElementById('cfg-balanced-gpu-pct').value = (s.balanced_gpu_percent != null ? s.balanced_gpu_percent : '');
  _populateCompSelects();
  _setCompField('vae',        s.vae_path        || '');
  _setCompField('t5xxl',      s.t5xxl_path      || '');
  _setCompField('clip-l',     s.clip_l_path     || '');
  _setCompField('clip-g',     s.clip_g_path     || '');
  _setCompField('clip-vision',s.clip_vision_path|| '');
  _setCompField('lora',       s.lora_path       || '');
  document.getElementById('cfg-lora-dir').value = s.lora_model_dir || '';
  document.getElementById('cfg-lora-train-base').value = s.lora_train_base_model || '';
  _populateAccel(s.acceleration);
  _populateTurboQuant(s.turboquant);
  openModal('cfg-modal');
}

// ---- Acceleration / distillation (Lightning / Turbo / LCM / Hyper-SD) ----
let _accelPresets = null;
async function _loadAccelPresets(){
  if (_accelPresets) return _accelPresets;
  try {
    const r = await fetch(ROOT_PATH + '/admin/api/accel-presets');
    const d = await r.json();
    _accelPresets = d.presets || {};
  } catch(e){ _accelPresets = {}; }
  return _accelPresets;
}
function _accelAppliesTo(){
  // Which families the current model-type selection allows (image / video).
  const types = new Set([...document.querySelectorAll('.cfg-type-cb:checked')].map(cb=>cb.value));
  const out = new Set();
  if (types.has('image_models')) out.add('image');
  if (types.has('video_models')) out.add('video');
  return out;
}
function _refreshAccelVisibility(){
  const section = document.getElementById('cfg-accel-section');
  if (!section) return;
  const applies = _accelAppliesTo();
  section.style.display = applies.size ? '' : 'none';
  // Rebuild the preset dropdown filtered to the applicable families.
  const sel = document.getElementById('cfg-accel-preset');
  if (!sel) return;
  const cur = sel.value;
  sel.innerHTML = '<option value="custom">Custom (set fields manually)</option>';
  const presets = _accelPresets || {};
  Object.keys(presets).forEach(k=>{
    const p = presets[k];
    const at = p.applies_to || [];
    if (!applies.size || at.some(a=>applies.has(a))){
      const o = document.createElement('option');
      o.value = k; o.textContent = p.label || k;
      sel.appendChild(o);
    }
  });
  // Preserve the previously selected preset if still present.
  if ([...sel.options].some(o=>o.value===cur)) sel.value = cur;
}
function onAccelToggle(){
  const on = document.getElementById('cfg-accel-enabled').checked;
  document.getElementById('cfg-accel-fields').style.display = on ? '' : 'none';
}
function onAccelPreset(){
  const key = document.getElementById('cfg-accel-preset').value;
  const p = (_accelPresets || {})[key];
  if (!p) return;  // "custom" — leave fields as-is
  document.getElementById('cfg-accel-lora').value = p.lora || '';
  document.getElementById('cfg-accel-lora-high').value = p.lora_high || '';
  document.getElementById('cfg-accel-lora-low').value = p.lora_low || '';
  document.getElementById('cfg-accel-weight').value = p.lora_weight != null ? p.lora_weight : '';
  document.getElementById('cfg-accel-steps').value = p.steps != null ? p.steps : '';
  document.getElementById('cfg-accel-guidance').value = p.guidance_scale != null ? p.guidance_scale : '';
  document.getElementById('cfg-accel-flowshift').value = p.flow_shift != null ? p.flow_shift : '';
  document.getElementById('cfg-accel-scheduler').value = p.scheduler || '';
  _syncAccelDistillFromFields();
}

// ---- Cascading distill-LoRA pickers (cached files) ----
let _accelLoras = null;
async function _loadAccelLoras(){
  if (_accelLoras) return _accelLoras;
  try {
    const r = await fetch(ROOT_PATH + '/admin/api/accel-loras');
    const d = await r.json();
    _accelLoras = d.models || [];
  } catch(e){ _accelLoras = []; }
  return _accelLoras;
}
function _refreshAccelDistill(){
  const sel = document.getElementById('cfg-accel-distill');
  if (!sel) return;
  const cur = sel.value;
  sel.innerHTML = '<option value="">— manual / pick a preset —</option>';
  (_accelLoras || []).forEach(m=>{
    const o = document.createElement('option');
    o.value = m.repo;
    o.textContent = m.repo + '  (' + m.files.length + ' file' + (m.files.length===1?'':'s') + ')';
    sel.appendChild(o);
  });
  if ([...sel.options].some(o=>o.value===cur)) sel.value = cur;
  const hint = document.getElementById('cfg-accel-distill-hint');
  if (hint && !(_accelLoras || []).length){
    hint.textContent = 'No distill-LoRA repos found in the local cache — use a preset or type the path/repo manually below.';
  }
}
function _fillAccelSel(selId, repo, files, keep){
  const sel = document.getElementById(selId);
  if (!sel) return;
  sel.innerHTML = '<option value="">— choose —</option>';
  (files || []).forEach(f=>{
    const o = document.createElement('option');
    o.value = repo + ':' + f; o.textContent = f;
    sel.appendChild(o);
  });
  sel.style.display = (files && files.length) ? '' : 'none';
  if (keep && [...sel.options].some(o=>o.value===keep)) sel.value = keep;
}
function onAccelDistill(){
  const repo = document.getElementById('cfg-accel-distill').value;
  const m = (_accelLoras || []).find(x=>x.repo===repo);
  if (!m){
    ['cfg-accel-lora-sel','cfg-accel-lora-high-sel','cfg-accel-lora-low-sel'].forEach(id=>{
      const s=document.getElementById(id); if(s){ s.innerHTML=''; s.style.display='none'; }
    });
    return;
  }
  // Single-LoRA dropdown = all files; high/low = classified subsets (fall back to
  // all files when the repo doesn't name them by noise level).
  _fillAccelSel('cfg-accel-lora-sel', repo, m.files);
  _fillAccelSel('cfg-accel-lora-high-sel', repo, (m.high && m.high.length) ? m.high : m.files);
  _fillAccelSel('cfg-accel-lora-low-sel', repo, (m.low && m.low.length) ? m.low : m.files);
}
function onAccelLoraPick(kind){
  const tmap = {lora:'cfg-accel-lora', high:'cfg-accel-lora-high', low:'cfg-accel-lora-low'};
  const smap = {lora:'cfg-accel-lora-sel', high:'cfg-accel-lora-high-sel', low:'cfg-accel-lora-low-sel'};
  const v = document.getElementById(smap[kind]).value;
  if (v) document.getElementById(tmap[kind]).value = v;
}
// On load (or after a preset fill), preselect the distill model + dropdowns from
// whatever repo the current high/single LoRA field points at.
function _syncAccelDistillFromFields(){
  const sel = document.getElementById('cfg-accel-distill');
  if (!sel) return;
  const refHigh = (document.getElementById('cfg-accel-lora-high').value || '').trim();
  const refLow  = (document.getElementById('cfg-accel-lora-low').value || '').trim();
  const refOne  = (document.getElementById('cfg-accel-lora').value || '').trim();
  const ref = refHigh || refOne || refLow;
  const repo = ref.includes(':') ? ref.split(':')[0] : ref;
  if (repo && [...sel.options].some(o=>o.value===repo)){
    sel.value = repo;
    onAccelDistill();
    const m = (_accelLoras || []).find(x=>x.repo===repo);
    if (m){
      _fillAccelSel('cfg-accel-lora-sel', repo, m.files, refOne);
      _fillAccelSel('cfg-accel-lora-high-sel', repo, (m.high&&m.high.length)?m.high:m.files, refHigh);
      _fillAccelSel('cfg-accel-lora-low-sel', repo, (m.low&&m.low.length)?m.low:m.files, refLow);
    }
  }
}
async function _populateAccel(a){
  await _loadAccelPresets();
  await _loadAccelLoras();
  _refreshAccelVisibility();
  _refreshAccelDistill();
  a = a || {};
  document.getElementById('cfg-accel-enabled').checked = !!a.enabled;
  const sel = document.getElementById('cfg-accel-preset');
  sel.value = [...sel.options].some(o=>o.value===(a.preset||'')) ? a.preset : 'custom';
  document.getElementById('cfg-accel-lora').value = a.lora || '';
  document.getElementById('cfg-accel-lora-high').value = a.lora_high || '';
  document.getElementById('cfg-accel-lora-low').value = a.lora_low || '';
  document.getElementById('cfg-accel-weight').value = a.lora_weight != null ? a.lora_weight : '';
  document.getElementById('cfg-accel-steps').value = a.steps != null ? a.steps : '';
  document.getElementById('cfg-accel-guidance').value = a.guidance_scale != null ? a.guidance_scale : '';
  document.getElementById('cfg-accel-flowshift').value = a.flow_shift != null ? a.flow_shift : '';
  document.getElementById('cfg-accel-scheduler').value = a.scheduler || '';
  _syncAccelDistillFromFields();
  onAccelToggle();
}
function _collectAccel(){
  if (!document.getElementById('cfg-accel-enabled').checked) return null;
  const num = id => { const v = document.getElementById(id).value.trim(); return v === '' ? null : parseFloat(v); };
  return {
    enabled: true,
    preset: document.getElementById('cfg-accel-preset').value || 'custom',
    lora: document.getElementById('cfg-accel-lora').value.trim() || null,
    lora_high: document.getElementById('cfg-accel-lora-high').value.trim() || null,
    lora_low: document.getElementById('cfg-accel-lora-low').value.trim() || null,
    lora_weight: num('cfg-accel-weight'),
    steps: num('cfg-accel-steps'),
    guidance_scale: num('cfg-accel-guidance'),
    flow_shift: num('cfg-accel-flowshift'),
    scheduler: document.getElementById('cfg-accel-scheduler').value.trim() || '',
  };
}

// ---- TurboQuant (embedding vector quantization) ----
let _tqInfo = null;
async function _loadTurboQuantInfo(){
  if (_tqInfo) return _tqInfo;
  try {
    const r = await fetch(ROOT_PATH + '/admin/api/turboquant-info');
    _tqInfo = await r.json();
  } catch(e){ _tqInfo = {builtin:true, library:false}; }
  return _tqInfo;
}
function _turboQuantApplies(){
  return [...document.querySelectorAll('.cfg-type-cb:checked')]
    .some(cb => cb.value === 'embedding_models');
}
function _refreshTurboQuantVisibility(){
  const section = document.getElementById('cfg-turboquant-section');
  if (section) section.style.display = _turboQuantApplies() ? '' : 'none';
}
function onTurboQuantToggle(){
  const on = document.getElementById('cfg-tq-enabled').checked;
  document.getElementById('cfg-tq-fields').style.display = on ? '' : 'none';
}
async function _populateTurboQuant(t){
  await _loadTurboQuantInfo();
  _refreshTurboQuantVisibility();
  // Reflect library availability in the backend dropdown + hint.
  const libOpt = document.querySelector('#cfg-tq-backend option[value="library"]');
  const hint = document.getElementById('cfg-tq-backend-hint');
  const libOk = !!(_tqInfo && _tqInfo.library);
  if (libOpt){ libOpt.disabled = !libOk; libOpt.textContent =
    'turboquant-py library (QJL)' + (libOk ? '' : ' — not installed'); }
  if (hint) hint.textContent = libOk
    ? 'turboquant-py detected.'
    : 'Install "turboquant-py[torch]" to enable the library backend.';
  t = t || {};
  document.getElementById('cfg-tq-enabled').checked = !!t.enabled;
  document.getElementById('cfg-tq-backend').value =
    (t.backend === 'library' && libOk) ? 'library' : 'builtin';
  document.getElementById('cfg-tq-bits').value = String(t.bits || 8);
  onTurboQuantToggle();
}
function _collectTurboQuant(){
  if (!document.getElementById('cfg-tq-enabled').checked) return null;
  return {
    enabled: true,
    backend: document.getElementById('cfg-tq-backend').value || 'builtin',
    bits: parseInt(document.getElementById('cfg-tq-bits').value) || 8,
  };
}

function _updatePreloadAllVisibility() {
  const loadMode = document.getElementById('cfg-load-mode').value;
  const maxInst = parseInt(document.getElementById('cfg-max-instances').value) || 1;
  const row = document.getElementById('cfg-preload-all-row');
  row.style.display = (loadMode === 'load' && maxInst > 1) ? '' : 'none';
}

function _estimateVram(m, nCtx) {
  // Mirror the server-side formula: weights × 1.15 + KV cache (0.5 MB per 1 K ctx)
  if (!m.size_gb) return null;
  const weights = m.size_gb * (m.cacheType === 'gguf' ? 1.0 : 1.1);
  const kvCacheGb = ((nCtx || 2048) / 1024) * 0.5 / 1024;
  return weights * 1.15 + kvCacheGb;
}

function saveNewModelConfig(){
  // Clear config_id so saveModelConfig creates a new entry instead of updating
  document.getElementById('cfg-config-id').value = '';
  saveModelConfig();
}

async function removeThisConfig(){
  const path = document.getElementById('cfg-path').value;
  const configId = document.getElementById('cfg-config-id').value.trim();
  if (!configId) return;
  if (!await showConfirm('Remove configuration', 'Remove this configuration? The model file and other configurations will be kept.', 'Remove')) return;
  try {
    const r = await fetch(ROOT_PATH + '/admin/api/model-disable', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({path, config_id: configId})
    });
    if (!r.ok) {
      let msg = `HTTP ${r.status}`;
      try { const d = await r.json(); msg = d.detail || d.error || msg; } catch(_){}
      throw new Error(msg);
    }
    closeModal('cfg-modal');
    loadCachedModels();
  } catch(e) { showAlert('Error: ' + e.message); }
}

// Show the weight-distribution row only when a "Split — <engine> first" option
// (value "__split__:<engine>") is chosen.
function _updateGpuSplitVisibility(){
  const sel = document.getElementById('cfg-engine');
  const row = document.getElementById('cfg-gpu-split-row');
  if(!sel || !row) return;
  row.style.display = (sel.value || '').startsWith('__split__:') ? '' : 'none';
}

// Engine-pin field: populate from the live engine names. Per engine we add two
// options: a single-card pin (the name itself) and a "Split — <name> first"
// (value "__split__:<name>") that pools the model across all GPUs with that
// engine's card leading. Only shown when >1 engine is configured.
async function _populateEnginePin(desired){
  const row = document.getElementById('cfg-engine-row');
  const sel = document.getElementById('cfg-engine');
  try {
    if (!_engineNames || !_engineNames.length) await _loadEngineInfo();
    const want = (desired !== undefined) ? desired : sel.value;
    // Clear everything except the static "" (Auto) option.
    sel.querySelectorAll('option[value]:not([value=""])').forEach(o => o.remove());
    _engineNames.forEach(n => {
      const o=document.createElement('option'); o.value=n; o.textContent=n + ' (single card)'; sel.appendChild(o);
    });
    if (_engineNames.length > 1) {
      _engineNames.forEach(n => {
        const o=document.createElement('option'); o.value='__split__:'+n;
        o.textContent='Split — ' + n + ' first'; sel.appendChild(o);
      });
    }
    sel.value = want || '';   // set AFTER options exist so the selection sticks
    row.style.display = _engineNames.length > 1 ? '' : 'none';
  } catch(e) { row.style.display = 'none'; }
  _updateGpuSplitVisibility();
}

// Per-model ds4 overrides: shown only when the ds4 engine is enabled globally and
// the model is a deepseek4 (the engine that ds4-server serves). Fields left blank
// inherit the global ds4 config.
function _populateDs4(m, s){
  const row = document.getElementById('cfg-ds4-row');
  if(!row) return;
  const hay = (((m && (m.path||m.id)) || '') + ' ' + ((s && s.alias) || '')).toLowerCase();
  const isDeepSeek4 = /deepseek[\s\-_]?v4|ds4/.test(hay);
  row.style.display = (_ds4Enabled && isDeepSeek4) ? '' : 'none';
  const d = (s && s.ds4) || {};
  document.getElementById('cfg-ds4-ssd').value =
    (d.ssd_streaming === true ? 'true' : (d.ssd_streaming === false ? 'false' : ''));
  document.getElementById('cfg-ds4-reserve').value =
    (d.expert_cache_reserve_gb != null ? d.expert_cache_reserve_gb : '');
  document.getElementById('cfg-ds4-extra-args').value = d.extra_args || '';
  document.getElementById('cfg-ds4-extra-env').value = d.extra_env || '';
}

// Collect the per-model ds4 overrides; only set fields are included (blank =
// inherit). The backend normalizes and drops the block when empty.
function _collectDs4(){
  const ssd = document.getElementById('cfg-ds4-ssd').value;       // '' | 'true' | 'false'
  const reserve = parseInt(document.getElementById('cfg-ds4-reserve').value);
  const ea = document.getElementById('cfg-ds4-extra-args').value.trim();
  const ev = document.getElementById('cfg-ds4-extra-env').value.trim();
  const o = {};
  if(ssd === 'true' || ssd === 'false') o.ssd_streaming = (ssd === 'true');
  if(!isNaN(reserve) && reserve > 0) o.expert_cache_reserve_gb = reserve;
  if(ea) o.extra_args = ea;
  if(ev) o.extra_env = ev;
  return o;
}

async function saveModelConfig(){
  const path = document.getElementById('cfg-path').value;
  const maxGpu = parseFloat(document.getElementById('cfg-max-gpu').value);
  const ramGb  = parseFloat(document.getElementById('cfg-ram-gb').value);
  const usedVram = parseFloat(document.getElementById('cfg-used-vram').value);
  const {primaryType, model_types, video_subtypes} = _getCheckedTypes();
  const origPath = document.getElementById('cfg-orig-path').value;
  const configId = document.getElementById('cfg-config-id').value.trim();
  const data = {
    path,
    config_id:         configId || undefined,
    orig_path:         (origPath && origPath !== path) ? origPath : undefined,
    model_type:        primaryType,
    model_types:       model_types,
    video_subtypes:    video_subtypes.length ? video_subtypes : undefined,
    alias:             document.getElementById('cfg-alias').value.trim() || null,
    config_name:       document.getElementById('cfg-config-name').value.trim() || null,
    backend:           document.getElementById('cfg-backend').value,
    load_mode:         document.getElementById('cfg-load-mode').value,
    used_vram_gb:      isNaN(usedVram) ? null : usedVram,
    force_vram_update: document.getElementById('cfg-force-vram-update').checked,
    max_instances:     parseInt(document.getElementById('cfg-max-instances').value) || 1,
    preload_all_instances: document.getElementById('cfg-preload-all-instances').checked,
    n_gpu_layers:      parseInt(document.getElementById('cfg-gpu-layers').value) || -1,
    n_ctx:             parseInt(document.getElementById('cfg-n-ctx').value) || 2048,
    n_batch:           parseInt(document.getElementById('cfg-n-batch').value) || null,
    n_seq_max:         parseInt(document.getElementById('cfg-n-seq-max').value) || null,
    cache_type_k:      document.getElementById('cfg-cache-type-k').value || null,
    cache_type_v:      document.getElementById('cfg-cache-type-v').value || null,
    mmproj:            document.getElementById('cfg-mmproj').value || null,
    auto_compact:      document.getElementById('cfg-autocompact').checked,
    auto_compact_pct:  parseInt(document.getElementById('cfg-autocompact-pct').value) || 85,
    auto_compact_strategy: document.getElementById('cfg-autocompact-strategy').value || 'drop_oldest',
    auto_compact_model: document.getElementById('cfg-autocompact-model').value || '',
    suppress_reasoning: document.getElementById('cfg-suppress-reasoning').checked,
    max_gpu_percent:   isNaN(maxGpu) ? null : maxGpu,
    manual_ram_gb:     isNaN(ramGb) ? null : ramGb,
    load_in_4bit:      document.getElementById('cfg-4bit').checked,
    load_in_8bit:      document.getElementById('cfg-8bit').checked,
    quant_backend:     document.getElementById('cfg-quant-backend').value || 'auto',
    component_quantization: _collectComponentQuant(),
    flash_attention:   document.getElementById('cfg-flash').checked,
    no_ram:            document.getElementById('cfg-noram').checked,
    offload_strategy:  document.getElementById('cfg-offload-strategy').value,
    offload_dir:       document.getElementById('cfg-offload-dir').value.trim() || './offload',
    // "Split — <engine> first" → gpu_split + pin to <engine> as the lead card.
    // A plain engine name → single-card pin. "" → auto single GPU.
    engine:            (function(){ const v=document.getElementById('cfg-engine').value.trim();
                          return v.startsWith('__split__:') ? (v.slice(10) || null) : (v || null); })(),
    engine_fallback:   document.getElementById('cfg-engine-fallback').checked,
    gpu_split:         document.getElementById('cfg-engine').value.trim().startsWith('__split__:'),
    tensor_split:      (document.getElementById('cfg-engine').value.trim().startsWith('__split__:')
                          ? (document.getElementById('cfg-tensor-split').value.trim() || null) : null),
    split_strategy:    (document.getElementById('cfg-engine').value.trim().startsWith('__split__:')
                          ? document.getElementById('cfg-split-strategy').value : null),
    split_secondary_cap_gb: (function(){
                          if (!document.getElementById('cfg-engine').value.trim().startsWith('__split__:')) return null;
                          const v = document.getElementById('cfg-split-secondary-cap').value.trim();
                          return v === '' ? null : parseFloat(v); })(),
    ds4:               _collectDs4(),
    system_prompt:     document.getElementById('cfg-sysprompt').value.trim() || null,
    parser:            document.getElementById('cfg-parser').value,
    tools_closer_prompt: document.getElementById('cfg-tools').checked,
    grammar_guided:    document.getElementById('cfg-grammar').checked,
    capabilities:      [...document.querySelectorAll('.cfg-cap-cb:checked')].map(cb => cb.value),
    model_template:    document.getElementById('cfg-template').value || null,
    vae_path:          _readCompField('vae')         || null,
    t5xxl_path:        _readCompField('t5xxl')       || null,
    clip_l_path:       _readCompField('clip-l')      || null,
    clip_g_path:       _readCompField('clip-g')      || null,
    clip_vision_path:  _readCompField('clip-vision') || null,
    lora_path:         _readCompField('lora')        || null,
    lora_model_dir:    document.getElementById('cfg-lora-dir').value.trim() || null,
    lora_train_base_model: document.getElementById('cfg-lora-train-base').value.trim() || null,
    max_vram:          parseFloat(document.getElementById('cfg-max-vram').value) || 0,
    sdcpp_flash_attn:  document.getElementById('cfg-sdcpp-flash').checked,
    sdcpp_diffusion_flash_attn: document.getElementById('cfg-sdcpp-diff-flash').checked,
    vae_tiling:        document.getElementById('cfg-vae-tiling').checked,
    output_crf:        (document.getElementById('cfg-output-crf').value.trim() === ''
                          ? null : parseInt(document.getElementById('cfg-output-crf').value)),
    balanced_gpu_percent: (document.getElementById('cfg-balanced-gpu-pct').value.trim() === ''
                          ? null : parseFloat(document.getElementById('cfg-balanced-gpu-pct').value)),
    acceleration:      _collectAccel(),
    turboquant:        _collectTurboQuant(),
  };
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/model-configure',{
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify(data)
    });
    const d = await r.json();
    if(d.success){
      if (d.warnings && d.warnings.length) {
        showAlert('Saved, but check this:\n\n• ' + d.warnings.join('\n• '));
      }
      closeModal('cfg-modal'); loadCachedModels();
    }
    else showAlert('Error: '+(d.detail||'Unknown'));
  }catch(e){ showAlert('Error: '+e.message); }
}

// Whisper-server models inherit their load mode from the backing GGUF model's
// config (there's no separate load-mode control on the whisper forms), defaulting
// to on-request when the file isn't configured as a model yet.
function _deriveWhisperLoadMode(ggufPath){
  const f = (_ggufFiles||[]).find(x => x && x.path === ggufPath);
  const s = (f && (f.settings || (f.configs && f.configs[0] && f.configs[0].settings))) || {};
  return s.load_mode || 'on-request';
}

async function addWhisperServerModel(){
  const usedVram = parseFloat(document.getElementById('ws-used-vram').value);
  const modelSource = document.getElementById('ws-model-source').value;
  const modelPath = getWhisperServerModelPath();
  const payload = {
    model_id: document.getElementById('ws-model-id').value.trim(),
    model_type: 'audio_models',
    backend: 'whisper-server',
    model_source: modelSource,
    server_path: document.getElementById('ws-server-path').value.trim(),
    model_path: modelPath,
    port: parseInt(document.getElementById('ws-port').value, 10) || 8744,
    gpu_device: parseInt(document.getElementById('ws-gpu-device').value, 10) || 0,
    load_mode: _deriveWhisperLoadMode(modelPath),
    used_vram_gb: Number.isNaN(usedVram) ? null : usedVram,
    alias: document.getElementById('ws-alias').value.trim() || null,
  };
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/model-configure', {
      method:'POST',
      headers:{'Content-Type':'application/json'},
      body: JSON.stringify(payload)
    });
    const d = await r.json();
    if(!r.ok) throw new Error(d.detail || 'Failed to add whisper-server model');
    document.getElementById('ws-model-id').value = nextWhisperServerModelId();
    document.getElementById('ws-server-path').value = defaultWhisperServerPath();
    document.getElementById('ws-model-source').value = 'cached-gguf';
    document.getElementById('ws-gguf-select').value = d.model_path || '';
    document.getElementById('ws-model-path').value = '';
    document.getElementById('ws-port').value = '8744';
    document.getElementById('ws-gpu-device').value = '0';
    document.getElementById('ws-used-vram').value = '';
    document.getElementById('ws-alias').value = '';
    document.getElementById('ws-submit-btn').textContent = 'Add model';
    toggleWhisperModelSource();
    refreshLocal();
  }catch(e){ showAlert('Error: '+e.message); }
}

toggleWhisperModelSource();

async function loadModel(idx){
  const m = _localModels[idx];
  // Find the button and show loading state
  const btn = document.querySelector(`button[onclick="loadModel(${idx})"]`);
  if(btn){ btn.disabled = true; btn.textContent = 'Loading…'; }
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/model-load',{
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({path: m.path})
    });
    const d = await r.json();
    if(d.success) refreshLocal();
    else{ if(btn){ btn.disabled=false; btn.textContent='Load now'; } showAlert('Error: '+(d.detail||'Unknown')); }
  }catch(e){ if(btn){ btn.disabled=false; btn.textContent='Load now'; } showAlert('Error: '+e.message); }
}

async function unloadModel(idx){
  const m = _localModels[idx];
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/model-unload',{
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({path: m.path})
    });
    const d = await r.json();
    if(d.success) refreshLocal();
    else showAlert('Error: '+(d.detail||'Unknown'));
  }catch(e){ showAlert('Error: '+e.message); }
}

async function disableModel(idx, keepReference){
  const m = _localModels[idx];
  if(!m){ showAlert('Error: model not found — please refresh the page.'); return; }
  // A freed (missing) model has no files left, so removing its config would make
  // it vanish entirely. keepReference strips the serving config but re-adds a bare
  // known reference so it stays in the list and remains re-downloadable.
  const keep = keepReference || !!m.missing;
  const msg = keep
    ? 'Remove this model\'s configuration? It will stay in your list as a known model you can re-download later.'
    : 'Remove this model from CoderAI config? It will stay in the local cache.';
  if(!await showConfirm('Remove model', msg, 'Remove')) return;
  try{
    // Collect all paths that are marked in_config for this model (covers quant groups)
    const paths = m.ggufGroup
      ? m.ggufGroup.filter(f=>f.in_config).map(f=>f.path)
      : (m.in_config ? [m.path] : []);
    if(!paths.length) paths.push(m.path);  // fallback
    // Disable each configured path in the group
    for(const p of paths){
      const r = await fetch(ROOT_PATH + '/admin/api/model-disable',{
        method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({path: p})
      });
      if(!r.ok){
        let msg = `HTTP ${r.status}`;
        try{ const d = await r.json(); msg = d.detail || d.error || msg; }catch(_){}
        throw new Error(msg);
      }
    }
    if(keep){
      // No files left on disk — record the model in the "to download" wishlist so
      // it survives config removal and stays listed as non-configured / to-download.
      const repo = m.source_repo || (m.cacheType==='hf' ? m.path : '');
      await fetch(ROOT_PATH + '/admin/api/model-mark-download', {
        method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({model_id: repo || m.path, source_repo: repo || m.path,
          model_type: m.defaultType || 'text_models', is_gguf: m.cacheType==='gguf'})
      });
    }
    loadCachedModels();
  }catch(e){ showAlert('Error: '+e.message); }
}

// Drop a model from the "to download" wishlist (it should no longer be listed).
async function unmarkDownload(idx){
  const m = _localModels[idx];
  if(!m){ showAlert('Error: model not found — please refresh the page.'); return; }
  if(!await showConfirm('Remove from list',
      `Remove "${m.label}" from your download list? It will no longer appear here until you add or download it again.`,
      'Remove')) return;
  try{
    const r = await fetch(ROOT_PATH + '/admin/api/model-unmark-download', {
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({model_id: m.source_repo || m.path})
    });
    const d = await r.json().catch(()=>({}));
    if(d.success) refreshLocal();
    else showAlert('Error: '+(d.detail||d.error||'Unknown'));
  }catch(e){ showAlert('Error: '+e.message); }
}

async function startUpload(){
  const fileInput = document.getElementById('upload-file');
  const file = fileInput.files[0];
  if(!file){ showAlert('Please select a file'); return; }
  if(!file.name.endsWith('.gguf')){ showAlert('Only .gguf files are supported'); return; }

  document.getElementById('upload-form').style.display='none';
  document.getElementById('upload-progress').style.display='block';
  document.getElementById('upload-filename').textContent = file.name;

  const chunkSize = 512 * 1024; // 512KB
  const totalChunks = Math.ceil(file.size / chunkSize);
  const uploadId = Date.now() + '_' + file.name;

  for(let i=0; i<totalChunks; i++){
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);

    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('filename', file.name);
    formData.append('chunk_index', i);
    formData.append('total_chunks', totalChunks);
    formData.append('upload_id', uploadId);

    try{
      const r = await fetch(ROOT_PATH + '/admin/api/model-upload',{method:'POST', body:formData});
      const d = await r.json();
      if(!d.success){ showAlert('Upload failed: '+(d.detail||'Unknown')); return; }

      const pct = Math.round((i+1)/totalChunks*100);
      document.getElementById('upload-bar').style.width = pct+'%';
      document.getElementById('upload-pct').textContent = pct+'%';
      document.getElementById('upload-status').textContent = `Chunk ${i+1}/${totalChunks}`;

      if(d.complete){
        document.getElementById('upload-status').textContent = 'Complete!';
        setTimeout(()=>{
          closeModal('upload-modal');
          document.getElementById('upload-form').style.display='block';
          document.getElementById('upload-progress').style.display='none';
          fileInput.value='';
          refreshLocal();
        }, 1500);
        return;
      }
    }catch(e){ showAlert('Upload error: '+e.message); return; }
  }
}
</script>
{% endblock %}
