KillerKing93/Transformers-InferenceServer-OpenAPI
0
1<!DOCTYPE html>2<html lang="en">3 <head>4 <meta charset="utf-8" />5 <title>Qwen3‑VL Chat (HF Space API)</title>6 <meta name="viewport" content="width=device-width, initial-scale=1" />7 <style>8 :root {9 --bg: #0f172a;10 --fg: #e2e8f0;11 --muted: #94a3b8;12 --accent: #6366f1;13 --card: #111827;14 --chip: #1f2937;15 --border: #334155;16 }17 html,18 body {19 height: 100%;20 margin: 0;21 background: var(--bg);22 color: var(--fg);23 font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica,24 Arial, "Apple Color Emoji", "Segoe UI Emoji";25 }26 .app {27 display: flex;28 flex-direction: column;29 height: 100%;30 max-width: 1200px;31 margin: 0 auto;32 }33 header {34 padding: 12px 16px;35 border-bottom: 1px solid var(--border);36 display: flex;37 gap: 12px;38 align-items: center;39 flex-wrap: wrap;40 }41 header .title {42 font-weight: 700;43 }44 header input[type="text"] {45 flex: 1 1 360px;46 background: var(--card);47 border: 1px solid var(--border);48 color: var(--fg);49 padding: 8px 10px;50 border-radius: 6px;51 }52 header .small {53 color: var(--muted);54 font-size: 12px;55 }56 main {57 flex: 1;58 overflow: auto;59 padding: 16px;60 display: flex;61 gap: 16px;62 }63 .chat {64 flex: 1 1 auto;65 display: flex;66 flex-direction: column;67 gap: 12px;68 }69 .msg {70 background: var(--card);71 border: 1px solid var(--border);72 border-radius: 10px;73 padding: 12px;74 }75 .msg.user {76 border-left: 3px solid #22c55e;77 }78 .msg.assistant {79 border-left: 3px solid var(--accent);80 }81 .role {82 font-weight: 700;83 margin-bottom: 6px;84 color: var(--muted);85 text-transform: uppercase;86 font-size: 12px;87 }88 .content pre {89 white-space: pre-wrap;90 word-break: break-word;91 }92 .media {93 display: flex;94 flex-wrap: wrap;95 gap: 8px;96 margin-top: 8px;97 }98 .media img,99 .media video {100 max-width: 240px;101 max-height: 180px;102 border: 1px solid var(--border);103 border-radius: 8px;104 }105 .aside {106 width: 320px;107 flex: 0 0 auto;108 display: flex;109 flex-direction: column;110 gap: 12px;111 }112 .card {113 background: var(--card);114 border: 1px solid var(--border);115 border-radius: 10px;116 padding: 12px;117 }118 .label {119 font-size: 12px;120 color: var(--muted);121 margin-bottom: 6px;122 }123 .row {124 display: flex;125 gap: 8px;126 align-items: center;127 flex-wrap: wrap;128 }129 .controls textarea {130 width: 100%;131 min-height: 80px;132 background: var(--card);133 border: 1px solid var(--border);134 color: var(--fg);135 padding: 8px;136 border-radius: 8px;137 resize: vertical;138 }139 button {140 background: var(--accent);141 color: white;142 border: 0;143 padding: 8px 12px;144 border-radius: 8px;145 cursor: pointer;146 }147 button.secondary {148 background: var(--chip);149 color: var(--fg);150 }151 input[type="number"],152 input[type="text"] {153 background: var(--card);154 border: 1px solid var(--border);155 color: var(--fg);156 padding: 6px 8px;157 border-radius: 6px;158 }159 .chips {160 display: flex;161 gap: 8px;162 flex-wrap: wrap;163 }164 .chip {165 background: var(--chip);166 color: var(--fg);167 border: 1px solid var(--border);168 padding: 4px 8px;169 border-radius: 999px;170 font-size: 12px;171 }172 footer {173 padding: 10px 16px;174 border-top: 1px solid var(--border);175 color: var(--muted);176 font-size: 12px;177 display: flex;178 justify-content: space-between;179 gap: 10px;180 flex-wrap: wrap;181 }182 a {183 color: #93c5fd;184 text-decoration: none;185 }186 a:hover {187 text-decoration: underline;188 }189 .hint {190 font-size: 12px;191 color: var(--muted);192 }193 input[type="file"] {194 display: none;195 }196 .file-btn {197 background: var(--chip);198 }199 .preview {200 display: flex;201 gap: 8px;202 flex-wrap: wrap;203 margin-top: 8px;204 }205 .preview-item {206 position: relative;207 }208 .remove {209 position: absolute;210 top: 4px;211 right: 4px;212 background: #ef4444;213 color: white;214 border: 0;215 border-radius: 6px;216 padding: 2px 6px;217 cursor: pointer;218 font-size: 12px;219 }220 </style>221 </head>222 <body>223 <div class="app">224 <header>225 <div class="title">Qwen3‑VL Chat</div>226 <input227 id="apiBase"228 type="text"229 placeholder="HF Space API Base, e.g. https://killerking93-transformers-inferenceserver-openapi.hf.space"230 />231 <button id="saveBase" class="secondary">Save Base</button>232 <span id="health" class="small">Health: checking…</span>233 </header>234 235 <main>236 <section class="chat" id="chat"></section>237 238 <aside class="aside">239 <div class="card">240 <div class="label">Prompt</div>241 <div class="controls">242 <textarea243 id="prompt"244 placeholder="Ask anything… Supports images and videos."245 ></textarea>246 <div class="row">247 <button id="attach" type="button" class="secondary">248 Attach Image/Video249 </button>250 <input251 id="file"252 type="file"253 accept="image/*,video/*"254 multiple255 style="display: none"256 />257 <input258 id="maxTokens"259 type="number"260 min="1"261 max="8192"262 value="4096"263 title="Max tokens"264 />265 <input266 id="temperature"267 type="number"268 min="0"269 max="2"270 step="0.1"271 value="0.7"272 title="Temperature"273 />274 <button id="send">Send (Stream)</button>275 </div>276 <div id="preview" class="preview"></div>277 <div class="row" style="margin-top: 8px">278 <button id="clearHistory" class="secondary">279 Clear History280 </button>281 <span class="hint"282 >Session <code id="sessionIdLabel"></code> — history saved283 locally</span284 >285 </div>286 </div>287 </div>288 289 <div class="card">290 <div class="label">Hints</div>291 <div class="chips">292 <div class="chip">Images: embedded as base64</div>293 <div class="chip">Videos: base64, frame-sampled by server</div>294 <div class="chip">SSE Streaming</div>295 </div>296 </div>297 </aside>298 </main>299 300 <footer>301 <div>302 Powered by FastAPI + Transformers (Qwen3‑VL). Calls public HF Space303 API (no internal access).304 </div>305 <div>306 <a href="./docs" target="_blank">Swagger</a> ·307 <a href="./openapi.yaml" target="_blank">OpenAPI YAML</a>308 </div>309 </footer>310 </div>311 312 <script>313 // Config and state314 const DEFAULT_SPACE =315 "https://killerking93-transformers-inferenceserver-openapi.hf.space";316 const qs = new URLSearchParams(location.search);317 const apiBaseInput = document.getElementById("apiBase");318 const saveBaseBtn = document.getElementById("saveBase");319 const healthEl = document.getElementById("health");320 const chatEl = document.getElementById("chat");321 const promptEl = document.getElementById("prompt");322 const fileEl = document.getElementById("file");323 const previewEl = document.getElementById("preview");324 const sendBtn = document.getElementById("send");325 const clearBtn = document.getElementById("clearHistory");326 const sessionIdLabel = document.getElementById("sessionIdLabel");327 const maxTokensEl = document.getElementById("maxTokens");328 const temperatureEl = document.getElementById("temperature");329 const attachBtn = document.getElementById("attach");330 331 const store = {332 get apiBase() {333 return localStorage.getItem("apiBase") || DEFAULT_SPACE;334 },335 set apiBase(v) {336 localStorage.setItem("apiBase", v);337 },338 get sessionId() {339 let sid = localStorage.getItem("sessionId");340 if (!sid) {341 sid = "sess-" + Math.random().toString(16).slice(2, 10);342 localStorage.setItem("sessionId", sid);343 }344 return sid;345 },346 get messages() {347 const sid = this.sessionId;348 try {349 return JSON.parse(localStorage.getItem(`chat:${sid}`) || "[]");350 } catch {351 return [];352 }353 },354 set messages(arr) {355 const sid = this.sessionId;356 try {357 const compact = compactMessages(arr || []);358 let serialized = JSON.stringify(compact);359 const LIMIT = 4.5 * 1024 * 1024; // ~4.5MB safety window below localStorage quota360 // Create small image thumbnail (JPEG) for compact storage and chat display361 async function createImageThumbnail(dataUrl, maxW = 320, maxH = 240, quality = 0.7) {362 return new Promise((resolve) => {363 const img = new Image();364 img.onload = () => {365 try {366 const ratio = Math.min(maxW / img.width, maxH / img.height, 1);367 const w = Math.max(1, Math.round(img.width * ratio));368 const h = Math.max(1, Math.round(img.height * ratio));369 const canvas = document.createElement('canvas');370 canvas.width = w; canvas.height = h;371 const ctx = canvas.getContext('2d');372 ctx.drawImage(img, 0, 0, w, h);373 resolve(canvas.toDataURL('image/jpeg', quality));374 } catch {375 resolve(null);376 }377 };378 img.onerror = () => resolve(null);379 img.src = dataUrl;380 });381 }382 383 // Create a poster thumbnail from the first video frame (best-effort)384 async function createVideoThumbnail(dataUrl, maxW = 320, maxH = 240, quality = 0.6) {385 return new Promise((resolve) => {386 let settled = false;387 const settle = (v) => { if (!settled) { settled = true; resolve(v); } };388 389 const video = document.createElement('video');390 video.preload = 'auto';391 video.muted = true;392 video.playsInline = true;393 video.src = dataUrl;394 395 const onReady = () => {396 try {397 const vw = Math.max(1, video.videoWidth || 1);398 const vh = Math.max(1, video.videoHeight || 1);399 const ratio = Math.min(maxW / vw, maxH / vh, 1);400 const w = Math.max(1, Math.round(vw * ratio));401 const h = Math.max(1, Math.round(vh * ratio));402 const canvas = document.createElement('canvas');403 canvas.width = w; canvas.height = h;404 const ctx = canvas.getContext('2d');405 ctx.drawImage(video, 0, 0, w, h);406 settle(canvas.toDataURL('image/jpeg', quality));407 } catch {408 settle(null);409 } finally {410 try { video.pause(); } catch {}411 video.src = '';412 }413 };414 415 video.addEventListener('loadeddata', onReady, { once: true });416 video.addEventListener('error', () => settle(null), { once: true });417 // Fallback timeout in case metadata never fires418 setTimeout(() => settle(null), 3000);419 });420 }421 while (serialized.length > LIMIT && compact.length > 1) {422 compact.shift(); // drop oldest message423 serialized = JSON.stringify(compact);424 }425 localStorage.setItem(`chat:${sid}`, serialized);426 } catch (e) {427 console.warn(428 "Persist messages failed, clearing oldest/history may be truncated:",429 e430 );431 try {432 localStorage.removeItem(`chat:${sid}`);433 } catch {}434 }435 },436 clear() {437 // Remove current chat history and rotate to a brand new session438 const sid = localStorage.getItem("sessionId");439 if (sid) {440 localStorage.removeItem(`chat:${sid}`);441 }442 // Drop session id so next access generates a fresh one443 localStorage.removeItem("sessionId");444 },445 };446 447 apiBaseInput.value = qs.get("api") || store.apiBase;448 sessionIdLabel.textContent = store.sessionId;449 450 saveBaseBtn.onclick = () => {451 const v = apiBaseInput.value.trim();452 if (!/^https?:\/\//i.test(v)) {453 alert("Provide a valid API base (https://...)");454 return;455 }456 store.apiBase = v;457 checkHealth();458 };459 460 async function checkHealth() {461 healthEl.textContent = "Health: checking…";462 try {463 const r = await fetch(new URL("/health", store.apiBase), {464 mode: "cors",465 });466 const j = await r.json();467 healthEl.textContent = `Health: ${j.ok ? "OK" : "ERR"} · ModelReady=${468 j.modelReady ? "yes" : "no"469 } · Model=${j.modelId || "unknown"}`;470 } catch (e) {471 healthEl.textContent = `Health: error (${472 e && e.message ? e.message : "network"473 })`;474 }475 }476 477 // UI helpers478 function renderAssistantText(text) {479 const frag = document.createDocumentFragment();480 if (typeof text !== "string" || !text.length) {481 const pre = document.createElement("pre");482 pre.textContent = text || "";483 frag.appendChild(pre);484 return frag;485 }486 const re = /<think>([\s\S]*?)<\/think>/gi;487 let last = 0;488 let m;489 while ((m = re.exec(text)) !== null) {490 const before = text.slice(last, m.index);491 if (before) {492 const pre = document.createElement("pre");493 pre.textContent = before;494 frag.appendChild(pre);495 }496 const det = document.createElement("details");497 const sum = document.createElement("summary");498 sum.textContent = "Show reasoning";499 det.appendChild(sum);500 const pre2 = document.createElement("pre");501 pre2.textContent = m[1];502 det.appendChild(pre2);503 frag.appendChild(det);504 last = m.index + m[0].length;505 }506 const after = text.slice(last);507 if (after) {508 const pre = document.createElement("pre");509 pre.textContent = after;510 frag.appendChild(pre);511 }512 if (!frag.childNodes.length) {513 const pre = document.createElement("pre");514 pre.textContent = text;515 frag.appendChild(pre);516 }517 return frag;518 }519 520 function render() {521 chatEl.innerHTML = "";522 const messages = store.messages;523 // Render messages grouped by role sequence524 for (const msg of messages) {525 const node = document.createElement("div");526 node.className = `msg ${msg.role}`;527 const role = document.createElement("div");528 role.className = "role";529 role.textContent = msg.role;530 node.appendChild(role);531 532 const content = document.createElement("div");533 content.className = "content";534 if (typeof msg.content === "string") {535 content.appendChild(renderAssistantText(msg.content));536 } else if (Array.isArray(msg.content)) {537 const textParts = msg.content.filter((p) => p.type === "text");538 for (const t of textParts) {539 const pre = document.createElement("pre");540 pre.textContent = t.text || "";541 content.appendChild(pre);542 }543 const media = document.createElement("div");544 media.className = "media";545 for (const p of msg.content) {546 if (p.type === "input_image" || p.type === "image_url") {547 const img = document.createElement("img");548 if (p.b64_json) {549 img.src = p.b64_json.startsWith("data:")550 ? p.b64_json551 : "data:image/*;base64," + p.b64_json;552 } else if (p.image_url && p.image_url.url) {553 img.src = p.image_url.url;554 }555 media.appendChild(img);556 } else if (p.type === "input_video" || p.type === "video_url") {557 const video = document.createElement("video");558 video.controls = true;559 if (p.b64_json) {560 video.src = p.b64_json.startsWith("data:")561 ? p.b64_json562 : "data:video/mp4;base64," + p.b64_json;563 } else if (p.video_url && p.video_url.url) {564 video.src = p.video_url.url;565 }566 media.appendChild(video);567 } else if (p.type === "image_thumb" && p.dataUrl) {568 const img = document.createElement("img");569 img.src = p.dataUrl;570 media.appendChild(img);571 } else if (p.type === "video_thumb" && p.dataUrl) {572 const img = document.createElement("img");573 img.src = p.dataUrl; // poster-like thumbnail574 media.appendChild(img);575 } else if (p.type === "image_ref") {576 const chip = document.createElement("div");577 chip.className = "chip";578 chip.textContent = `Image (${p.name || "ref"})`;579 media.appendChild(chip);580 } else if (p.type === "video_ref") {581 const chip = document.createElement("div");582 chip.className = "chip";583 chip.textContent = `Video (${p.name || "ref"})`;584 media.appendChild(chip);585 }586 }587 if (media.childElementCount) content.appendChild(media);588 }589 node.appendChild(content);590 chatEl.appendChild(node);591 }592 chatEl.scrollTop = chatEl.scrollHeight;593 }594 595 // File handling596 const fileQueue = [];597 function handleFileList(files) {598 const arr = Array.from(files || []);599 return Promise.all(600 arr.map(async (f) => {601 const b64 = await fileToDataURL(f);602 fileQueue.push({ name: f.name, type: f.type, dataUrl: b64 });603 })604 ).then(() => {605 renderPreview();606 });607 }608 // Button triggers native picker609 if (attachBtn) attachBtn.addEventListener("click", () => fileEl.click());610 // Native input change611 fileEl.addEventListener("change", async (e) => {612 await handleFileList(e.target.files);613 e.target.value = "";614 });615 // Drag & drop onto preview area616 previewEl.addEventListener("dragover", (e) => {617 e.preventDefault();618 e.dataTransfer.dropEffect = "copy";619 });620 previewEl.addEventListener("drop", async (e) => {621 e.preventDefault();622 await handleFileList(e.dataTransfer.files);623 });624 // Paste from clipboard (images/videos)625 document.addEventListener("paste", async (e) => {626 if (627 e.clipboardData &&628 e.clipboardData.files &&629 e.clipboardData.files.length630 ) {631 await handleFileList(e.clipboardData.files);632 }633 });634 635 function renderPreview() {636 previewEl.innerHTML = "";637 for (let i = 0; i < fileQueue.length; i++) {638 const f = fileQueue[i];639 const wrap = document.createElement("div");640 wrap.className = "preview-item";641 const btn = document.createElement("button");642 btn.className = "remove";643 btn.textContent = "x";644 btn.onclick = () => {645 fileQueue.splice(i, 1);646 renderPreview();647 };648 wrap.appendChild(btn);649 if (f.type.startsWith("image/")) {650 const img = document.createElement("img");651 img.src = f.dataUrl;652 img.style.maxWidth = "160px";653 img.style.maxHeight = "120px";654 wrap.appendChild(img);655 } else if (f.type.startsWith("video/")) {656 const video = document.createElement("video");657 video.src = f.dataUrl;658 video.controls = true;659 video.style.maxWidth = "160px";660 video.style.maxHeight = "120px";661 wrap.appendChild(video);662 } else {663 const pre = document.createElement("pre");664 pre.textContent = f.name;665 wrap.appendChild(pre);666 }667 previewEl.appendChild(wrap);668 }669 }670 671 function fileToDataURL(file) {672 return new Promise((resolve, reject) => {673 const reader = new FileReader();674 reader.onload = () => resolve(reader.result);675 reader.onerror = reject;676 reader.readAsDataURL(file);677 });678 }679 680 function dataUrlToBase64(d) {681 return d.includes("base64,") ? d.split("base64,")[1] : d;682 }683 684 // Create small image thumbnail data URL (JPEG) for compact storage and UI display685 async function createImageThumbnail(686 dataUrl,687 maxW = 320,688 maxH = 240,689 quality = 0.7690 ) {691 return new Promise((resolve) => {692 const img = new Image();693 img.onload = () => {694 try {695 const ratio = Math.min(maxW / img.width, maxH / img.height, 1);696 const w = Math.max(1, Math.round(img.width * ratio));697 const h = Math.max(1, Math.round(img.height * ratio));698 const canvas = document.createElement("canvas");699 canvas.width = w;700 canvas.height = h;701 const ctx = canvas.getContext("2d");702 ctx.drawImage(img, 0, 0, w, h);703 resolve(canvas.toDataURL("image/jpeg", quality));704 } catch {705 resolve(null);706 }707 };708 img.onerror = () => resolve(null);709 img.src = dataUrl;710 });711 }712 713 // Create a poster thumbnail from the first video frame714 async function createVideoThumbnail(715 dataUrl,716 maxW = 320,717 maxH = 240,718 quality = 0.6719 ) {720 return new Promise((resolve) => {721 let settled = false;722 const settle = (v) => {723 if (!settled) {724 settled = true;725 resolve(v);726 }727 };728 729 const video = document.createElement("video");730 video.preload = "auto";731 video.muted = true;732 video.playsInline = true;733 video.src = dataUrl;734 735 const onReady = () => {736 try {737 const vw = Math.max(1, video.videoWidth || 1);738 const vh = Math.max(1, video.videoHeight || 1);739 const ratio = Math.min(maxW / vw, maxH / vh, 1);740 const w = Math.max(1, Math.round(vw * ratio));741 const h = Math.max(1, Math.round(vh * ratio));742 const canvas = document.createElement("canvas");743 canvas.width = w;744 canvas.height = h;745 const ctx = canvas.getContext("2d");746 ctx.drawImage(video, 0, 0, w, h);747 settle(canvas.toDataURL("image/jpeg", quality));748 } catch {749 settle(null);750 } finally {751 try {752 video.pause();753 } catch {}754 video.src = "";755 }756 };757 758 video.addEventListener("loadeddata", onReady, { once: true });759 video.addEventListener("error", () => settle(null), { once: true });760 setTimeout(() => settle(null), 3000);761 });762 }763 764 // Compact messages for storage: strip large binary fields to avoid localStorage quota overflow.765 function compactMessages(arr) {766 const out = [];767 for (const m of arr || []) {768 const c = Array.isArray(m.content)769 ? m.content.map((p) => {770 if (p && typeof p === "object") {771 // Remove huge base64 payloads from persisted history; keep lightweight placeholders only.772 if (p.type === "input_image" && p.b64_json) {773 return {774 type: "image_ref",775 name: p.name || "image",776 mime: "image/*",777 };778 }779 if (p.type === "input_video" && p.b64_json) {780 return {781 type: "video_ref",782 name: p.name || "video",783 mime: "video/*",784 };785 }786 }787 // Keep text and URL references as-is788 return p;789 })790 : m.content;791 out.push({ role: m.role, content: c });792 }793 return out;794 }795 796 // Transform persisted messages to a request-safe form (text + URL refs only).797 function transformForSend(arr) {798 const out = [];799 for (const m of arr || []) {800 if (801 m &&802 (m.role === "user" || m.role === "assistant" || m.role === "system")803 ) {804 const entry = { role: m.role, content: [] };805 if (typeof m.content === "string") {806 entry.content.push({ type: "text", text: m.content });807 } else if (Array.isArray(m.content)) {808 for (const p of m.content) {809 if (!p || typeof p !== "object") continue;810 if (p.type === "text" && typeof p.text === "string") {811 entry.content.push({ type: "text", text: p.text });812 } else if (813 p.type === "image_url" &&814 p.image_url &&815 p.image_url.url816 ) {817 entry.content.push({818 type: "image_url",819 image_url: { url: p.image_url.url },820 });821 } else if (822 p.type === "video_url" &&823 p.video_url &&824 p.video_url.url825 ) {826 entry.content.push({827 type: "video_url",828 video_url: { url: p.video_url.url },829 });830 }831 // Skip image_ref/video_ref/image_thumb/video_thumb when sending; only the current turn will include base64832 }833 }834 out.push(entry);835 }836 }837 // Limit history window to avoid stale over-conditioning and ghosting838 const SEND_MAX_MESSAGES = 24; // ~12 turns839 if (out.length > SEND_MAX_MESSAGES) {840 return out.slice(out.length - SEND_MAX_MESSAGES);841 }842 return out;843 }844 845 // Holds the most recent outgoing user message with real base64 attachments (not persisted)846 let lastOutgoingUser = null;847 848 // Build OpenAI-style messages array from stored history (already in that shape)849 function getMessages() {850 return store.messages;851 }852 853 async function pushUserMessageFromUI() {854 // Build two versions:855 // 1) storedMsg: safe for localStorage (no huge base64) but visually rich using thumbnails856 // 2) outgoingMsg: includes real base64 attachments for the current turn857 const storedMsg = { role: "user", content: [] };858 const outgoingMsg = { role: "user", content: [] };859 860 const text = (promptEl.value || "").trim();861 if (text) {862 storedMsg.content.push({ type: "text", text });863 outgoingMsg.content.push({ type: "text", text });864 }865 866 for (const f of fileQueue) {867 if (f.type.startsWith("image/")) {868 outgoingMsg.content.push({869 type: "input_image",870 b64_json: dataUrlToBase64(f.dataUrl),871 name: f.name || "image",872 });873 const thumb = await createImageThumbnail(f.dataUrl);874 if (thumb) {875 storedMsg.content.push({876 type: "image_thumb",877 dataUrl: thumb,878 name: f.name || "image",879 });880 } else {881 storedMsg.content.push({882 type: "image_ref",883 name: f.name || "image",884 mime: f.type || "image/*",885 });886 }887 } else if (f.type.startsWith("video/")) {888 outgoingMsg.content.push({889 type: "input_video",890 b64_json: dataUrlToBase64(f.dataUrl),891 name: f.name || "video",892 });893 const vthumb = await createVideoThumbnail(f.dataUrl);894 if (vthumb) {895 storedMsg.content.push({896 type: "video_thumb",897 dataUrl: vthumb,898 name: f.name || "video",899 });900 } else {901 storedMsg.content.push({902 type: "video_ref",903 name: f.name || "video",904 mime: f.type || "video/*",905 });906 }907 }908 }909 910 const messages = getMessages();911 messages.push(storedMsg);912 store.messages = messages;913 914 // Stash the real payload for the immediate request915 lastOutgoingUser = outgoingMsg;916 917 // clear UI queue918 fileQueue.splice(0, fileQueue.length);919 previewEl.innerHTML = "";920 promptEl.value = "";921 render();922 }923 924 async function sendStream() {925 const apiBase = apiBaseInput.value.trim() || DEFAULT_SPACE;926 927 // Build request messages: text history + replace last user turn with real attachments if available928 let msgs = transformForSend(getMessages());929 if (lastOutgoingUser) {930 if (msgs.length && msgs[msgs.length - 1].role === "user") {931 msgs[msgs.length - 1] = lastOutgoingUser;932 } else {933 msgs.push(lastOutgoingUser);934 }935 }936 // Clear the stash to avoid accidental reuse937 lastOutgoingUser = null;938 939 const body = {940 messages: msgs,941 stream: true,942 session_id: store.sessionId,943 max_tokens: Math.max(1, parseInt(maxTokensEl.value || "4096", 10)),944 temperature: parseFloat(temperatureEl.value || "0.7"),945 };946 947 const url = new URL("/v1/chat/completions", apiBase);948 const resp = await fetch(url, {949 method: "POST",950 headers: { "Content-Type": "application/json" },951 body: JSON.stringify(body),952 mode: "cors",953 });954 if (!resp.ok || !resp.body) {955 const text = await resp.text().catch(() => "");956 throw new Error(`HTTP ${resp.status}: ${text}`);957 }958 // Prepare assistant message to accumulate streamed content959 const messages = getMessages();960 const asst = { role: "assistant", content: "" };961 messages.push(asst);962 store.messages = messages;963 render();964 965 const reader = resp.body.getReader();966 const decoder = new TextDecoder();967 let buffer = "";968 while (true) {969 const { done, value } = await reader.read();970 if (done) break;971 buffer += decoder.decode(value, { stream: true });972 // split SSE blocks973 let idx;974 while ((idx = buffer.indexOf("\n\n")) !== -1) {975 const block = buffer.slice(0, idx);976 buffer = buffer.slice(idx + 2);977 const lines = block.split("\n");978 for (const line of lines) {979 if (line.startsWith("data:")) {980 const data = line.slice(5).trim();981 if (data === "[DONE]") continue;982 try {983 const j = JSON.parse(data);984 const delta =985 (((j || {}).choices || [])[0] || {}).delta || {};986 if (987 typeof delta.content === "string" &&988 delta.content.length989 ) {990 // append token991 const msgs = getMessages();992 const last = msgs[msgs.length - 1];993 if (last && last.role === "assistant") {994 last.content = (last.content || "") + delta.content;995 store.messages = msgs;996 render();997 }998 }999 } catch {}1000 }1001 }1002 }1003 }1004 }1005 1006 sendBtn.onclick = async () => {1007 try {1008 await pushUserMessageFromUI();1009 await sendStream();1010 } catch (e) {1011 alert("Send failed: " + (e && e.message ? e.message : e));1012 }1013 };1014 1015 clearBtn.onclick = () => {1016 if (confirm("Clear chat history and start a new session?")) {1017 store.clear();1018 // Reset UI state1019 fileQueue.splice(0, fileQueue.length);1020 previewEl.innerHTML = "";1021 promptEl.value = "";1022 // Force a new session id (getter will create it) and update label1023 sessionIdLabel.textContent = store.sessionId;1024 render();1025 }1026 };1027 1028 (async function init() {1029 render();1030 await checkHealth();1031 // Auto-save default base on first load if empty1032 if (!localStorage.getItem("apiBase"))1033 localStorage.setItem(1034 "apiBase",1035 apiBaseInput.value.trim() || DEFAULT_SPACE1036 );1037 })();1038 </script>1039 </body>1040</html>1041 