1406 lines
43 KiB
HTML
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>
|
|
|