Files
AI-Model-Tester/index.html
T
2026-05-06 16:42:03 +03:00

1406 lines
43 KiB
HTML

<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Model Tester</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Outfit:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
:root {
--bg0: #05070d;
--bg1: #080b16;
--card: rgba(255, 255, 255, 0.06);
--card2: rgba(255, 255, 255, 0.09);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.72);
--muted2: rgba(255, 255, 255, 0.55);
--line: rgba(255, 255, 255, 0.12);
--shadow: 0 18px 60px rgba(0, 0, 0, 0.45);
--glow: 0 0 0 1px rgba(150, 200, 255, 0.22), 0 0 40px rgba(120, 180, 255, 0.22);
--radius: 18px;
--ease: 0.3s ease;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
color: var(--text);
font-family: Outfit, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
background: radial-gradient(1100px 700px at 15% 10%, rgba(120, 180, 255, 0.18), transparent 55%),
radial-gradient(900px 600px at 80% 30%, rgba(214, 110, 255, 0.14), transparent 60%),
radial-gradient(900px 800px at 55% 90%, rgba(80, 255, 215, 0.08), transparent 60%),
linear-gradient(180deg, var(--bg0), var(--bg1));
}
.wrap {
max-width: 1120px;
margin: 0 auto;
padding: 26px 18px 46px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 18px;
border-radius: var(--radius);
background: var(--card);
backdrop-filter: blur(14px);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(120, 180, 255, 0.9), rgba(214, 110, 255, 0.75));
box-shadow: 0 18px 40px rgba(140, 160, 255, 0.18);
position: relative;
overflow: hidden;
}
.logo::after {
content: "";
position: absolute;
inset: -80px;
background: conic-gradient(
from 210deg,
rgba(255, 255, 255, 0.0),
rgba(255, 255, 255, 0.16),
rgba(255, 255, 255, 0.0)
);
animation: spin 4.5s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.title h1 {
margin: 0;
font-size: 20px;
letter-spacing: 0.2px;
font-weight: 700;
}
.title p {
margin: 2px 0 0;
color: var(--muted2);
font-size: 12.5px;
}
.status-pill {
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
padding: 8px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--muted);
}
main {
display: grid;
gap: 16px;
margin-top: 16px;
}
.grid2 {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 16px;
}
@media (max-width: 980px) {
.grid2 {
grid-template-columns: 1fr;
}
}
.card {
border-radius: var(--radius);
background: var(--card);
backdrop-filter: blur(14px);
border: 1px solid var(--line);
box-shadow: var(--shadow);
padding: 16px;
}
.card h2 {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.2px;
color: rgba(255, 255, 255, 0.88);
}
.providers {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
}
@media (max-width: 980px) {
.providers {
grid-template-columns: repeat(2, 1fr);
}
}
.provider {
position: relative;
border-radius: 16px;
padding: 12px 12px;
cursor: pointer;
transition: transform var(--ease), background var(--ease), border-color var(--ease), box-shadow var(--ease);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
min-height: 62px;
display: flex;
align-items: center;
gap: 10px;
}
.provider:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.06);
}
.provider.active {
border-color: rgba(150, 200, 255, 0.35);
background: rgba(130, 180, 255, 0.11);
box-shadow: var(--glow);
}
.picon {
width: 34px;
height: 34px;
border-radius: 12px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.12);
font-size: 16px;
}
.pmeta {
display: grid;
gap: 2px;
}
.pname {
font-size: 13px;
font-weight: 700;
}
.psub {
font-size: 12px;
color: var(--muted2);
}
.row {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
@media (max-width: 640px) {
.row2 {
grid-template-columns: 1fr;
}
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input[type="text"],
input[type="password"],
select,
textarea {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.25);
color: var(--text);
padding: 11px 12px;
outline: none;
transition: box-shadow var(--ease), border-color var(--ease);
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 13px;
}
textarea {
min-height: 170px;
resize: vertical;
line-height: 1.5;
}
input:focus,
select:focus,
textarea:focus {
border-color: rgba(150, 200, 255, 0.35);
box-shadow: 0 0 0 1px rgba(150, 200, 255, 0.22), 0 0 0 4px rgba(120, 180, 255, 0.12);
}
/* Prevent browser autofill highlight from breaking dark theme */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
-webkit-text-fill-color: var(--text);
-webkit-box-shadow: 0 0 0px 1000px rgba(0, 0, 0, 0.25) inset;
box-shadow: 0 0 0px 1000px rgba(0, 0, 0, 0.25) inset;
transition: background-color 9999s ease-in-out 0s;
caret-color: var(--text);
}
.field {
position: relative;
}
.field-actions {
display: flex;
gap: 8px;
align-items: center;
}
.btn {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
border-radius: 14px;
padding: 10px 12px;
cursor: pointer;
transition: transform var(--ease), background var(--ease), box-shadow var(--ease), border-color var(--ease);
font-family: Outfit, system-ui, sans-serif;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.btn:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.08);
}
.btn.primary {
background: linear-gradient(135deg, rgba(120, 180, 255, 0.22), rgba(214, 110, 255, 0.16));
border-color: rgba(150, 200, 255, 0.25);
}
.btn.primary:hover {
box-shadow: var(--glow);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.hint {
color: var(--muted2);
font-size: 12px;
margin-top: 6px;
}
.inline {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.key-status {
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
color: var(--muted);
padding: 7px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.11);
}
.toggle {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
transition: box-shadow var(--ease), border-color var(--ease), background var(--ease);
}
.toggle input {
display: none;
}
.switch {
width: 46px;
height: 26px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(0, 0, 0, 0.35);
position: relative;
transition: background var(--ease), border-color var(--ease);
}
.knob {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
transition: transform var(--ease);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
}
.toggle.on {
border-color: rgba(120, 255, 215, 0.24);
background: rgba(120, 255, 215, 0.06);
box-shadow: 0 0 0 1px rgba(120, 255, 215, 0.18), 0 0 30px rgba(120, 255, 215, 0.12);
}
.toggle.on .switch {
border-color: rgba(120, 255, 215, 0.35);
background: rgba(120, 255, 215, 0.16);
}
.toggle.on .knob {
transform: translateX(20px);
}
.footer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
flex-wrap: wrap;
}
.count {
color: var(--muted2);
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
}
.response {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.22);
padding: 14px 14px;
min-height: 220px;
overflow: auto;
}
.chatlist {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
padding: 12px;
max-height: 420px;
overflow: auto;
display: grid;
gap: 10px;
}
.msg {
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
padding: 10px 12px;
}
.msg.user {
background: rgba(120, 180, 255, 0.10);
border-color: rgba(150, 200, 255, 0.22);
}
.msg.ai {
background: rgba(255, 255, 255, 0.05);
}
.msg-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.msg-title {
font-weight: 800;
font-size: 12.5px;
color: rgba(255, 255, 255, 0.86);
}
.msg-meta {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 11.5px;
color: var(--muted2);
white-space: nowrap;
}
.msg-body {
white-space: pre-wrap;
line-height: 1.55;
color: rgba(255, 255, 255, 0.9);
}
.msg-body .md {
font-family: Outfit, system-ui, sans-serif;
}
.response .md {
font-family: Outfit, system-ui, sans-serif;
color: rgba(255, 255, 255, 0.92);
}
.response pre,
.response code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12.5px;
}
.response pre {
padding: 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
overflow: auto;
}
details.thinking {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
padding: 10px 12px;
margin: 0 0 10px;
}
details.thinking summary {
cursor: pointer;
color: rgba(255, 255, 255, 0.86);
font-weight: 700;
font-size: 13px;
list-style: none;
outline: none;
}
details.thinking summary::-webkit-details-marker {
display: none;
}
.thinking-body {
margin-top: 10px;
color: rgba(255, 255, 255, 0.86);
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12.5px;
line-height: 1.55;
white-space: pre-wrap;
}
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
max-width: 680px;
width: calc(100% - 24px);
padding: 12px 14px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(12px);
box-shadow: var(--shadow);
display: none;
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
}
.toast.show {
display: block;
}
.skeleton {
border-radius: 14px;
height: 44px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0.1),
rgba(255, 255, 255, 0.06)
);
background-size: 200% 100%;
animation: shimmer 1.2s ease-in-out infinite;
border: 1px solid rgba(255, 255, 255, 0.1);
}
@keyframes shimmer {
0% {
background-position: 0% 0%;
}
100% {
background-position: 200% 0%;
}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">
<div class="logo" aria-hidden="true"></div>
<div class="title">
<h1>AI Model Tester</h1>
<p>Единый интерфейс для Ollama / OpenRouter / NVIDIA / HuggingFace</p>
</div>
</div>
<div class="status-pill" id="serverStatus">server: unknown</div>
</header>
<main>
<section class="card">
<h2>Provider selector</h2>
<div class="providers" id="providers"></div>
</section>
<section class="grid2">
<section class="card">
<h2>Configuration</h2>
<div class="row">
<div class="row2">
<div>
<label for="endpoint">Endpoint (для Ollama-web / ollama.com)</label>
<input id="endpoint" type="text" placeholder="https://ollama.com" />
<div class="hint">Для остальных провайдеров используется фиксированный API endpoint.</div>
</div>
<div>
<label for="model">Model</label>
<input
id="modelSearch"
type="text"
placeholder="Поиск модели..."
style="margin-bottom: 8px"
name="model_search_no_autofill"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
/>
<div id="modelSkeleton" class="skeleton" style="display: none"></div>
<select id="model"></select>
<div class="hint" id="modelHint">Загрузите список моделей для выбранного провайдера.</div>
</div>
</div>
<div class="row2">
<div>
<label for="apiKey">API Key</label>
<div class="field-actions">
<input
id="apiKey"
type="password"
placeholder="••••••••••••"
name="api_key_no_autofill"
autocomplete="new-password"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
inputmode="text"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
/>
<button class="btn" id="toggleKey">Show</button>
<button class="btn" id="saveKey">Save</button>
</div>
<div class="footer-row">
<div class="key-status" id="keyStatus">key: not saved</div>
<div class="inline">
<button class="btn" id="loadModels">Load models</button>
<button class="btn" id="refreshModels">Refresh (Ollama)</button>
</div>
</div>
</div>
<div>
<label>Thinking mode</label>
<div class="inline">
<div class="toggle" id="reasoningToggle" role="switch" aria-checked="false">
<input id="reasoning" type="checkbox" />
<div class="switch"><div class="knob"></div></div>
<div>
<div style="font-weight: 700; font-size: 13px">Режим размышления</div>
<div style="color: var(--muted2); font-size: 12px">Enable reasoning</div>
</div>
</div>
<div class="hint">Если провайдер не поддерживает, запрос всё равно будет выполнен.</div>
</div>
</div>
</div>
</div>
</section>
<section class="card">
<h2>Query</h2>
<div class="row">
<div>
<label for="prompt">Prompt</label>
<textarea id="prompt" placeholder="Напишите запрос..."></textarea>
<div class="footer-row">
<div class="count" id="charCount">0 chars</div>
<div class="inline">
<button class="btn primary" id="run">Send</button>
<button class="btn" id="stop">Stop</button>
</div>
</div>
</div>
</div>
</section>
</section>
<section class="card">
<h2>Response</h2>
<div class="footer-row" style="margin-top: 0; margin-bottom: 10px">
<div class="count" id="respMeta">idle</div>
<div class="inline">
<button class="btn" id="copy">Copy</button>
<button class="btn" id="clear">Clear</button>
</div>
</div>
<div class="response">
<details class="thinking" id="reasoningBox" style="display: none">
<summary id="reasoningTitle">Thinking</summary>
<div class="thinking-body" id="reasoningText"></div>
</details>
<div class="md" id="response"></div>
</div>
</section>
<section class="card">
<h2>Chat history</h2>
<div class="footer-row" style="margin-top: 0; margin-bottom: 10px">
<div class="count" id="chatMeta">idle</div>
<div class="inline">
<button class="btn" id="chatLoadOlder">Load older</button>
<button class="btn" id="chatClear">Clear history</button>
</div>
</div>
<div class="chatlist" id="chatList"></div>
</section>
</main>
</div>
<div class="toast" id="toast"></div>
<script>
const PROVIDERS = [
{ id: "ollama", name: "Ollama", icon: "🦙", sub: "local /api/tags" },
{ id: "ollama-web", name: "Ollama-web", icon: "🌐", sub: "ollama.com API" },
{ id: "openrouter", name: "OpenRouter", icon: "🧭", sub: "chat/completions" },
{ id: "nvidia", name: "NVIDIA", icon: "🟩", sub: "integrate.api" },
{ id: "huggingface", name: "HuggingFace", icon: "🤗", sub: "inference API" },
];
const el = (id) => document.getElementById(id);
const providersEl = el("providers");
const serverStatusEl = el("serverStatus");
const endpointEl = el("endpoint");
const apiKeyEl = el("apiKey");
const toggleKeyEl = el("toggleKey");
const saveKeyEl = el("saveKey");
const keyStatusEl = el("keyStatus");
const modelEl = el("model");
const modelSearchEl = el("modelSearch");
const modelHintEl = el("modelHint");
const modelSkeletonEl = el("modelSkeleton");
const loadModelsEl = el("loadModels");
const refreshModelsEl = el("refreshModels");
const reasoningToggleEl = el("reasoningToggle");
const reasoningEl = el("reasoning");
const promptEl = el("prompt");
const charCountEl = el("charCount");
const runEl = el("run");
const stopEl = el("stop");
const responseEl = el("response");
const reasoningBoxEl = el("reasoningBox");
const reasoningTitleEl = el("reasoningTitle");
const reasoningTextEl = el("reasoningText");
const respMetaEl = el("respMeta");
const chatMetaEl = el("chatMeta");
const chatListEl = el("chatList");
const chatLoadOlderEl = el("chatLoadOlder");
const chatClearEl = el("chatClear");
const copyEl = el("copy");
const clearEl = el("clear");
const toastEl = el("toast");
let state = {
provider: "ollama",
hasKey: {},
keyMasked: {},
endpoint: {},
lastModel: {},
models: {}, // provider -> string[]
abort: null,
rawText: "",
rawReasoning: "",
currentChatId: null,
chats: {}, // provider -> { items: [], hasMore: bool, loading: bool }
};
const STORAGE_KEYS = {
lastProvider: "aimt.lastProvider",
lastModelPrefix: "aimt.lastModel.",
};
function isValidProvider(id) {
return PROVIDERS.some((p) => p.id === id);
}
function loadLastProviderFromStorage() {
try {
const p = localStorage.getItem(STORAGE_KEYS.lastProvider) || "";
return isValidProvider(p) ? p : "";
} catch {
return "";
}
}
function saveLastProviderToStorage(provider) {
try {
localStorage.setItem(STORAGE_KEYS.lastProvider, provider);
} catch {
// ignore
}
}
function loadLastModelFromStorage(provider) {
try {
const m = localStorage.getItem(STORAGE_KEYS.lastModelPrefix + provider) || "";
return m || "";
} catch {
return "";
}
}
function saveLastModelToStorage(provider, model) {
try {
localStorage.setItem(STORAGE_KEYS.lastModelPrefix + provider, model);
} catch {
// ignore
}
}
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
window.clearTimeout(toast._t);
toast._t = window.setTimeout(() => toastEl.classList.remove("show"), 2600);
}
function setStatus(text) {
serverStatusEl.textContent = text;
}
function renderProviders() {
providersEl.innerHTML = "";
for (const p of PROVIDERS) {
const d = document.createElement("div");
d.className = "provider" + (p.id === state.provider ? " active" : "");
d.dataset.provider = p.id;
d.innerHTML = `
<div class="picon">${p.icon}</div>
<div class="pmeta">
<div class="pname">${p.name}</div>
<div class="psub">${p.sub}</div>
</div>
`;
d.addEventListener("click", () => selectProvider(p.id));
providersEl.appendChild(d);
}
}
function fmtTs(ts) {
try {
const d = new Date((ts || 0) * 1000);
if (!isFinite(d.getTime())) return "";
return d.toLocaleString();
} catch {
return "";
}
}
function getChatState(provider) {
if (!state.chats[provider]) {
state.chats[provider] = { items: [], hasMore: false, loading: false };
}
return state.chats[provider];
}
function renderChat() {
const provider = state.provider;
const cs = getChatState(provider);
const items = cs.items || [];
chatListEl.innerHTML = "";
if (!items.length) {
const d = document.createElement("div");
d.className = "hint";
d.textContent = "История пуста для этого провайдера.";
chatListEl.appendChild(d);
} else {
for (const it of items) {
const wrap = document.createElement("div");
wrap.style.display = "grid";
wrap.style.gap = "8px";
const u = document.createElement("div");
u.className = "msg user";
u.innerHTML = `
<div class="msg-header">
<div class="msg-title">You</div>
<div class="msg-meta">${fmtTs(it.createdAt || 0)}</div>
</div>
<div class="msg-body"></div>
`;
u.querySelector(".msg-body").textContent = it.prompt || "";
const a = document.createElement("div");
a.className = "msg ai";
const st = it.status || "done";
const meta = `${it.model || ""}${st && st !== "done" ? " · " + st : ""}`;
a.innerHTML = `
<div class="msg-header">
<div class="msg-title">Assistant</div>
<div class="msg-meta">${meta}</div>
</div>
<div class="msg-body">
${it.reasoning ? `<details class="thinking" open><summary>Thinking</summary><div class="thinking-body"></div></details>` : ""}
<div class="md"></div>
</div>
`;
if (it.reasoning) {
a.querySelector(".thinking-body").textContent = it.reasoning || "";
}
a.querySelector(".md").innerHTML = marked.parse(it.response || "", { breaks: true });
wrap.appendChild(u);
wrap.appendChild(a);
chatListEl.appendChild(wrap);
}
}
chatLoadOlderEl.disabled = cs.loading || !cs.hasMore;
chatClearEl.disabled = cs.loading || !items.length;
chatMetaEl.textContent = cs.loading
? "loading..."
: items.length
? `${items.length} items${cs.hasMore ? " · more available" : ""}`
: "idle";
}
async function loadChat({ reset = false } = {}) {
const provider = state.provider;
const cs = getChatState(provider);
if (cs.loading) return;
cs.loading = true;
renderChat();
try {
const limit = 10;
let url = `/api/chat?provider=${encodeURIComponent(provider)}&limit=${limit}`;
if (!reset && cs.items && cs.items.length) {
const oldest = cs.items[0];
if (oldest && oldest.id) url += `&before_id=${encodeURIComponent(oldest.id)}`;
}
const r = await fetch(url);
const raw = await r.text().catch(() => "");
let data = {};
try {
data = raw ? JSON.parse(raw) : {};
} catch {
data = {};
}
if (!r.ok || !data.ok) {
const details =
(data && data.error) ||
(raw && raw.trim() ? raw.trim().slice(0, 300) : "") ||
`${r.status} ${r.statusText}`.trim();
throw new Error(details || "chat load failed");
}
const pageDesc = data.items || [];
const pageChrono = pageDesc.slice().reverse(); // newest->oldest => oldest->newest
if (reset) {
cs.items = pageChrono;
} else {
cs.items = pageChrono.concat(cs.items || []);
}
cs.hasMore = !!data.hasMore;
} catch (e) {
toast(e.message || "Не удалось загрузить историю");
} finally {
cs.loading = false;
renderChat();
}
}
chatLoadOlderEl.addEventListener("click", () => loadChat({ reset: false }));
chatClearEl.addEventListener("click", async () => {
const provider = state.provider;
const cs = getChatState(provider);
if (cs.loading) return;
if (!confirm("Очистить историю для этого провайдера?")) return;
cs.loading = true;
renderChat();
try {
const r = await fetch("/api/chat/clear", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || "clear failed");
cs.items = [];
cs.hasMore = false;
toast("История очищена");
} catch (e) {
toast(e.message || "Не удалось очистить историю");
} finally {
cs.loading = false;
renderChat();
}
});
function updateProviderUI() {
renderProviders();
const p = state.provider;
const canEndpoint = p === "ollama-web";
endpointEl.disabled = !canEndpoint;
endpointEl.value = state.endpoint[p] || (canEndpoint ? "https://ollama.com" : "");
const hasKey = !!state.hasKey[p];
const masked = state.keyMasked[p] || "";
keyStatusEl.textContent = hasKey ? `key: saved (${masked})` : "key: not saved";
refreshModelsEl.disabled = p !== "ollama";
}
function applyModelList(models, { keepSelection = true } = {}) {
const provider = state.provider;
const q = (modelSearchEl.value || "").trim().toLowerCase();
const prev = keepSelection ? modelEl.value : "";
const filtered = (models || []).filter((m) => !q || String(m).toLowerCase().includes(q));
modelEl.innerHTML = "";
if (!filtered.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = q ? "No matches" : "No models found";
modelEl.appendChild(opt);
modelHintEl.textContent = q ? "No matches" : "No models found";
return;
}
for (const m of filtered) {
const opt = document.createElement("option");
opt.value = m;
opt.textContent = m;
modelEl.appendChild(opt);
}
const last = state.lastModel[provider];
if (keepSelection && prev && filtered.includes(prev)) {
modelEl.value = prev;
} else if (last && filtered.includes(last)) {
modelEl.value = last;
} else {
modelEl.value = filtered[0] || "";
}
modelHintEl.textContent = `${(models || []).length} models loaded`;
}
async function selectProvider(provider) {
state.provider = provider;
saveLastProviderToStorage(provider);
updateProviderUI();
modelSearchEl.value = "";
const cached = state.models[provider];
if (cached && cached.length) {
applyModelList(cached, { keepSelection: false });
} else {
await loadModels({ auto: true });
}
await loadChat({ reset: true });
}
function setReasoning(on) {
reasoningEl.checked = !!on;
reasoningToggleEl.classList.toggle("on", !!on);
reasoningToggleEl.setAttribute("aria-checked", !!on ? "true" : "false");
setReasoningUI();
}
reasoningToggleEl.addEventListener("click", () => setReasoning(!reasoningEl.checked));
setReasoning(false);
toggleKeyEl.addEventListener("click", () => {
apiKeyEl.type = apiKeyEl.type === "password" ? "text" : "password";
toggleKeyEl.textContent = apiKeyEl.type === "password" ? "Show" : "Hide";
});
promptEl.addEventListener("input", () => {
charCountEl.textContent = `${promptEl.value.length} chars`;
});
function showModelLoading(on) {
modelSkeletonEl.style.display = on ? "block" : "none";
modelEl.style.display = on ? "none" : "block";
}
async function loadConfig() {
try {
const r = await fetch("/api/config");
if (!r.ok) throw new Error("config fetch failed");
const cfg = await r.json();
const provs = cfg.providers || {};
for (const p of PROVIDERS) {
const v = provs[p.id] || {};
state.hasKey[p.id] = !!v.hasKey;
state.keyMasked[p.id] = v.keyMasked || "";
state.endpoint[p.id] = v.endpoint || "";
state.lastModel[p.id] = v.lastModel || loadLastModelFromStorage(p.id) || "";
}
setStatus("server: ok");
} catch (e) {
setStatus("server: error");
toast("Не удалось загрузить config с сервера");
} finally {
updateProviderUI();
}
}
async function saveConfig() {
const provider = state.provider;
const apiKey = apiKeyEl.value.trim();
const endpoint = endpointEl.value.trim();
const lastModel = modelEl.value || "";
if (!apiKey && !state.hasKey[provider]) {
toast("Введите API key (или выберите провайдера без ключа)");
return;
}
const body = { provider, apiKey, lastModel };
if (provider === "ollama-web") body.endpoint = endpoint;
try {
const r = await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || "save failed");
toast("Сохранено");
apiKeyEl.value = "";
await loadConfig();
} catch (e) {
toast(`Ошибка сохранения: ${e.message}`);
}
}
saveKeyEl.addEventListener("click", saveConfig);
async function loadModels({ auto = false } = {}) {
const provider = state.provider;
showModelLoading(true);
modelHintEl.textContent = auto ? "Auto loading models..." : "Loading models...";
try {
const r = await fetch(`/api/models?provider=${encodeURIComponent(provider)}`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || "models load failed");
const models = data.models || [];
state.models[provider] = models;
applyModelList(models, { keepSelection: false });
} catch (e) {
modelHintEl.textContent = "Failed to load models";
toast(e.message || "Failed to load models");
} finally {
showModelLoading(false);
}
}
loadModelsEl.addEventListener("click", loadModels);
refreshModelsEl.addEventListener("click", loadModels);
modelSearchEl.addEventListener("input", () => {
const provider = state.provider;
const models = state.models[provider] || [];
applyModelList(models, { keepSelection: true });
});
// persist last selected model immediately
modelEl.addEventListener("change", () => {
const provider = state.provider;
const lastModel = modelEl.value || "";
if (!lastModel) return;
state.lastModel[provider] = lastModel;
saveLastModelToStorage(provider, lastModel);
fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, lastModel }),
}).catch(() => {});
});
function setResponseMarkdownFromRaw() {
const md = state.rawText || "";
responseEl.innerHTML = marked.parse(md, { breaks: true });
}
function setReasoningUI() {
const has = !!(state.rawReasoning || "").trim();
const enabled = reasoningEl.checked;
const show = enabled && has;
reasoningBoxEl.style.display = show ? "block" : "none";
if (!show) return;
reasoningTitleEl.textContent = "Thinking (reasoning)";
reasoningTextEl.textContent = state.rawReasoning;
}
function clearResponse() {
state.rawText = "";
state.rawReasoning = "";
setResponseMarkdownFromRaw();
setReasoningUI();
respMetaEl.textContent = "idle";
}
clearEl.addEventListener("click", clearResponse);
copyEl.addEventListener("click", async () => {
try {
const out = state.rawReasoning
? `# Reasoning\n\n${state.rawReasoning}\n\n# Response\n\n${state.rawText || ""}`
: state.rawText || "";
await navigator.clipboard.writeText(out);
toast("Скопировано");
} catch (e) {
toast("Не удалось скопировать");
}
});
function stopStreaming() {
if (state.abort) {
state.abort.abort();
state.abort = null;
respMetaEl.textContent = "stopped";
toast("Остановлено");
}
}
stopEl.addEventListener("click", stopStreaming);
function requireModel() {
const m = modelEl.value;
if (!m) throw new Error("Выберите модель");
return m;
}
async function run() {
stopStreaming();
const provider = state.provider;
const model = requireModel();
const prompt = promptEl.value.trim();
if (!prompt) {
toast("Введите prompt");
return;
}
// quick UX: if key needed but missing
const needsKey = provider !== "ollama";
if (needsKey && !state.hasKey[provider] && !apiKeyEl.value.trim()) {
toast("Нет API key. Введите и нажмите Save.");
return;
}
// If user typed key but didn't save yet, auto-save for convenience
if (apiKeyEl.value.trim()) {
await saveConfig();
} else {
// still persist lastModel
fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, lastModel: model }),
}).catch(() => {});
}
clearResponse();
state.currentChatId = null;
respMetaEl.textContent = `streaming: ${provider} / ${model}`;
runEl.disabled = true;
const ac = new AbortController();
state.abort = ac;
try {
const r = await fetch("/api/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
model,
prompt,
reasoning: reasoningEl.checked,
}),
signal: ac.signal,
});
if (!r.ok) {
const t = await r.text().catch(() => "");
throw new Error(t || "Request failed");
}
const reader = r.body.getReader();
const dec = new TextDecoder("utf-8");
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.type === "meta") {
state.currentChatId = msg.chatId || null;
continue;
}
if (msg.type === "delta" && typeof msg.text === "string") {
const ch = msg.channel || "content";
if (ch === "reasoning") {
state.rawReasoning += msg.text;
setReasoningUI();
} else {
state.rawText += msg.text;
setResponseMarkdownFromRaw();
}
} else if (msg.type === "error") {
throw new Error(msg.error || "Request failed");
} else if (msg.type === "done") {
respMetaEl.textContent = `done: ${provider} / ${model}`;
// Append to chat UI (no refetch needed)
const cs = getChatState(provider);
cs.items = (cs.items || []).concat([
{
id: state.currentChatId || Date.now(),
provider,
model,
prompt,
response: state.rawText || "",
reasoning: state.rawReasoning || "",
reasoningEnabled: !!reasoningEl.checked,
status: "done",
error: "",
createdAt: Math.floor(Date.now() / 1000),
updatedAt: Math.floor(Date.now() / 1000),
},
]);
renderChat();
}
}
}
} catch (e) {
if (e.name === "AbortError") {
// ignore
} else {
respMetaEl.textContent = "error";
toast(e.message || "Ошибка запроса");
}
} finally {
runEl.disabled = false;
state.abort = null;
}
}
runEl.addEventListener("click", run);
// init
const bootProvider = loadLastProviderFromStorage();
if (bootProvider) state.provider = bootProvider;
renderProviders();
updateProviderUI();
loadConfig().then(() => Promise.all([loadModels({ auto: true }), loadChat({ reset: true })]));
</script>
</body>
</html>