tinybard / static /main.js
Hermes Bot
feat: add support for custom OpenAI-compatible endpoints in config UI
5941741 unverified
Raw
History Blame Contribute Delete
10.3 kB
/**
* TinyBard — Frontend Client
* Connects to the gr.Server backend via @gradio/client.
* All game state is managed client-side and passed to the API.
*/
const GRADIO_CLIENT_URL = window.location.origin;
// ---------------------------------------------------------------------------
// Game State
// ---------------------------------------------------------------------------
let gameState = {
genre: "",
step: 0,
health: 100,
history: [],
gameActive: false
};
// ---------------------------------------------------------------------------
// DOM refs
// ---------------------------------------------------------------------------
const output = document.getElementById("output");
const choicesEl = document.getElementById("choices");
const genreSelector = document.getElementById("genre-selector");
const inputLine = document.getElementById("input-line");
const cmdInput = document.getElementById("cmd-input");
const healthVal = document.getElementById("health-val");
const modelStatus = document.getElementById("model-status");
const boot = document.getElementById("boot");
// ---------------------------------------------------------------------------
// API client — uses FastAPI clean-JSON endpoints
// ---------------------------------------------------------------------------
async function checkModelStatus() {
try {
const resp = await fetch(`${GRADIO_CLIENT_URL}/api/model_status`);
if (!resp.ok) return;
const s = await resp.json();
const model = s.model || "inference";
const cd = s.cooldown || { active: false, remaining_seconds: 0, window_seconds: 0 };
if (cd.active) {
modelStatus.textContent = `☘ ${model} / COOLDOWN ${cd.remaining_seconds.toFixed(1)}s`;
modelStatus.style.color = "var(--asp-ember)";
} else if (model) {
modelStatus.textContent = `☘ ${model} / READY`;
modelStatus.style.color = "var(--asp-sun)";
} else {
modelStatus.textContent = "☘ NO MODEL / FALLBACK";
modelStatus.style.color = "var(--asp-frost)";
}
} catch {
modelStatus.textContent = "☘ MODEL: ?";
}
}
// Poll model status every 2s so cooldown countdown updates
setInterval(checkModelStatus, 2000);
async function apiCall(endpoint, payload) {
// Use the FastAPI clean-JSON endpoints (returns a dict directly).
// /api/game/start -> start_game
// /api/game/choice -> make_choice
const path = endpoint === "/start_game"
? "/api/game/start"
: "/api/game/choice";
const resp = await fetch(`${GRADIO_CLIENT_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
return await resp.json();
}
// ---------------------------------------------------------------------------
// UI Helpers
// ---------------------------------------------------------------------------
function scrollToBottom() {
output.scrollTop = output.scrollHeight;
}
function appendOutput(html, className = "") {
const el = document.createElement("div");
el.className = className;
el.innerHTML = html;
output.appendChild(el);
scrollToBottom();
}
function clearBoot() {
if (boot) boot.remove();
}
function updateHealth(hp) {
gameState.health = hp;
healthVal.textContent = hp;
if (hp <= 25) healthVal.style.color = "#ff0040";
else if (hp <= 50) healthVal.style.color = "#ffb000";
else healthVal.style.color = "#00ff41";
}
function showChoices(choices) {
choicesEl.innerHTML = "";
choicesEl.style.display = "flex";
choices.forEach((choice, i) => {
const btn = document.createElement("button");
btn.className = "choice-btn";
btn.textContent = choice;
btn.addEventListener("click", () => handleChoice(choice));
choicesEl.appendChild(btn);
});
}
function hideChoices() {
choicesEl.style.display = "none";
choicesEl.innerHTML = "";
}
function showInput() {
inputLine.style.display = "flex";
cmdInput.focus();
}
function hideInput() {
inputLine.style.display = "none";
}
// ---------------------------------------------------------------------------
// Game Logic
// ---------------------------------------------------------------------------
async function startGame(genre) {
gameState = { genre, step: 0, health: 100, history: [], gameActive: true };
genreSelector.style.display = "none";
clearBoot();
appendOutput(`<span class="narrator-prefix">> STARTING ${genre.toUpperCase()} ADVENTURE...</span>`, "line amber");
updateHealth(100);
try {
const data = await apiCall("/start_game", { genre });
gameState.step = data.step || 1;
gameState.history = data.history || [];
const storyEl = document.createElement("div");
storyEl.className = "story-text";
storyEl.textContent = data.story;
output.appendChild(storyEl);
if (data.game_over) {
endGame(data);
} else {
showChoices(data.choices);
}
} catch (e) {
appendOutput(`<span class="error">ERROR: ${e.message}</span>`, "line error");
console.error(e);
}
scrollToBottom();
}
async function handleChoice(choice) {
if (!gameState.gameActive) return;
hideChoices();
appendOutput(`<span class="player-action">> You chose: ${choice}</span>`, "player-action");
try {
const data = await apiCall("/make_choice", {
choice,
genre: gameState.genre,
step: gameState.step,
health: gameState.health,
history_json: JSON.stringify(gameState.history)
});
gameState.step = data.step || gameState.step + 1;
gameState.history = data.history || gameState.history;
updateHealth(data.health ?? gameState.health);
const storyEl = document.createElement("div");
storyEl.className = "story-text";
storyEl.textContent = data.story;
output.appendChild(storyEl);
if (data.game_over) {
endGame(data);
} else {
showChoices(data.choices);
}
} catch (e) {
appendOutput(`<span class="error">ERROR: ${e.message}</span>`, "line error");
console.error(e);
}
scrollToBottom();
}
function endGame(data) {
gameState.gameActive = false;
hideChoices();
const isWin = data.health > 0;
const className = isWin ? "game-over-win" : "game-over-lose";
const label = isWin ? "★ VICTORY ★" : "☠ GAME OVER ☠";
appendOutput(`<div class="${className}">${label}<br><small>Final Health: ${data.health}</small></div>`, "");
// New game button
const btn = document.createElement("button");
btn.className = "new-game-btn";
btn.textContent = "[ NEW ADVENTURE ]";
btn.addEventListener("click", resetGame);
choicesEl.style.display = "flex";
choicesEl.appendChild(btn);
}
function resetGame() {
output.innerHTML = "";
hideChoices();
gameState = { genre: "", step: 0, health: 100, history: [], gameActive: false };
healthVal.textContent = "100";
healthVal.style.color = "#00ff41";
genreSelector.style.display = "flex";
}
// ---------------------------------------------------------------------------
// Event Listeners
// ---------------------------------------------------------------------------
document.querySelectorAll(".genre-option").forEach(el => {
el.addEventListener("click", () => {
const genre = el.dataset.genre;
startGame(genre);
});
});
cmdInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && cmdInput.value.trim()) {
handleChoice(cmdInput.value.trim());
cmdInput.value = "";
}
});
// ---------------------------------------------------------------------------
// User Config Modal
// ---------------------------------------------------------------------------
const configBtn = document.getElementById('config-btn');
const configModal = document.getElementById('tb-config-modal');
const configClose = document.getElementById('tb-config-close');
const configSave = document.getElementById('tb-config-save');
const modelInput = document.getElementById('tb-model-input');
const tokenInput = document.getElementById('tb-token-input');
const endpointInput = document.getElementById('tb-endpoint-input');
const configStatus = document.getElementById('tb-config-status');
if (configBtn && configModal) {
configBtn.addEventListener('click', async () => {
const cfg = await fetch('/api/config').then(r => r.json());
modelInput.value = cfg.model || '';
tokenInput.value = '';
if (endpointInput) {
endpointInput.value = cfg.custom_endpoint || '';
}
configStatus.textContent = '';
configModal.style.display = 'flex';
});
configClose.addEventListener('click', () => {
configModal.style.display = 'none';
});
configModal.addEventListener('click', (e) => {
if (e.target === configModal) configModal.style.display = 'none';
});
configSave.addEventListener('click', async () => {
const body = {};
// We always pass these fields to let user clear them (by passing empty strings or letting backend handle them)
body.model = modelInput.value.trim() || "";
body.hf_token = tokenInput.value.trim() || "";
if (endpointInput) {
body.custom_endpoint = endpointInput.value.trim() || "";
}
const resp = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
configStatus.textContent = data.status === 'ok' ? '✓ Saved' : '✗ Failed';
configStatus.style.color = data.status === 'ok' ? 'var(--asp-sun)' : 'var(--asp-ember)';
setTimeout(() => { configModal.style.display = 'none'; }, 800);
});
}
// Boot
// ---------------------------------------------------------------------------
(async () => {
await checkModelStatus();
})();