| |
| |
| |
| |
| |
| |
| |
|
|
| import { runAgent } from "./agent.js"; |
|
|
| |
| |
| |
| |
| |
| |
|
|
| const MODELS = [ |
| { |
| id: "Qwen/Qwen3.5-2B", |
| provider: "featherless-ai", |
| label: "Qwen3.5-2B", |
| nativeTools: true, |
| note: "2B. Has native tool support, but is small enough to fail the prompted protocol sometimes — the honest demo case.", |
| }, |
| { |
| id: "Qwen/Qwen3.5-4B", |
| provider: "featherless-ai", |
| label: "Qwen3.5-4B", |
| nativeTools: true, |
| note: "4B. Verified working end-to-end in prompted mode.", |
| }, |
| { |
| id: "google/gemma-2-2b-it", |
| provider: "featherless-ai", |
| label: "Gemma-2-2B-it", |
| nativeTools: false, |
| note: "No native tool support at all, and its template rejects a system role. Prompted mode is the only way it can call a tool.", |
| }, |
| { |
| id: "microsoft/Phi-3-mini-4k-instruct", |
| provider: "featherless-ai", |
| label: "Phi-3-mini (3.8B)", |
| nativeTools: false, |
| note: "No native tool support. Prompted mode only.", |
| }, |
| { |
| id: "mistralai/Mistral-7B-Instruct-v0.2", |
| provider: "featherless-ai", |
| label: "Mistral-7B-Instruct-v0.2", |
| nativeTools: false, |
| note: "No native tool support. Prompted mode only.", |
| }, |
| { |
| id: "Qwen/Qwen2.5-3B-Instruct", |
| provider: "featherless-ai", |
| label: "Qwen2.5-3B-Instruct", |
| nativeTools: true, |
| note: "Native tool support — useful as the control group.", |
| }, |
| ]; |
|
|
| const EXAMPLES = [ |
| "What is Bitcoin trading at right now?", |
| "Is Ethereum pricier than Solana, and what is Ethereum worth in Turkish lira?", |
| "How has Cardano done over the last 30 days?", |
| "What is Dogecoin, and what is its market cap in euros?", |
| ]; |
|
|
| |
| |
| |
|
|
| const $ = (id) => document.getElementById(id); |
| const els = { |
| model: $("model"), |
| modelHint: $("model-hint"), |
| modeGroup: $("mode-group"), |
| modeHint: $("mode-hint"), |
| token: $("token"), |
| tokenHint: $("token-hint"), |
| signin: $("signin"), |
| chat: $("chat"), |
| examples: $("examples"), |
| q: $("q"), |
| send: $("send"), |
| banner: $("banner-slot"), |
| }; |
|
|
| let mode = "auto"; |
| let history = []; |
| let busy = false; |
|
|
| |
| |
| |
|
|
| function initModels() { |
| const groups = [ |
| { name: "No native tool support — prompted mode only", want: false }, |
| { name: "Native tool support — control group", want: true }, |
| ]; |
| for (const g of groups) { |
| const og = document.createElement("optgroup"); |
| og.label = g.name; |
| for (const m of MODELS.filter((m) => m.nativeTools === g.want)) { |
| const o = document.createElement("option"); |
| o.value = m.id; |
| o.textContent = m.label; |
| og.appendChild(o); |
| } |
| els.model.appendChild(og); |
| } |
| els.model.value = "Qwen/Qwen3.5-2B"; |
| syncModelHint(); |
| els.model.addEventListener("change", syncModelHint); |
| } |
|
|
| const currentModel = () => MODELS.find((m) => m.id === els.model.value) || MODELS[0]; |
|
|
| function syncModelHint() { |
| const m = currentModel(); |
| els.modelHint.textContent = m.note; |
| syncModeHint(); |
| } |
|
|
| function syncModeHint() { |
| const m = currentModel(); |
| if (mode === "native" && !m.nativeTools) { |
| els.modeHint.innerHTML = |
| "<strong>This model has no native tool support</strong> — expect it to ignore the tools and answer from memory. That is the point of the comparison."; |
| } else if (mode === "auto") { |
| els.modeHint.textContent = |
| "Auto tries native first, then falls back to the prompted protocol."; |
| } else if (mode === "prompted") { |
| els.modeHint.textContent = |
| "Schemas are injected into the system prompt; calls are parsed out of plain text."; |
| } else { |
| els.modeHint.textContent = "Uses the provider's built-in tool-calling API."; |
| } |
| } |
|
|
| function initModes() { |
| els.modeGroup.addEventListener("click", (e) => { |
| const btn = e.target.closest("button[data-mode]"); |
| if (!btn) return; |
| mode = btn.dataset.mode; |
| for (const b of els.modeGroup.querySelectorAll("button")) { |
| b.setAttribute("aria-pressed", String(b === btn)); |
| } |
| syncModeHint(); |
| }); |
| } |
|
|
| function initExamples() { |
| for (const ex of EXAMPLES) { |
| const b = document.createElement("button"); |
| b.type = "button"; |
| b.textContent = ex; |
| b.addEventListener("click", () => { |
| els.q.value = ex; |
| els.q.focus(); |
| }); |
| els.examples.appendChild(b); |
| } |
| } |
|
|
| |
| |
| |
|
|
| const TOKEN_KEY = "ftas_token"; |
|
|
| async function initAuth() { |
| const saved = sessionStorage.getItem(TOKEN_KEY); |
| if (saved) els.token.value = saved; |
| els.token.addEventListener("change", () => |
| sessionStorage.setItem(TOKEN_KEY, els.token.value.trim()) |
| ); |
|
|
| |
| if (!window.huggingface?.variables?.OAUTH_CLIENT_ID) return; |
|
|
| try { |
| const { oauthLoginUrl, oauthHandleRedirectIfPresent } = await import( |
| "https://esm.sh/@huggingface/hub@1" |
| ); |
|
|
| const result = await oauthHandleRedirectIfPresent(); |
| if (result?.accessToken) { |
| els.token.value = result.accessToken; |
| sessionStorage.setItem(TOKEN_KEY, result.accessToken); |
| els.tokenHint.innerHTML = `Signed in as <strong>${escapeHTML( |
| result.userInfo?.name || "your account" |
| )}</strong>. Inference uses your own quota.`; |
| return; |
| } |
|
|
| els.signin.hidden = false; |
| els.signin.addEventListener("click", async () => { |
| window.location.href = await oauthLoginUrl({ scopes: "openid profile inference-api" }); |
| }); |
| } catch { |
| |
| els.signin.hidden = true; |
| } |
| } |
|
|
| |
| |
| |
|
|
| const escapeHTML = (s) => |
| String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); |
|
|
| function addMessage(who, text) { |
| const wrap = document.createElement("div"); |
| wrap.className = `msg ${who}`; |
| wrap.innerHTML = `<div class="who">${who === "user" ? "You" : "AI"}</div><div class="body"></div>`; |
| const body = wrap.querySelector(".body"); |
| if (text) body.appendChild(paragraphs(text)); |
| els.chat.appendChild(wrap); |
| wrap.scrollIntoView({ behavior: "smooth", block: "nearest" }); |
| return body; |
| } |
|
|
| function paragraphs(text) { |
| const frag = document.createDocumentFragment(); |
| for (const chunk of String(text).split(/\n{2,}/)) { |
| if (!chunk.trim()) continue; |
| const p = document.createElement("p"); |
| p.textContent = chunk.trim(); |
| frag.appendChild(p); |
| } |
| return frag; |
| } |
|
|
| |
| function makeTrace(body) { |
| const details = document.createElement("details"); |
| details.className = "trace"; |
| details.open = true; |
| details.innerHTML = `<summary><span class="spinner"></span>Execution trace</summary><pre></pre>`; |
| const pre = details.querySelector("pre"); |
| const summary = details.querySelector("summary"); |
| body.appendChild(details); |
|
|
| let lastTurn = 0; |
| const line = (cls, text) => { |
| const span = document.createElement("span"); |
| if (cls) span.className = cls; |
| span.textContent = text + "\n"; |
| pre.appendChild(span); |
| pre.scrollTop = pre.scrollHeight; |
| }; |
|
|
| return { |
| event(e) { |
| switch (e.type) { |
| case "turn_start": |
| if (e.turn !== lastTurn) { |
| lastTurn = e.turn; |
| line("t-turn", `${e.turn > 1 ? "\n" : ""}[Turn ${e.turn}]`); |
| } |
| break; |
| case "fallback": |
| line("t-err", `!! ${e.reason}`); |
| line("t-note", ` ${e.detail}`); |
| break; |
| case "degrade": |
| line("t-err", `!! ${e.reason}`); |
| line("t-note", ` ${e.detail}`); |
| break; |
| case "nudge": |
| line("t-err", `!! no tool call emitted — model replied: "${truncate(collapse(e.text), 120)}"`); |
| line("t-note", ` ${e.detail}`); |
| break; |
| case "reasoning": |
| line("t-note", ` reasoning: ${truncate(collapse(e.text), 400)}`); |
| break; |
| case "thought": |
| line("t-note", ` model says: ${truncate(collapse(e.text), 220)}`); |
| break; |
| case "tool_call": |
| line("t-call", `-> ${e.name}(${fmtArgs(e.args)})`); |
| break; |
| case "tool_result": |
| line( |
| e.failed ? "t-err" : "t-res", |
| `<- ${JSON.stringify(e.result)} [${e.ms} ms]` |
| ); |
| break; |
| case "usage": |
| line("t-note", ` tokens: ${e.usage.prompt_tokens} in / ${e.usage.completion_tokens} out`); |
| break; |
| case "final": |
| line("t-turn", `\n[Turn ${e.turn}] Final response`); |
| break; |
| } |
| }, |
| done(label) { |
| summary.innerHTML = `Execution trace — ${escapeHTML(label)}`; |
| details.open = false; |
| }, |
| fail() { |
| summary.innerHTML = `Execution trace — failed`; |
| }, |
| }; |
| } |
|
|
| const collapse = (s) => String(s).replace(/\s+/g, " ").trim(); |
| const truncate = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s); |
|
|
| function fmtArgs(args) { |
| return Object.entries(args || {}) |
| .map(([k, v]) => `${k}=${typeof v === "string" ? JSON.stringify(v) : v}`) |
| .join(", "); |
| } |
|
|
| function showBanner(msg) { |
| els.banner.innerHTML = ""; |
| const div = document.createElement("div"); |
| div.className = "banner"; |
| div.innerHTML = msg; |
| els.banner.appendChild(div); |
| } |
|
|
| |
| |
| |
|
|
| async function send() { |
| if (busy) return; |
| const q = els.q.value.trim(); |
| if (!q) return; |
|
|
| const token = els.token.value.trim(); |
| if (!token) { |
| showBanner( |
| 'A Hugging Face token is required. Paste one from <a href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener">huggingface.co/settings/tokens</a>, or sign in above.' |
| ); |
| els.token.focus(); |
| return; |
| } |
| els.banner.innerHTML = ""; |
|
|
| busy = true; |
| els.send.disabled = true; |
| els.q.value = ""; |
| addMessage("user", q); |
|
|
| const body = addMessage("bot", ""); |
| const trace = makeTrace(body); |
| const m = currentModel(); |
|
|
| try { |
| const out = await runAgent({ |
| userMessage: q, |
| history, |
| model: m.id, |
| provider: m.provider, |
| token, |
| mode, |
| onEvent: (e) => trace.event(e), |
| }); |
|
|
| const badge = document.createElement("div"); |
| badge.innerHTML = |
| `<span class="badge ${out.mode}">${out.mode} mode</span>` + |
| (out.fellBack ? `<span class="badge">fell back from native</span>` : "") + |
| `<span class="badge">${out.turns} turn${out.turns === 1 ? "" : "s"}</span>`; |
| body.appendChild(badge); |
|
|
| const answer = out.answer?.trim(); |
| body.appendChild( |
| paragraphs( |
| answer || |
| "The model returned no usable answer. Small models sometimes stall — try again, or switch mode." |
| ) |
| ); |
|
|
| trace.done(`${out.mode} mode, ${out.turns} turn${out.turns === 1 ? "" : "s"}`); |
|
|
| history.push({ role: "user", content: q }); |
| if (answer) history.push({ role: "assistant", content: answer }); |
| history = history.slice(-8); |
| } catch (e) { |
| trace.fail(); |
| const div = document.createElement("div"); |
| div.className = "banner"; |
| div.textContent = e.message || String(e); |
| body.appendChild(div); |
| if (e.kind === "credits" || e.kind === "auth") { |
| showBanner(escapeHTML(e.message)); |
| } |
| } finally { |
| busy = false; |
| els.send.disabled = false; |
| els.q.focus(); |
| } |
| } |
|
|
| els.send.addEventListener("click", send); |
| els.q.addEventListener("keydown", (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| send(); |
| } |
| }); |
|
|
| initModels(); |
| initModes(); |
| initExamples(); |
| initAuth(); |
|
|
| addMessage( |
| "bot", |
| "Ask me about crypto prices, trends, or currency conversions. Every tool call and result is shown in the execution trace, including the model's own reasoning." |
| ); |
|
|