ErenYanic's picture
Shed few-shot and retry on provider failure; widen auto fallback
3d8a28d verified
Raw
History Blame Contribute Delete
12.9 kB
/**
* UI wiring: model/mode selection, token handling, and the live execution trace.
*
* The trace is rendered in the exact `[Turn N] -> tool(args) <- result` notation
* the assignment brief specifies, and it is written as the run happens rather
* than reconstructed afterwards.
*/
import { runAgent } from "./agent.js";
/* ------------------------------------------------------------------ *
* model catalogue
*
* `nativeTools` reflects what is actually in each model's chat_template,
* verified against tokenizer_config.json — not guesswork.
* ------------------------------------------------------------------ */
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?",
];
/* ------------------------------------------------------------------ *
* dom
* ------------------------------------------------------------------ */
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;
/* ------------------------------------------------------------------ *
* setup
* ------------------------------------------------------------------ */
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);
}
}
/* ------------------------------------------------------------------ *
* token: OAuth when hosted on a Space, pasted token otherwise
* ------------------------------------------------------------------ */
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())
);
// window.huggingface is injected only inside a Space. No Space, no OAuth.
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 {
// CDN blocked or OAuth misconfigured: the pasted-token path still works.
els.signin.hidden = true;
}
}
/* ------------------------------------------------------------------ *
* rendering
* ------------------------------------------------------------------ */
const escapeHTML = (s) =>
String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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;
}
/** Live trace panel, written in the brief's `[Turn N] -> tool <- result` form. */
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);
}
/* ------------------------------------------------------------------ *
* send
* ------------------------------------------------------------------ */
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."
);