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:
+73
-18
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user