feat(tools): gateway-chat.html — auto-discover gateway models + image upload for vision smoke

Model field now pulls /v1/models (the ↻ control; new gateway models just appear)
instead of a hardcoded stale list; 📎 attaches an image (base64 data: URL in
image_url content) so the multimodal models (Qwopus, image-judge) can be smoked.
Static-verified (JS syntax + element-id consistency); headless smoke was blocked
by a shared-browser version skew in /opt/ms-playwright, not a tool defect.
This commit is contained in:
2026-06-19 11:56:20 -07:00
parent 75bd4c3679
commit 4c40b9fac6
+73 -18
View File
@@ -11,6 +11,11 @@
it directly. It deliberately NEVER sends a `tools` field, sidestepping the it directly. It deliberately NEVER sends a `tools` field, sidestepping the
vLLM "tools must not be an empty array" bug that breaks the LiteLLM UI vLLM "tools must not be an empty array" bug that breaks the LiteLLM UI
playground for vLLM-backed models. Settings persist in localStorage. playground for vLLM-backed models. Settings persist in localStorage.
Built for SMOKING new models: the Model field auto-discovers every model on
the gateway (the ↻ control pulls /v1/models — new models just appear), and 📎
attaches an image so you can smoke multimodal/vision models (Qwopus,
image-judge, …) too. Image is sent as a base64 data: URL in image_url content.
--> -->
<style> <style>
:root{--bg:#0f1115;--panel:#171a21;--ink:#e6e9ef;--muted:#8b93a7;--acc:#5b8cff;--line:#262b36;--user:#1f6feb22;--warn:#ff6b6b} :root{--bg:#0f1115;--panel:#171a21;--ink:#e6e9ef;--muted:#8b93a7;--acc:#5b8cff;--line:#262b36;--user:#1f6feb22;--warn:#ff6b6b}
@@ -38,6 +43,12 @@
.err{color:var(--warn);font-family:ui-monospace,monospace;font-size:12px;white-space:pre-wrap} .err{color:var(--warn);font-family:ui-monospace,monospace;font-size:12px;white-space:pre-wrap}
.hint{font-size:11px;color:var(--muted);margin-top:8px} .hint{font-size:11px;color:var(--muted);margin-top:8px}
code{font-family:ui-monospace,monospace} code{font-family:ui-monospace,monospace}
#refresh{cursor:pointer;color:var(--acc);font-size:10px;letter-spacing:0}
#composer{align-items:flex-end}
#attach{flex:0 0 auto;display:flex;align-items:center;justify-content:center;padding:0 12px;height:42px;font-size:18px}
#attached{display:none;align-items:center;gap:8px;font-size:11px;color:var(--muted);padding:6px 18px 0;background:var(--panel)}
#attached img{height:30px;border-radius:4px;border:1px solid var(--line)}
.msg .thumb{max-width:220px;max-height:220px;border-radius:6px;border:1px solid var(--line);margin-bottom:6px;display:block}
</style> </style>
</head> </head>
<body> <body>
@@ -47,18 +58,9 @@
<input id="base" placeholder="http://10.250.50.70:4000/v1"> <input id="base" placeholder="http://10.250.50.70:4000/v1">
<label>API key</label> <label>API key</label>
<input id="key" type="password" placeholder="sk-…"> <input id="key" type="password" placeholder="sk-…">
<label>Model</label> <label>Model <span id="refresh" title="Reload model list from /v1/models">↻ load</span></label>
<input id="model" list="models" placeholder="mistral-small-4"> <input id="model" list="models" placeholder="(pick or type — ↻ loads all gateway models)">
<datalist id="models"> <datalist id="models"></datalist>
<option>granite-4.1-8b</option>
<option>mistral-small-4</option>
<option>mistral-small-4-reasoning</option>
<option>qwen3.6-35b-a3b</option>
<option>qwen3.6-35b-a3b-thinking</option>
<option>selene-1-mini-8b</option>
<option>glm-5.1</option>
<option>glm-4.7</option>
</datalist>
<label>System prompt</label> <label>System prompt</label>
<textarea id="sys" placeholder="You are a helpful assistant."></textarea> <textarea id="sys" placeholder="You are a helpful assistant."></textarea>
<div class="row"> <div class="row">
@@ -69,7 +71,9 @@
</aside> </aside>
<main id="main"> <main id="main">
<div id="log"></div> <div id="log"></div>
<div id="attached"></div>
<div id="composer"> <div id="composer">
<label id="attach" class="ghost" title="Attach an image (smoke vision models)">📎<input id="img" type="file" accept="image/*" hidden></label>
<textarea id="inp" placeholder="Message… (Enter to send · Shift+Enter for newline)"></textarea> <textarea id="inp" placeholder="Message… (Enter to send · Shift+Enter for newline)"></textarea>
<button id="send">Send</button> <button id="send">Send</button>
<button id="reset" class="ghost" title="Clear conversation">Reset</button> <button id="reset" class="ghost" title="Clear conversation">Reset</button>
@@ -82,9 +86,45 @@ cfg.forEach(k => { const v = localStorage.getItem('gc_'+k); if (v !== null) $(k)
if (!$('base').value) $('base').value = 'http://10.250.50.70:4000/v1'; if (!$('base').value) $('base').value = 'http://10.250.50.70:4000/v1';
cfg.forEach(k => $(k).addEventListener('input', e => localStorage.setItem('gc_'+k, e.target.value))); cfg.forEach(k => $(k).addEventListener('input', e => localStorage.setItem('gc_'+k, e.target.value)));
let history = []; // [{role:'user'|'assistant', content}] let history = []; // [{role, content}] content = string | multimodal array
let attached = null; // { url:dataURL, name } staged for the NEXT message
const log = $('log'); const log = $('log');
// --- model auto-discovery: fill the datalist from /v1/models (new models just appear) ---
async function loadModels(){
try {
const res = await fetch($('base').value.replace(/\/+$/,'') + '/models',
{ headers:{ 'Authorization':'Bearer ' + $('key').value } });
if (!res.ok){ $('refresh').textContent = '↻ ' + res.status; return; }
const ids = ((await res.json()).data || []).map(m => m.id).sort();
const dl = $('models'); dl.innerHTML = '';
for (const id of ids){ const o = document.createElement('option'); o.value = id; dl.append(o); }
$('refresh').textContent = '↻ ' + ids.length;
} catch (e){ $('refresh').textContent = '↻ offline'; }
}
$('refresh').addEventListener('click', loadModels);
$('base').addEventListener('change', loadModels);
$('key').addEventListener('change', loadModels);
loadModels();
// --- image attach (vision smoke): stage a base64 data: URL for the next send ---
$('img').addEventListener('change', e => {
const f = e.target.files[0]; if (!f) return;
const r = new FileReader();
r.onload = () => { attached = { url:r.result, name:f.name }; showAttached(); };
r.readAsDataURL(f);
});
function showAttached(){
const a = $('attached');
if (!attached){ a.style.display = 'none'; a.innerHTML = ''; return; }
a.style.display = 'flex'; a.innerHTML = '';
const im = document.createElement('img'); im.src = attached.url;
const nm = document.createElement('span'); nm.textContent = attached.name;
const x = document.createElement('span'); x.textContent = '✕ remove'; x.style.cursor = 'pointer'; x.style.color = 'var(--warn)';
x.onclick = () => { attached = null; $('img').value = ''; showAttached(); };
a.append(im, nm, x);
}
function addMsg(who, cls){ function addMsg(who, cls){
const wrap = document.createElement('div'); wrap.className = 'msg ' + cls; const wrap = document.createElement('div'); wrap.className = 'msg ' + cls;
const h = document.createElement('div'); h.className = 'who'; h.textContent = who; const h = document.createElement('div'); h.className = 'who'; h.textContent = who;
@@ -96,10 +136,25 @@ function addMsg(who, cls){
function showErr(text){ const m = addMsg('error','err'); m.body.className = 'err'; m.body.textContent = text; } function showErr(text){ const m = addMsg('error','err'); m.body.className = 'err'; m.body.textContent = text; }
async function send(){ async function send(){
const text = $('inp').value.trim(); if (!text) return; const text = $('inp').value.trim();
if (!text && !attached) return;
$('inp').value = ''; $('inp').value = '';
history.push({ role:'user', content:text });
addMsg('you','user').body.textContent = text; // multimodal array if an image is staged, else a plain string
let userContent;
if (attached){
userContent = [];
if (text) userContent.push({ type:'text', text });
userContent.push({ type:'image_url', image_url:{ url:attached.url } });
} else {
userContent = text;
}
history.push({ role:'user', content:userContent });
const u = addMsg('you','user');
if (attached){ const th = document.createElement('img'); th.className = 'thumb'; th.src = attached.url; u.body.append(th); }
u.body.append(document.createTextNode(text));
attached = null; $('img').value = ''; showAttached();
const messages = []; const messages = [];
const sys = $('sys').value.trim(); const sys = $('sys').value.trim();
@@ -107,7 +162,7 @@ async function send(){
messages.push(...history); messages.push(...history);
const body = { const body = {
model: $('model').value.trim() || 'mistral-small-4', model: $('model').value.trim() || 'gen',
messages, messages,
temperature: parseFloat($('temp').value), temperature: parseFloat($('temp').value),
max_tokens: parseInt($('max').value, 10), max_tokens: parseInt($('max').value, 10),
@@ -150,7 +205,7 @@ async function send(){
$('send').addEventListener('click', send); $('send').addEventListener('click', send);
$('inp').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); send(); } }); $('inp').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); send(); } });
$('reset').addEventListener('click', () => { history = []; log.innerHTML = ''; }); $('reset').addEventListener('click', () => { history = []; log.innerHTML = ''; attached = null; $('img').value = ''; showAttached(); });
</script> </script>
</body> </body>
</html> </html>