CoolFace
Apppublic

KillerKing93/Transformers-InferenceServer-OpenAPI

sourceHugging Faceupdated 10mo agoView on Hugging Face
0likes
index.html1041 linesDownload Raw Back to web
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