Skip to content

Instantly share code, notes, and snippets.

@HenkPoley
Last active January 12, 2026 17:32
Show Gist options
  • Select an option

  • Save HenkPoley/74e6acd6f7f7f32806347be34d6e5622 to your computer and use it in GitHub Desktop.

Select an option

Save HenkPoley/74e6acd6f7f7f32806347be34d6e5622 to your computer and use it in GitHub Desktop.
Just plug in an OpenRouter API key. It stores it in your browser.
const DEFAULT_MODEL = "xiaomi/mimo-v2-flash:free";
const STORAGE_KEY = "openrouter_api_key";
const MODEL_STORAGE_KEY = "openrouter_model";
const MULTI_MODEL_STORAGE_KEY = "openrouter_multi_model";
const MODEL_STATS_STORAGE_KEY = "openrouter_model_stats";
const API_URL = "https://openrouter.ai/api/v1/chat/completions";
const apiKeyInput = document.getElementById("apiKey");
const saveKeyButton = document.getElementById("saveKey");
const modelSelect = document.getElementById("modelSelect");
const modelSearch = document.getElementById("modelSearch");
const modelDisplay = document.getElementById("modelDisplay");
const modelError = document.getElementById("modelError");
const multiModelToggle = document.getElementById("multiModel");
const messagesEl = document.getElementById("messages");
const composer = document.getElementById("composer");
const promptEl = document.getElementById("prompt");
const clearButton = document.getElementById("clear");
const state = {
messages: [],
isSending: false,
model: DEFAULT_MODEL,
modelOptions: [],
modelOptionsSorted: [],
multiModelEnabled: false,
modelStats: {},
};
const resizeTextarea = () => {
promptEl.style.height = "auto";
promptEl.style.height = `${promptEl.scrollHeight}px`;
};
const addMessage = (role, content) => {
state.messages.push({ role, content });
};
const copyRawText = async (text, button) => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
if (button) {
const original = button.textContent;
button.textContent = "Copied";
setTimeout(() => {
button.textContent = original;
}, 1200);
}
} catch (error) {
if (button) {
const original = button.textContent;
button.textContent = "Failed";
setTimeout(() => {
button.textContent = original;
}, 1200);
}
}
};
const createMarkdownSection = (content) => {
const section = document.createElement("div");
section.className = "markdown-section";
section.dataset.raw = content;
const actions = document.createElement("div");
actions.className = "markdown-actions";
const copyButton = document.createElement("button");
copyButton.type = "button";
copyButton.className = "ghost copy-button";
copyButton.textContent = "Copy";
copyButton.addEventListener("click", () => {
copyRawText(section.dataset.raw || "", copyButton);
});
actions.appendChild(copyButton);
const body = document.createElement("div");
body.className = "markdown-body";
body.innerHTML = renderMarkdown(content);
section.append(actions, body);
return { section, body };
};
const updateMarkdownSection = (section, content) => {
if (!section) return;
section.dataset.raw = content;
const body = section.querySelector(".markdown-body");
if (body) {
body.innerHTML = renderMarkdown(content);
}
};
const renderMessage = (role, content) => {
const wrapper = document.createElement("article");
wrapper.className = `message ${role}`;
const roleEl = document.createElement("div");
roleEl.className = "role";
roleEl.textContent = role === "user" ? "You" : "Assistant";
const contentEl = document.createElement("div");
contentEl.className = "content";
const markdown = createMarkdownSection(content);
contentEl.appendChild(markdown.section);
wrapper.append(roleEl, contentEl);
messagesEl.appendChild(wrapper);
messagesEl.scrollTop = messagesEl.scrollHeight;
return wrapper;
};
const createHelperPanel = (totalHelpers) => {
const panel = document.createElement("div");
panel.className = "helper-panel";
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "ghost helper-toggle";
const list = document.createElement("div");
list.className = "helper-list";
let received = 0;
const entries = [];
const hasVisible = () => entries.some(({ body }) => !body.hidden);
const updateToggle = () => {
const verb = hasVisible() ? "Hide all" : "Show all";
toggle.textContent = `${verb} helper answers (${received}/${totalHelpers})`;
};
const setAllVisible = (nextVisible) => {
entries.forEach(({ body, toggleButton }) => {
body.hidden = !nextVisible;
toggleButton.textContent = body.hidden ? "Show" : "Hide";
});
updateToggle();
};
const updateAllVisibility = () => {
updateToggle();
};
toggle.addEventListener("click", () => {
setAllVisible(!hasVisible());
});
updateToggle();
panel.append(toggle, list);
return {
panel,
list,
registerEntry: (body, toggleButton) => {
entries.push({ body, toggleButton });
updateAllVisibility();
},
incrementReceived: () => {
received += 1;
updateToggle();
},
updateToggle,
updateAllVisibility,
setAllVisible,
};
};
const addHelperEntry = (helperPanel, modelId, status, content) => {
const entry = document.createElement("div");
entry.className = "helper-entry";
const meta = document.createElement("div");
meta.className = "helper-meta";
const metaInfo = document.createElement("div");
metaInfo.className = "helper-meta-info";
const label = document.createElement("span");
label.textContent = modelId;
const statusEl = document.createElement("span");
statusEl.className = `helper-status ${status === "ok" ? "ok" : "fail"}`;
statusEl.textContent = status === "ok" ? "ok" : "failed";
metaInfo.append(label, statusEl);
const entryToggle = document.createElement("button");
entryToggle.type = "button";
entryToggle.className = "ghost helper-entry-toggle";
entryToggle.textContent = "Show";
const body = document.createElement("div");
body.className = "helper-body";
const markdown = createMarkdownSection(content);
body.appendChild(markdown.section);
body.hidden = true;
entryToggle.addEventListener("click", () => {
body.hidden = !body.hidden;
entryToggle.textContent = body.hidden ? "Show" : "Hide";
helperPanel.updateAllVisibility();
});
meta.append(metaInfo, entryToggle);
entry.append(meta, body);
helperPanel.list.appendChild(entry);
helperPanel.registerEntry(body, entryToggle);
};
const renderMarkdown = (input) => {
const escaped = input
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const codeBlocks = [];
const withCodeBlocks = escaped.replace(/```([\s\S]*?)```/g, (_, code) => {
const index = codeBlocks.length;
codeBlocks.push(code);
return `__CODE_BLOCK_${index}__`;
});
const lines = withCodeBlocks.split(/\n/);
let formatted = "";
let inList = false;
const closeList = () => {
if (inList) {
formatted += "</ul>";
inList = false;
}
};
lines.forEach((line) => {
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
const hrMatch = line.match(/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/);
const listMatch = line.match(/^\s*[-*+]\s+(.*)$/);
if (headingMatch) {
closeList();
const level = headingMatch[1].length;
formatted += `<h${level}>${headingMatch[2]}</h${level}>`;
return;
}
if (hrMatch) {
closeList();
formatted += "<hr />";
return;
}
if (listMatch) {
if (!inList) {
formatted += "<ul>";
inList = true;
}
formatted += `<li>${listMatch[1]}</li>`;
return;
}
closeList();
if (line.trim().length === 0) {
formatted += "<p></p>";
} else {
formatted += `<p>${line}</p>`;
}
});
closeList();
formatted = formatted
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
formatted = formatted.replace(/__CODE_BLOCK_(\d+)__/g, (_, index) => {
const code = codeBlocks[Number(index)] || "";
return `<pre><code>${code.replace(/\n/g, "<br />")}</code></pre>`;
});
return formatted;
};
const updateMessageContent = (messageEl, content) => {
if (!messageEl) return;
const section = messageEl.querySelector(".markdown-section");
updateMarkdownSection(section, content);
};
const setSending = (isSending) => {
state.isSending = isSending;
composer.toggleAttribute("data-busy", isSending);
promptEl.disabled = isSending;
saveKeyButton.disabled = isSending;
clearButton.disabled = isSending;
document.getElementById("send").disabled = isSending;
};
const loadApiKey = () => {
const key = localStorage.getItem(STORAGE_KEY);
if (key) {
apiKeyInput.value = key;
}
};
const loadModel = () => {
const savedModel = localStorage.getItem(MODEL_STORAGE_KEY);
state.model = savedModel || DEFAULT_MODEL;
if (modelSelect) {
modelSelect.value = state.model;
}
if (modelDisplay) {
modelDisplay.textContent = state.model;
}
updateModelErrorDisplay();
};
const saveModel = () => {
const selected = modelSelect?.value || DEFAULT_MODEL;
state.model = selected;
localStorage.setItem(MODEL_STORAGE_KEY, selected);
if (modelDisplay) {
modelDisplay.textContent = selected;
}
updateModelErrorDisplay();
};
const cacheModelOptions = () => {
if (!modelSelect) return;
const seen = new Set();
state.modelOptions = Array.from(modelSelect.options)
.map((option, index) => ({
value: option.value,
label: option.textContent || option.value,
order: index,
}))
.filter((option) => {
if (seen.has(option.value)) return false;
seen.add(option.value);
return true;
});
sortModelOptions();
};
const buildModelOptionLabel = (option) => {
const stats = state.modelStats[option.value];
if (!stats) return option.label;
const successes = stats.successes ?? 0;
const failures = stats.failures ?? 0;
const error = stats.lastErrorCode ? ` ERR ${stats.lastErrorCode}` : "";
return `${option.label} (ok ${successes} / fail ${failures}${error})`;
};
const rebuildModelOptions = (matches, selectedValue) => {
if (!modelSelect) return;
modelSelect.innerHTML = "";
matches.forEach((option) => {
const next = document.createElement("option");
next.value = option.value;
next.textContent = buildModelOptionLabel(option);
const lastErrorCode = state.modelStats[option.value]?.lastErrorCode;
if (lastErrorCode) {
next.style.color = "#b00020";
}
modelSelect.appendChild(next);
});
if (selectedValue) {
modelSelect.value = selectedValue;
}
};
const sortModelOptions = () => {
state.modelOptionsSorted = [...state.modelOptions].sort((a, b) => {
const aStats = state.modelStats[a.value];
const bStats = state.modelStats[b.value];
const aFailures = aStats?.failures ?? 0;
const bFailures = bStats?.failures ?? 0;
if (aFailures !== bFailures) {
return aFailures - bFailures;
}
return a.order - b.order;
});
};
const filterModels = () => {
if (!modelSelect || !modelSearch) return;
const query = modelSearch.value.trim().toLowerCase();
const previous = modelSelect.value;
const source = state.modelOptionsSorted.length
? state.modelOptionsSorted
: state.modelOptions;
const matches = query.length
? source.filter((option) => option.label.toLowerCase().includes(query))
: source;
const nextSelection = matches.find((option) => option.value === previous)
? previous
: matches[0]?.value;
rebuildModelOptions(matches, nextSelection);
if (nextSelection && nextSelection !== state.model) {
saveModel();
}
};
const saveApiKey = () => {
const value = apiKeyInput.value.trim();
if (!value) {
alert("Please enter an OpenRouter API key.");
return;
}
localStorage.setItem(STORAGE_KEY, value);
alert("API key saved locally.");
};
const loadModelStats = () => {
try {
const saved = localStorage.getItem(MODEL_STATS_STORAGE_KEY);
state.modelStats = saved ? JSON.parse(saved) : {};
} catch (error) {
state.modelStats = {};
}
};
const saveModelStats = () => {
localStorage.setItem(MODEL_STATS_STORAGE_KEY, JSON.stringify(state.modelStats));
};
const updateModelErrorDisplay = () => {
if (!modelError) return;
const stats = state.modelStats[state.model];
const code = stats?.lastErrorCode;
modelError.textContent = code ? `ERR ${code}` : "";
};
const recordModelSuccess = (modelId) => {
const entry = state.modelStats[modelId] || {
failures: 0,
successes: 0,
lastErrorCode: null,
};
entry.successes += 1;
entry.lastErrorCode = null;
state.modelStats[modelId] = entry;
saveModelStats();
if (modelId === state.model) {
updateModelErrorDisplay();
}
sortModelOptions();
filterModels();
};
const recordModelFailure = (modelId, statusCode) => {
const entry = state.modelStats[modelId] || {
failures: 0,
successes: 0,
lastErrorCode: null,
};
entry.failures += 1;
entry.lastErrorCode = statusCode ? String(statusCode) : "unknown";
state.modelStats[modelId] = entry;
saveModelStats();
if (modelId === state.model) {
updateModelErrorDisplay();
}
sortModelOptions();
filterModels();
};
const loadMultiModel = () => {
const saved = localStorage.getItem(MULTI_MODEL_STORAGE_KEY);
state.multiModelEnabled = saved === "true";
if (multiModelToggle) {
multiModelToggle.checked = state.multiModelEnabled;
}
};
const saveMultiModel = () => {
state.multiModelEnabled = multiModelToggle?.checked ?? false;
localStorage.setItem(MULTI_MODEL_STORAGE_KEY, String(state.multiModelEnabled));
};
const weightedSampleModels = (count, excludeValue) => {
const pool = state.modelOptions
.filter((option) => option.value !== excludeValue)
.map((option) => {
const stats = state.modelStats[option.value];
const successes = stats?.successes ?? 0;
const failures = stats?.failures ?? 0;
const weight = (successes + 1) / (failures + 1);
return { option, weight };
});
const picks = [];
const available = [...pool];
while (picks.length < count && available.length > 0) {
const totalWeight = available.reduce((sum, item) => sum + item.weight, 0);
let threshold = Math.random() * totalWeight;
let chosenIndex = 0;
for (let i = 0; i < available.length; i += 1) {
threshold -= available[i].weight;
if (threshold <= 0) {
chosenIndex = i;
break;
}
}
picks.push(available[chosenIndex].option);
available.splice(chosenIndex, 1);
}
return picks;
};
const callOpenRouter = async (messages, modelId = state.model) => {
const key = apiKeyInput.value.trim();
if (!key) {
throw new Error("Missing API key.");
}
const headers = {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
};
if (window.location?.origin) {
headers["HTTP-Referer"] = window.location.origin;
}
headers["X-Title"] = "OpenRouter Chat Frontend";
let response;
try {
response = await fetch(API_URL, {
method: "POST",
headers,
body: JSON.stringify({
model: modelId,
messages,
}),
});
} catch (error) {
recordModelFailure(modelId, "network");
throw error;
}
if (!response.ok) {
const text = await response.text();
let detail = response.statusText;
try {
const payload = JSON.parse(text);
const message = payload?.error?.message;
const raw = payload?.error?.metadata?.raw;
const provider =
payload?.error?.metadata?.provider_name || payload?.error?.provider_name;
detail =
[message, provider ? `provider=${provider}` : null, raw]
.filter(Boolean)
.join(" | ") || text || detail;
} catch (parseError) {
detail = text || detail;
}
recordModelFailure(modelId, response.status);
throw new Error(`OpenRouter error (${response.status}): ${detail}`);
}
const data = await response.json();
const choice = data.choices?.[0]?.message?.content;
if (!choice) {
recordModelFailure(modelId, "empty");
throw new Error("No response returned from OpenRouter.");
}
recordModelSuccess(modelId);
return choice;
};
const handleSubmit = async (event) => {
event.preventDefault();
if (state.isSending) return;
const content = promptEl.value.trim();
if (!content) return;
addMessage("user", content);
renderMessage("user", content);
promptEl.value = "";
resizeTextarea();
setSending(true);
const placeholder = renderMessage("assistant", "Thinking...");
try {
let assistantReply = "";
if (state.multiModelEnabled) {
const helpers = weightedSampleModels(4, state.model);
if (helpers.length === 0) {
throw new Error("No alternate models available for synthesis.");
}
const names = helpers.map((model) => model.value).join(", ");
const helperHeader = `Consulting: ${names}`;
let helperPanel;
if (placeholder) {
updateMessageContent(placeholder, helperHeader);
helperPanel = createHelperPanel(helpers.length);
placeholder.appendChild(helperPanel.panel);
}
const helperCalls = helpers.map((model) =>
callOpenRouter(state.messages, model.value)
.then((result) => {
if (helperPanel) {
addHelperEntry(helperPanel, model.value, "ok", result);
helperPanel.incrementReceived();
}
return result;
})
.catch((error) => {
if (helperPanel) {
addHelperEntry(
helperPanel,
model.value,
"fail",
error?.message || "unknown error",
);
helperPanel.incrementReceived();
}
throw error;
}),
);
const results = await Promise.allSettled(helperCalls);
const successes = results
.map((result, index) => ({
result,
model: helpers[index],
}))
.filter(({ result }) => result.status === "fulfilled");
const failures = results
.map((result, index) => ({
result,
model: helpers[index],
}))
.filter(({ result }) => result.status === "rejected");
if (successes.length === 0) {
throw new Error("All helper models failed to respond.");
}
const synthesisPrompt = [
`User prompt:`,
content,
"",
...successes.map(
({ result, model }) =>
`Model ${model.value} response:\n${result.value}`,
),
].join("\n");
assistantReply = await callOpenRouter(
[
{
role: "system",
content:
"You are synthesizing up to 4 model responses into one final answer for the user. Create a single, coherent response that reads as if written by one expert. Prioritize correctness over consensus. Include all distinct, relevant details; remove duplicates and contradictions. If a response is speculative, low-confidence, or off-topic, exclude it. When responses disagree, choose the most defensible view and, if useful, briefly note the uncertainty. Preserve any concrete examples, steps, or caveats that improve usefulness. Keep the final output concise, clear, and well-structured for the user’s request.",
},
{ role: "user", content: synthesisPrompt },
],
state.model,
);
const failureList = failures.map(({ model, result }) => {
const reason = result?.reason?.message || "unknown error";
return `- ${model.value}: ${reason}`;
});
if (failureList.length > 0) {
assistantReply = `${helperHeader}\n\n${assistantReply}\n\n**Helper failures:**\n${failureList.join("\n")}`;
} else {
assistantReply = `${helperHeader}\n\n${assistantReply}`;
}
} else {
assistantReply = await callOpenRouter(state.messages);
}
if (placeholder) {
updateMessageContent(placeholder, assistantReply);
}
addMessage("assistant", assistantReply);
} catch (error) {
if (placeholder) {
updateMessageContent(placeholder, error.message);
}
} finally {
setSending(false);
}
};
const clearChat = () => {
state.messages = [];
messagesEl.innerHTML = "";
};
saveKeyButton.addEventListener("click", saveApiKey);
modelSelect.addEventListener("change", saveModel);
modelSearch.addEventListener("input", filterModels);
if (multiModelToggle) {
multiModelToggle.addEventListener("change", saveMultiModel);
}
composer.addEventListener("submit", handleSubmit);
promptEl.addEventListener("input", resizeTextarea);
promptEl.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
composer.requestSubmit();
}
});
clearButton.addEventListener("click", clearChat);
loadApiKey();
loadModelStats();
loadModel();
loadMultiModel();
cacheModelOptions();
filterModels();
resizeTextarea();
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenRouter Chat</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="page">
<header class="hero">
<div>
<p class="eyebrow">OpenRouter</p>
<h1>Minimal Chat Console</h1>
<p class="subhead">
A focused client for OpenRouter with a clean prompt history.
</p>
</div>
<div class="key-panel">
<label for="apiKey">API key</label>
<div class="key-row">
<input id="apiKey" type="password" placeholder="sk-or-..." autocomplete="off" />
<button id="saveKey" type="button">Save</button>
</div>
<label class="model-label" for="modelSelect">Model</label>
<input
id="modelSearch"
type="search"
placeholder="Search models..."
autocomplete="off"
/>
<select id="modelSelect">
<option value="xiaomi/mimo-v2-flash:free">xiaomi/mimo-v2-flash:free</option>
<option value="openai/gpt-oss-120b:free">openai/gpt-oss-120b:free</option>
<option value="deepseek/deepseek-r1-0528:free">deepseek/deepseek-r1-0528:free</option>
<option value="nex-agi/deepseek-v3.1-nex-n1:free">nex-agi/deepseek-v3.1-nex-n1:free</option>
<option value="google/gemma-3-27b-it:free">google/gemma-3-27b-it:free</option>
<option value="meta-llama/llama-3.1-405b-instruct:free">meta-llama/llama-3.1-405b-instruct:free</option>
<option value="nousresearch/hermes-3-llama-3.1-405b:free">nousresearch/hermes-3-llama-3.1-405b:free</option>
<option value="meta-llama/llama-3.3-70b-instruct:free">meta-llama/llama-3.3-70b-instruct:free</option>
<option value="mistralai/mistral-7b-instruct:free">mistralai/mistral-7b-instruct:free</option>
<option value="openai/gpt-oss-20b:free">openai/gpt-oss-20b:free</option>
<option value="allenai/olmo-3.1-32b-think:free">allenai/olmo-3.1-32b-think:free</option>
<option value="nvidia/nemotron-3-nano-30b-a3b:free">nvidia/nemotron-3-nano-30b-a3b:free</option>
<option value="mistralai/devstral-2512:free">mistralai/devstral-2512:free</option>
<option value="z-ai/glm-4.5-air:free">z-ai/glm-4.5-air:free</option>
<option value="arcee-ai/trinity-mini:free">arcee-ai/trinity-mini:free</option>
<option value="kwaipilot/kat-coder-pro:free">kwaipilot/kat-coder-pro:free</option>
<option value="alibaba/tongyi-deepresearch-30b-a3b:free">alibaba/tongyi-deepresearch-30b-a3b:free</option>
<option value="nvidia/nemotron-nano-12b-v2-vl:free">nvidia/nemotron-nano-12b-v2-vl:free</option>
<option value="tngtech/deepseek-r1t2-chimera:free">tngtech/deepseek-r1t2-chimera:free</option>
<option value="tngtech/tng-r1t-chimera:free">tngtech/tng-r1t-chimera:free</option>
<option value="tngtech/deepseek-r1t-chimera:free">tngtech/deepseek-r1t-chimera:free</option>
<option value="qwen/qwen3-coder:free">qwen/qwen3-coder:free</option>
<option value="cognitivecomputations/dolphin-mistral-24b-venice-edition:free">cognitivecomputations/dolphin-mistral-24b-venice-edition:free</option>
<option value="mistralai/mistral-small-3.1-24b-instruct:free">mistralai/mistral-small-3.1-24b-instruct:free</option>
<option value="google/gemma-3-12b-it:free">google/gemma-3-12b-it:free</option>
<option value="nvidia/nemotron-nano-9b-v2:free">nvidia/nemotron-nano-9b-v2:free</option>
<option value="google/gemma-3n-e4b-it:free">google/gemma-3n-e4b-it:free</option>
<option value="google/gemma-3n-e2b-it:free">google/gemma-3n-e2b-it:free</option>
<option value="qwen/qwen-2.5-vl-7b-instruct:free">qwen/qwen-2.5-vl-7b-instruct:free</option>
<option value="qwen/qwen3-4b:free">qwen/qwen3-4b:free</option>
<option value="google/gemma-3-4b-it:free">google/gemma-3-4b-it:free</option>
<option value="google/gemini-2.0-flash-exp:free">google/gemini-2.0-flash-exp:free</option>
<option value="meta-llama/llama-3.2-3b-instruct:free">meta-llama/llama-3.2-3b-instruct:free</option>
<!-- OpenRouter error (404): No endpoints found matching your data policy (Free model publication). Configure: https://openrouter.ai/settings/privacy -->
<!-- <option value="moonshotai/kimi-k2:free">moonshotai/kimi-k2:free</option> -->
<!-- Not Free -->
<!-- <option value="sourceful/riverflow-v2-max-preview">sourceful/riverflow-v2-max-preview</option> -->
<!-- <option value="sourceful/riverflow-v2-standard-preview">sourceful/riverflow-v2-standard-preview</option> -->
<!-- <option value="sourceful/riverflow-v2-fast-preview">sourceful/riverflow-v2-fast-preview</option> -->
<!-- <option value="openai/gpt-oss-20b">openai/gpt-oss-20b:free</option> -->
</select>
<label class="toggle-row" for="multiModel">
<input id="multiModel" type="checkbox" />
Experimental: multi-model synthesis
</label>
<p class="hint">Stored locally in your browser. Never sent anywhere except OpenRouter.</p>
</div>
</header>
<main class="chat">
<section id="messages" class="messages" aria-live="polite"></section>
<form id="composer" class="composer" autocomplete="off">
<textarea
id="prompt"
rows="1"
placeholder="Ask something..."
required
></textarea>
<div class="composer-actions">
<button id="clear" type="button" class="ghost">Clear</button>
<button id="send" type="submit">Send</button>
</div>
</form>
</main>
<footer class="footer">
<div>
Model: <span class="mono" id="modelDisplay">xiaomi/mimo-v2-flash:free</span>
<span id="modelError" class="model-error"></span>
</div>
<div>
Powered by OpenRouter • <a href="https://openrouter.ai" target="_blank" rel="noreferrer">openrouter.ai</a>
</div>
</footer>
</div>
<script src="app.js"></script>
</body>
</html>
:root {
color-scheme: light;
font-family: "Space Grotesk", "Trebuchet MS", sans-serif;
--ink: #1d1a20;
--muted: #585166;
--panel: #f5f0e6;
--accent: #e36f55;
--accent-dark: #b5472e;
--wash: #fff8ef;
--shadow: 0 30px 60px rgba(22, 12, 8, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: radial-gradient(circle at top left, #f9f0dc 0%, #f0e3ce 40%, #efe7d7 100%);
color: var(--ink);
min-height: 100vh;
}
.page {
max-width: 1100px;
margin: 0 auto;
padding: 48px 24px 32px;
display: grid;
gap: 32px;
}
.hero {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
align-items: center;
}
.eyebrow {
letter-spacing: 0.2em;
text-transform: uppercase;
font-size: 12px;
margin: 0 0 12px;
color: var(--muted);
}
h1 {
font-size: clamp(2.4rem, 3vw, 3.4rem);
margin: 0 0 8px;
}
.subhead {
margin: 0;
color: var(--muted);
max-width: 36ch;
}
.key-panel {
background: var(--panel);
padding: 20px;
border-radius: 18px;
box-shadow: var(--shadow);
}
.key-panel label {
display: block;
font-weight: 600;
margin-bottom: 8px;
}
.key-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
}
.key-panel input {
border-radius: 12px;
border: 1px solid rgba(29, 26, 32, 0.2);
padding: 12px 14px;
font-size: 0.95rem;
background: #fff;
}
.key-panel button {
border: none;
border-radius: 12px;
background: var(--accent);
color: white;
padding: 12px 18px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, background 0.2s ease;
}
.model-label {
display: block;
margin: 16px 0 8px;
font-weight: 600;
}
.key-panel select {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(29, 26, 32, 0.2);
padding: 12px 14px;
font-size: 0.95rem;
background: #fff;
}
.toggle-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
font-weight: 600;
font-size: 0.9rem;
}
.toggle-row input {
width: 18px;
height: 18px;
}
.key-panel input[type="search"] {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(29, 26, 32, 0.2);
padding: 10px 12px;
font-size: 0.9rem;
background: #fff;
margin-bottom: 10px;
}
.key-panel button:hover {
background: var(--accent-dark);
transform: translateY(-1px);
}
.hint {
margin: 10px 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.chat {
display: grid;
gap: 20px;
}
.messages {
display: grid;
gap: 16px;
padding: 20px;
background: rgba(255, 255, 255, 0.6);
border-radius: 24px;
min-height: 320px;
box-shadow: var(--shadow);
}
.message {
display: grid;
gap: 8px;
padding: 16px 18px;
border-radius: 18px;
background: var(--wash);
border: 1px solid rgba(29, 26, 32, 0.08);
}
.message.user {
background: #fff0e1;
border-color: rgba(227, 111, 85, 0.25);
}
.message .role {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--muted);
}
.message .content {
white-space: pre-wrap;
line-height: 1.5;
}
.markdown-section {
display: grid;
gap: 6px;
}
.markdown-actions {
display: flex;
justify-content: flex-end;
}
.copy-button {
padding: 4px 12px;
font-size: 0.75rem;
}
.composer {
display: grid;
gap: 12px;
background: #fff;
padding: 16px;
border-radius: 20px;
box-shadow: var(--shadow);
}
.composer textarea {
border: none;
resize: none;
font-size: 1rem;
font-family: inherit;
min-height: 48px;
outline: none;
}
.composer-actions {
display: flex;
justify-content: space-between;
gap: 12px;
}
button {
font-family: inherit;
}
#send {
background: var(--accent);
color: #fff;
border: none;
padding: 10px 22px;
border-radius: 999px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
}
#send:hover {
background: var(--accent-dark);
transform: translateY(-1px);
}
.ghost {
background: transparent;
border: 1px solid rgba(29, 26, 32, 0.2);
padding: 10px 20px;
border-radius: 999px;
cursor: pointer;
}
.helper-panel {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed rgba(29, 26, 32, 0.15);
}
.helper-toggle {
border-radius: 999px;
padding: 6px 14px;
font-size: 0.85rem;
}
.helper-list {
margin-top: 10px;
display: grid;
gap: 10px;
}
.helper-entry {
background: #fff;
border-radius: 12px;
border: 1px solid rgba(29, 26, 32, 0.08);
padding: 10px 12px;
}
.helper-meta {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 6px;
}
.helper-meta-info {
display: flex;
align-items: center;
gap: 8px;
}
.helper-entry-toggle {
padding: 4px 12px;
font-size: 0.75rem;
}
.helper-status.ok {
color: #1c6b3e;
font-weight: 600;
}
.helper-status.fail {
color: #b00020;
font-weight: 600;
}
.footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 0.9rem;
}
.model-error {
color: #b00020;
font-weight: 600;
margin-left: 8px;
}
.footer a {
color: inherit;
}
.mono {
font-family: "Space Mono", "SFMono-Regular", Consolas, monospace;
}
@media (max-width: 700px) {
.page {
padding: 32px 18px 24px;
}
.hero {
gap: 18px;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment