Files
llm/tools/model-chat/index.html
T

4261 lines
153 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Model Chat Testbench</title>
<style>
:root {
--bg: #f3efe6;
--bg-top: #e6decd;
--panel: #fffdfa;
--panel-2: #f4eee2;
--panel-3: #e7ded0;
--ink: #192126;
--muted: #66717a;
--line: #d4c9b6;
--line-strong: #a9997f;
--accent: #0f766e;
--accent-strong: #124e4a;
--accent-soft: rgba(15, 118, 110, 0.10);
--accent-2: #9a5a17;
--danger: #b42318;
--danger-soft: rgba(180, 35, 24, 0.08);
--ok: #166534;
--ok-soft: rgba(22, 101, 52, 0.08);
--shadow: 0 20px 50px rgba(50, 40, 24, 0.12);
--shadow-soft: 0 12px 30px rgba(50, 40, 24, 0.07);
--radius: 10px;
--radius-lg: 18px;
--mono: "Cascadia Mono", "JetBrains Mono", Consolas, monospace;
--sans: "Aptos", "Segoe UI Variable Text", "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html,
body {
height: 100%;
}
body {
margin: 0;
min-height: 100%;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(18, 78, 74, 0.08), transparent 26%),
linear-gradient(180deg, var(--bg-top) 0, var(--bg) 260px);
font-family: var(--sans);
letter-spacing: 0;
overflow: hidden;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
border: 1px solid var(--line);
background: var(--panel);
color: var(--ink);
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease, box-shadow 140ms ease;
}
button:hover {
border-color: var(--line-strong);
box-shadow: 0 10px 18px rgba(50, 40, 24, 0.08);
}
button:active { transform: translateY(1px); }
button:disabled { cursor: not-allowed; opacity: 0.55; }
.app {
height: 100vh;
min-height: 0;
display: grid;
grid-template-columns: minmax(400px, 460px) minmax(0, 1fr);
overflow: hidden;
}
.sidebar {
border-right: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(243, 239, 230, 0.92), rgba(243, 239, 230, 0.88)),
linear-gradient(90deg, rgba(18, 78, 74, 0.04), transparent 30%);
padding: 22px 22px 18px;
display: flex;
flex-direction: column;
gap: 16px;
overflow: auto;
}
.brand {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.brand h1 {
margin: 0;
font-size: 25px;
line-height: 0.96;
font-weight: 800;
letter-spacing: -0.03em;
}
.status {
min-width: 88px;
padding: 7px 10px;
border: 1px solid var(--line);
background: var(--panel);
border-radius: 999px;
font-size: 12px;
text-align: center;
color: var(--accent-strong);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.status.ok {
color: var(--ok);
border-color: rgba(22, 101, 52, 0.28);
background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(22, 101, 52, 0.04));
}
.status.err {
color: var(--danger);
border-color: rgba(180, 35, 24, 0.28);
background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(180, 35, 24, 0.04));
}
.page-nav {
display: none;
}
.page-nav button {
min-height: 54px;
border-radius: 14px;
text-align: left;
padding: 10px 12px;
font-size: 13px;
line-height: 1.2;
background: rgba(255, 253, 248, 0.82);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
}
.page-nav button.active {
background:
linear-gradient(135deg, rgba(25, 33, 38, 0.96), rgba(18, 78, 74, 0.94));
color: #fff;
border-color: rgba(25, 33, 38, 0.94);
box-shadow: 0 18px 30px rgba(18, 78, 74, 0.18);
}
.page-nav button span {
display: block;
color: inherit;
opacity: 0.72;
font-size: 11px;
margin-top: 4px;
}
.settings-grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
align-items: start;
}
.settings-grid > .control-overview,
.settings-grid > .control-section,
.settings-grid > .photo-console,
.settings-grid > .model-library {
grid-column: span 12;
min-width: 0;
}
.control-overview,
.control-section {
border: 1px solid rgba(169, 153, 127, 0.32);
border-radius: var(--radius-lg);
background: rgba(255, 253, 250, 0.78);
box-shadow: var(--shadow-soft);
padding: 16px;
}
.control-overview {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
align-items: stretch;
}
.control-section {
display: grid;
gap: 12px;
}
.control-section-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
}
.control-section-head h2 {
margin: 0;
font-size: 15px;
line-height: 1.1;
letter-spacing: -0.02em;
}
.control-section-head p {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.control-section-body {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
align-items: start;
}
.control-overview > *,
.control-section-body > * {
grid-column: auto !important;
}
.control-overview > .field,
.control-section-body > .field,
.control-section-body > .params,
.control-section-body > .model-meta,
.control-section-body > .service-panel,
.control-section-body > .plugin-tool,
.control-section-body > .gallery-panel,
.control-section-body > .ingest-panel,
.control-section-body > .utility {
min-width: 0;
border: 1px solid rgba(212, 201, 182, 0.92);
border-radius: 14px;
background: rgba(255, 253, 250, 0.92);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.8);
padding: 12px;
}
#healthPanel { grid-column: span 1; }
#runtimeGroup { grid-column: span 1; }
#endpointGroup { grid-column: span 5; }
#pluginGroup { grid-column: span 3; }
#modelGroup { grid-column: span 4; }
#modelMeta { grid-column: span 5; }
#serviceGroup { grid-column: span 7; }
#compareGroup { grid-column: span 4; }
#ragProfileField { grid-column: span 3; }
#paramsGroup { grid-column: span 3; }
#systemPromptGroup { grid-column: span 6; }
#quickChecksGroup { grid-column: span 3; }
#utilityGroup { grid-column: span 3; margin-top: 0; }
#audioTool,
#videoTool,
#imageTool { grid-column: span 6; }
#galleryPanel { grid-column: span 6; }
#ingestGroup { grid-column: span 8; }
#modelLibrary { grid-column: span 4; }
[data-pages][hidden] {
display: none !important;
}
.inline-warning {
display: none;
padding: 8px 10px;
border: 1px solid rgba(180, 35, 24, 0.35);
border-radius: var(--radius);
background: rgba(180, 35, 24, 0.08);
color: var(--danger);
font-size: 12px;
line-height: 1.35;
}
.inline-warning.active { display: block; }
.field {
display: grid;
gap: 8px;
}
.field label {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.04em;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
color: var(--ink);
padding: 9px 10px;
outline: none;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.13);
}
.plugin-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.plugin-tabs button {
min-height: 38px;
border-radius: 12px;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plugin-tabs button.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.model-meta {
min-height: 96px;
padding: 12px;
border: 1px solid rgba(212, 201, 182, 0.9);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(255,255,255,0.94), rgba(244, 238, 226, 0.9));
display: grid;
gap: 7px;
font-size: 13px;
}
.service-panel {
border: 1px solid rgba(212, 201, 182, 0.9);
border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244, 238, 226, 0.9));
padding: 12px;
display: grid;
gap: 10px;
}
.service-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.service-actions button {
min-height: 36px;
border-radius: 12px;
font-size: 12px;
}
.benchmark-actions {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.benchmark-settings {
display: grid;
grid-template-columns: minmax(0, 1fr) 96px;
gap: 6px;
align-items: center;
font-size: 12px;
color: var(--muted);
}
.benchmark-settings input {
padding: 7px 8px;
min-height: 32px;
}
.benchmark-actions button {
min-height: 36px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
background: var(--accent-strong);
color: #fff;
border-color: var(--accent-strong);
}
.benchmark-history {
max-height: 124px;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 253, 248, 0.58);
padding: 8px;
display: grid;
gap: 6px;
font-size: 11px;
color: var(--muted);
line-height: 1.25;
}
.benchmark-row {
border-top: 1px solid rgba(213, 206, 192, 0.75);
padding-top: 6px;
overflow-wrap: anywhere;
}
.benchmark-row:first-child {
border-top: 0;
padding-top: 0;
}
.benchmark-row a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
margin-left: 6px;
}
.benchmark-row a:hover {
text-decoration: underline;
}
.service-status {
font-size: 12px;
color: var(--muted);
overflow-wrap: anywhere;
font-family: var(--mono);
}
.profile-hint {
border-top: 1px solid var(--line);
padding-top: 8px;
font-size: 12px;
color: var(--ink);
overflow-wrap: anywhere;
line-height: 1.35;
}
.profile-hint code {
display: block;
margin-top: 4px;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
}
.health-panel {
border: 1px solid rgba(18, 78, 74, 0.12);
border-radius: 16px;
background:
linear-gradient(135deg, rgba(25, 33, 38, 0.96), rgba(18, 78, 74, 0.9));
padding: 14px;
display: grid;
gap: 10px;
font-size: 12px;
color: #f8f2e8;
}
.health-row {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 8px;
align-items: start;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.health-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.health-row span:first-child {
color: rgba(248, 242, 232, 0.72);
text-transform: uppercase;
font-weight: 700;
font-size: 11px;
}
.health-row span:last-child {
overflow-wrap: anywhere;
font-family: var(--mono);
font-size: 11px;
color: #fff;
}
.check-list {
max-height: 130px;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 253, 248, 0.6);
padding: 8px;
display: grid;
gap: 7px;
}
.check-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 7px;
align-items: start;
font-size: 12px;
line-height: 1.25;
}
.plugin-tool {
border: 1px solid rgba(212, 201, 182, 0.9);
border-radius: 14px;
background: rgba(255, 253, 250, 0.92);
padding: 12px;
display: none;
gap: 10px;
}
.plugin-tool.page-visible {
display: none;
}
.plugin-tool.active {
display: grid;
}
.plugin-tool.active.page-visible {
display: grid;
}
.image-preview {
display: grid;
gap: 8px;
margin-top: 10px;
}
.image-preview img {
width: min(100%, 520px);
max-height: 520px;
object-fit: contain;
border: 1px solid var(--line);
background: #111827;
}
.mask-editor {
display: none;
gap: 8px;
}
.mask-editor.active {
display: grid;
}
.mask-stage {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
max-height: 320px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #111827;
overflow: hidden;
touch-action: none;
}
.mask-stage canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.mask-tools,
.gallery-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.mask-tools button,
.gallery-actions button {
min-height: 32px;
border-radius: var(--radius);
font-size: 12px;
}
.gallery-panel {
border: 1px solid rgba(212, 201, 182, 0.9);
border-radius: 14px;
background: rgba(255, 253, 250, 0.92);
padding: 12px;
display: grid;
gap: 8px;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
max-height: 260px;
overflow: auto;
}
.gallery-item {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
overflow: hidden;
display: grid;
gap: 5px;
padding-bottom: 7px;
}
.gallery-item img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
background: #111827;
border-bottom: 1px solid var(--line);
}
.gallery-item button {
margin: 0 7px;
min-height: 28px;
border-radius: var(--radius);
font-size: 12px;
}
.gallery-meta,
.job-meta {
padding: 0 7px;
font-size: 11px;
color: var(--muted);
line-height: 1.25;
overflow-wrap: anywhere;
}
.image-jobs {
max-height: 92px;
overflow: auto;
display: grid;
gap: 5px;
font-size: 11px;
color: var(--muted);
}
.check-row input {
width: 16px;
height: 16px;
padding: 0;
margin: 0;
}
.meta-row {
display: flex;
justify-content: space-between;
gap: 10px;
}
.meta-row span:first-child { color: var(--muted); }
.meta-row span:last-child {
text-align: right;
font-family: var(--mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.params {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.inline-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.inline-actions button {
min-width: 92px;
border-radius: var(--radius);
font-size: 13px;
}
.prompt-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.prompt-buttons button,
.utility button {
min-height: 34px;
border-radius: var(--radius);
font-size: 13px;
}
.utility {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.ingest-panel {
border: 1px solid rgba(212, 201, 182, 0.9);
border-radius: 14px;
background: rgba(255, 253, 250, 0.92);
padding: 12px;
display: grid;
gap: 9px;
}
.ingest-panel summary {
cursor: pointer;
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
font-weight: 700;
}
.ingest-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.ingest-actions button {
min-height: 34px;
border-radius: var(--radius);
font-size: 13px;
}
.jobs-list {
max-height: 118px;
overflow: auto;
display: grid;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.job-row {
border-top: 1px solid var(--line);
padding-top: 6px;
overflow-wrap: anywhere;
}
.photo-console,
.model-library {
min-width: 0;
border: 1px solid rgba(169, 153, 127, 0.32);
border-radius: var(--radius-lg);
background: rgba(255, 253, 250, 0.82);
box-shadow: var(--shadow-soft);
padding: 16px;
}
.photo-console {
grid-column: span 12;
display: grid;
gap: 12px;
}
.photo-console-head,
.model-library-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.photo-console-head h2,
.model-library-head h2 {
margin: 0;
font-size: 16px;
line-height: 1.1;
}
.photo-console-head p,
.model-library-head p {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.photo-console-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px;
}
.photo-console-grid > .field,
.photo-console-grid > .model-meta,
.photo-console-grid > .photo-note {
grid-column: span 4;
}
.photo-quick-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.photo-quick-actions button {
min-height: 36px;
border-radius: var(--radius);
font-size: 12px;
font-weight: 700;
}
.photo-service-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
}
.photo-service-actions button {
min-height: 36px;
border-radius: var(--radius);
font-size: 12px;
font-weight: 700;
}
.photo-presets {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.photo-presets button {
min-height: 38px;
border-radius: var(--radius);
text-align: left;
padding: 8px 10px;
font-size: 12px;
line-height: 1.2;
}
.photo-presets button span {
display: block;
margin-top: 3px;
font-size: 11px;
color: var(--muted);
}
.photo-note {
border: 1px solid rgba(15, 118, 110, 0.16);
border-radius: var(--radius);
background: rgba(15, 118, 110, 0.06);
padding: 10px 12px;
font-size: 12px;
line-height: 1.4;
color: var(--ink);
}
.model-library {
grid-column: span 12;
display: grid;
gap: 12px;
}
.model-library-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.model-card {
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
padding: 12px;
display: grid;
gap: 10px;
min-width: 0;
}
.model-card.active {
border-color: rgba(15, 118, 110, 0.45);
box-shadow: 0 10px 22px rgba(15, 118, 110, 0.10);
}
.model-card h3 {
margin: 0;
font-size: 14px;
line-height: 1.25;
}
.model-card p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.model-card .meta-row span:last-child {
font-size: 11px;
}
.model-card-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.model-card-actions button {
min-height: 34px;
border-radius: var(--radius);
font-size: 12px;
}
.main {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
overflow: hidden;
background:
linear-gradient(180deg, rgba(255,255,255,0.4), rgba(255,255,255,0) 180px),
var(--bg);
}
.topbar {
border-bottom: 1px solid var(--line);
padding: 16px 20px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: rgba(243, 239, 230, 0.86);
backdrop-filter: blur(12px);
}
.topbar-title {
min-width: 0;
}
.topbar-title strong {
display: block;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-title span {
color: var(--muted);
font-size: 12px;
font-family: var(--mono);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.main-page-nav {
border-bottom: 1px solid var(--line);
background: rgba(255, 253, 250, 0.72);
padding: 0 20px;
display: flex;
align-items: end;
gap: 18px;
overflow-x: auto;
overflow-y: hidden;
}
.main-page-nav button {
min-height: 48px;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
text-align: left;
padding: 13px 2px 11px;
font-size: 13px;
line-height: 1.1;
font-weight: 700;
white-space: nowrap;
background: transparent;
box-shadow: none;
}
.main-page-nav button:hover {
box-shadow: none;
color: var(--accent-strong);
}
.main-page-nav button.active {
color: var(--accent-strong);
border-bottom-color: var(--accent);
background: transparent;
box-shadow: none;
}
.send-button {
min-width: 116px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff;
border-color: var(--ink);
font-weight: 700;
}
.send-button.hidden {
display: none;
}
.page-screen {
min-height: 0;
}
.page-screen[hidden] {
display: none !important;
}
.overview {
min-height: 0;
overflow: auto;
padding: 24px 20px 28px;
align-content: start;
gap: 18px;
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.overview-card {
border: 1px solid rgba(169, 153, 127, 0.24);
border-radius: 18px;
background: rgba(255, 253, 250, 0.9);
box-shadow: var(--shadow-soft);
padding: 18px;
display: grid;
gap: 12px;
}
.overview-card h2 {
margin: 0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.overview-hero {
grid-column: span 8;
background:
radial-gradient(circle at top right, rgba(230, 184, 120, 0.16), transparent 28%),
linear-gradient(135deg, rgba(25, 33, 38, 0.98), rgba(18, 78, 74, 0.94)),
var(--panel);
color: #f6f2e9;
border-color: rgba(32, 49, 47, 0.7);
min-height: 220px;
align-content: start;
}
.overview-hero h2,
.overview-hero .overview-note {
color: rgba(246, 242, 233, 0.78);
}
.overview-hero strong {
font-size: 34px;
line-height: 1.05;
max-width: 12ch;
letter-spacing: -0.04em;
}
.overview-hero .overview-route {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-family: var(--mono);
font-size: 11px;
color: rgba(246, 242, 233, 0.92);
}
.overview-route div {
padding: 7px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
}
.overview-runtime {
grid-column: span 4;
min-height: 220px;
}
.overview-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.overview-stat {
border: 1px solid rgba(212, 201, 182, 0.82);
border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,0.86), rgba(244, 238, 226, 0.72));
padding: 12px;
display: grid;
gap: 4px;
}
.overview-stat strong {
font-size: 28px;
line-height: 1;
}
.overview-stat span {
font-size: 12px;
color: var(--muted);
}
.overview-runtime-list,
.overview-model-list {
display: grid;
gap: 8px;
font-size: 12px;
}
.overview-runtime-list div,
.overview-model-list div {
display: grid;
grid-template-columns: minmax(0, 124px) minmax(0, 1fr);
justify-content: space-between;
gap: 10px;
align-items: start;
padding-top: 8px;
border-top: 1px solid rgba(212, 201, 182, 0.58);
}
.overview-runtime-list div:first-child,
.overview-model-list div:first-child {
border-top: 0;
padding-top: 0;
}
.overview-runtime-list span:first-child,
.overview-model-list span:first-child {
color: var(--muted);
text-transform: uppercase;
font-size: 11px;
}
.overview-runtime-list span:last-child,
.overview-model-list span:last-child {
text-align: right;
font-family: var(--mono);
overflow-wrap: anywhere;
}
.overview-model {
grid-column: span 4;
}
.overview-services {
grid-column: span 8;
}
.service-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.service-tile {
border: 1px solid rgba(212, 201, 182, 0.82);
border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,0.94), rgba(244, 238, 226, 0.86));
padding: 12px;
display: grid;
gap: 8px;
}
.service-tile-head {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.service-tile-head strong {
font-size: 13px;
line-height: 1.2;
}
.service-badge {
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--line);
font-size: 11px;
background: rgba(232, 225, 211, 0.45);
white-space: nowrap;
}
.service-badge.online,
.service-badge.ok {
color: var(--ok);
border-color: rgba(22, 101, 52, 0.3);
}
.service-badge.ready_to_start {
color: var(--accent-2);
border-color: rgba(154, 90, 23, 0.26);
background: rgba(154, 90, 23, 0.08);
}
.service-badge.blocked,
.service-badge.error {
color: var(--danger);
border-color: rgba(180, 35, 24, 0.28);
}
.service-tile p {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.35;
}
.service-tile code {
font-family: var(--mono);
font-size: 11px;
color: var(--ink);
overflow-wrap: anywhere;
}
.overview-note {
font-size: 12px;
color: var(--muted);
line-height: 1.4;
}
.conversation-page {
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
}
.chat {
min-height: 0;
overflow: auto;
padding: 22px 18px;
display: flex;
flex-direction: column;
gap: 14px;
overscroll-behavior: contain;
}
.message {
max-width: min(880px, 92%);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
box-shadow: 0 8px 18px rgba(42, 36, 27, 0.06);
overflow: hidden;
}
.message.user {
align-self: flex-end;
border-color: rgba(15, 118, 110, 0.32);
}
.message.assistant {
align-self: flex-start;
}
.message.error {
border-color: rgba(180, 35, 24, 0.35);
}
.message-head {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
background: rgba(236, 231, 220, 0.55);
}
.message-body {
padding: 12px;
white-space: pre-wrap;
line-height: 1.48;
overflow-wrap: anywhere;
}
.message-foot {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--line);
background: rgba(236, 231, 220, 0.38);
}
.message-foot button {
min-height: 28px;
padding: 4px 8px;
border-radius: var(--radius);
font-size: 12px;
}
.message-foot button.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.composer {
border-bottom: 1px solid var(--line);
padding: 14px 18px;
background: rgba(232, 225, 211, 0.58);
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: end;
}
.composer textarea {
min-height: 92px;
max-height: 220px;
resize: vertical;
line-height: 1.42;
}
.composer-actions {
display: grid;
gap: 8px;
}
.icon-button {
width: 42px;
height: 42px;
border-radius: var(--radius);
display: grid;
place-items: center;
font-weight: 800;
}
.loading {
animation: pulse 1s ease-in-out infinite alternate;
}
@keyframes pulse {
from { opacity: 0.62; }
to { opacity: 1; }
}
@media (max-width: 860px) {
body { overflow: auto; }
.app {
height: auto;
min-height: 100vh;
grid-template-columns: 1fr;
overflow: visible;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
max-height: none;
}
.main {
min-height: 70vh;
overflow: visible;
}
.topbar { height: auto; align-items: flex-start; }
.topbar-actions {
width: 100%;
justify-content: flex-end;
}
.composer { grid-template-columns: 1fr; }
.composer-actions { grid-template-columns: 1fr 1fr; }
.send-button { width: 100%; }
.page-nav {
display: none;
}
.main-page-nav {
gap: 14px;
padding: 0 16px;
}
.settings-grid {
grid-template-columns: 1fr;
}
.control-overview,
.control-section-body {
grid-template-columns: 1fr;
}
.overview {
grid-template-columns: 1fr;
}
.overview-hero,
.overview-runtime,
.overview-model,
.overview-services {
grid-column: auto;
}
.service-grid {
grid-template-columns: 1fr;
}
.model-library-grid,
.photo-quick-actions,
.photo-console-grid,
.photo-presets {
grid-template-columns: 1fr;
}
.photo-console-grid > .field,
.photo-console-grid > .model-meta,
.photo-console-grid > .photo-note {
grid-column: auto;
}
}
</style>
</head>
<body>
<main class="app">
<aside class="sidebar">
<div class="brand">
<h1>Model Chat Testbench</h1>
<div class="status" id="status">init</div>
</div>
<nav class="page-nav" id="pageNav">
<button type="button" data-page-id="overview">Обзор<span>Статус и маршруты</span></button>
<button type="button" data-page-id="workspace">Рабочее место<span>Endpoint и сервис</span></button>
<button type="button" data-page-id="chat">Текст<span>Чат и промпты</span></button>
<button type="button" data-page-id="rag">1С / RAG<span>Контекст и ответы</span></button>
<button type="button" data-page-id="photo">Фото<span>Генерация и edit</span></button>
<button type="button" data-page-id="media">Медиа<span>Аудио и vision</span></button>
<button type="button" data-page-id="benchmarks">Тесты<span>Сравнение и quality</span></button>
<button type="button" data-page-id="models">Модели<span>Загрузка и реестр</span></button>
</nav>
<div class="settings-grid">
<section class="control-overview" data-pages="all">
<div class="health-panel" id="healthPanel" data-pages="all">
<div class="health-row"><span>host</span><span id="healthHost">-</span></div>
<div class="health-row"><span>GPU</span><span id="healthGpu">-</span></div>
<div class="health-row"><span>vLLM</span><span id="healthVllm">-</span></div>
<div class="health-row"><span>RAG</span><span id="healthRag">-</span></div>
<div class="health-row"><span>preflight</span><span id="healthPreflight">-</span></div>
<div class="health-row"><span>routes</span><span id="healthRoutes">-</span></div>
</div>
<div class="field" id="runtimeGroup" data-pages="all">
<label>Площадка</label>
<select id="runtimeProfile"></select>
<div class="profile-hint" id="runtimeHint"></div>
</div>
</section>
<section class="control-section" data-pages="workspace chat rag photo media benchmarks models">
<div class="control-section-head">
<div>
<h2>Маршрут и модель</h2>
<p>Сначала профиль и endpoint, затем плагин, модель и её текущий маршрут.</p>
</div>
</div>
<div class="control-section-body">
<div class="field" id="endpointGroup" data-pages="workspace chat rag photo media benchmarks">
<label>Endpoint</label>
<div class="inline-actions">
<select id="endpointPreset"></select>
<button id="checkEndpoint">Проверить</button>
</div>
<input id="baseUrl" spellcheck="false">
<div class="status" id="endpointStatus">not checked</div>
</div>
<div class="field" id="pluginGroup" data-pages="workspace chat rag photo media benchmarks models">
<label>Плагин</label>
<div class="plugin-tabs" id="pluginTabs"></div>
</div>
<div class="field" id="modelGroup" data-pages="workspace chat rag photo media benchmarks models">
<label>Модель</label>
<select id="modelSelect"></select>
</div>
<div class="model-meta" id="modelMeta" data-pages="workspace chat rag photo media benchmarks models"></div>
<div class="service-panel" id="serviceGroup" data-pages="workspace photo media benchmarks models">
<div class="service-status" id="serviceStatus">service: -</div>
<div class="profile-hint" id="profileHint"></div>
<div class="service-actions">
<button data-service-action="start">Start</button>
<button data-service-action="stop">Stop</button>
<button data-service-action="restart">Restart</button>
<button data-service-action="status">Status</button>
</div>
<div class="benchmark-actions">
<label class="benchmark-settings" for="benchmarkTokens">
<span>Benchmark tokens</span>
<input id="benchmarkTokens" type="number" min="64" max="1024" step="32" value="384">
</label>
<button id="runtimeBenchmark">Benchmark GPU / CPU</button>
</div>
<div class="benchmark-history" id="benchmarkHistory">benchmark history: -</div>
</div>
</div>
</section>
<section class="control-section" data-pages="chat rag benchmarks">
<div class="control-section-head">
<div>
<h2>Промпт и проверки</h2>
<p>Параметры ответа, системный промпт и быстрые сценарии проверки собраны рядом.</p>
</div>
</div>
<div class="control-section-body">
<div class="field" id="ragProfileField" data-pages="rag">
<label>RAG profile</label>
<select id="ragProfile"></select>
</div>
<div class="params" id="paramsGroup" data-pages="chat rag benchmarks">
<div class="field">
<label>Temperature</label>
<input id="temperature" type="number" min="0" max="2" step="0.1" value="0.2">
</div>
<div class="field">
<label>Max tokens</label>
<input id="maxTokens" type="number" min="16" max="8192" step="16" value="1000">
</div>
</div>
<div class="field" id="systemPromptGroup" data-pages="chat rag">
<label>System prompt</label>
<textarea id="systemPrompt">Отвечай по-русски, кратко и по делу. Если данных не хватает, скажи что нужно проверить.</textarea>
</div>
<div class="field" id="quickChecksGroup" data-pages="chat rag benchmarks">
<label>Быстрые проверки</label>
<select id="testSelect"></select>
<div class="prompt-buttons">
<button data-prompt="default">По плагину</button>
<button data-prompt="sanity">Sanity</button>
<button data-prompt="compare">Критика</button>
<button data-prompt="json">JSON</button>
</div>
</div>
<div class="utility" id="utilityGroup" data-pages="chat rag benchmarks">
<button id="clearChat">Очистить</button>
<button id="reloadCatalog">Обновить</button>
<button id="routeTask">Маршрут</button>
<button id="compareRun">Сравнить</button>
<button id="qualityRun">Quality</button>
<button id="ragPrompt">1C RAG</button>
<button id="ragAnswer">RAG ответ</button>
</div>
<div class="field" id="compareGroup" data-pages="benchmarks">
<label>Сравнение</label>
<div class="check-list" id="compareModels"></div>
</div>
</div>
</section>
<section class="photo-console" id="photoConsole" data-pages="photo models">
<div class="photo-console-head">
<div>
<h2>Фото-модели</h2>
<p>Отдельный блок для выбора image-модели, запуска endpoint и быстрого переключения между реализмом, аниме и редактированием.</p>
</div>
<div class="status" id="photoConsoleStatus">image: -</div>
</div>
<div class="photo-console-grid">
<div class="field">
<label>Быстрый выбор</label>
<div class="photo-quick-actions">
<button type="button" data-photo-preset="realvisxl-v5_0">Реализм</button>
<button type="button" data-photo-preset="animagine-xl-4_0">Аниме</button>
<button type="button" data-photo-preset="illustriousxl">Иллюстрация</button>
<button type="button" data-photo-preset="sdxl-inpainting-1.0">Редактирование</button>
</div>
<div class="photo-service-actions">
<button type="button" id="photoStartModel">Start</button>
<button type="button" id="photoCheckEndpoint">Проверить</button>
<button type="button" id="photoRefreshStatus">Обновить</button>
</div>
</div>
<div class="model-meta" id="photoModelMeta"></div>
<div class="photo-note" id="photoConsoleNote">Для генерации фото сначала выберите image-модель и нажмите Start. Если модель уже на диске, но endpoint не запущен, здесь это будет видно сразу.</div>
</div>
</section>
<section class="control-section" data-pages="media">
<div class="control-section-head">
<div>
<h2>Медиа-инструменты</h2>
<p>Аудио и vision отделены от фото, чтобы рабочие сценарии не смешивались.</p>
</div>
</div>
<div class="control-section-body">
<div class="plugin-tool" id="audioTool" data-pages="media">
<div class="field">
<label>Аудиофайл</label>
<input id="audioFile" type="file" accept="audio/*,.wav,.mp3,.m4a,.ogg,.flac">
</div>
<div class="params">
<div class="field">
<label>Language</label>
<select id="audioLanguage">
<option value="">auto</option>
<option value="russian">russian</option>
<option value="english">english</option>
</select>
</div>
<div class="field">
<label>Task</label>
<select id="audioTask">
<option value="transcribe">transcribe</option>
<option value="translate">translate</option>
</select>
</div>
</div>
</div>
<div class="plugin-tool" id="videoTool" data-pages="media">
<div class="field">
<label>Изображение</label>
<input id="imageFile" type="file" accept="image/*,.png,.jpg,.jpeg,.webp">
</div>
</div>
</div>
</section>
<section class="control-section" data-pages="photo">
<div class="control-section-head">
<div>
<h2>Фото workflow</h2>
<p>Пресеты, параметры генерации и галерея сведены в один рабочий контур.</p>
</div>
</div>
<div class="control-section-body">
<div class="plugin-tool" id="imageTool" data-pages="photo">
<div class="field">
<label>Готовые сценарии</label>
<div class="photo-presets">
<button type="button" data-photo-workflow="real-photo">Реалистичное фото<span>портреты, предметка</span></button>
<button type="button" data-photo-workflow="anime">Аниме<span>персонажи, clean line art</span></button>
<button type="button" data-photo-workflow="replace-object">Замена объекта<span>inpaint по маске</span></button>
<button type="button" data-photo-workflow="retouch-face">Ретушь лица<span>деликатный edit</span></button>
</div>
</div>
<div class="field">
<label>Image model</label>
<select id="imageModel"></select>
</div>
<div class="inline-warning" id="imageModelWarning"></div>
<div class="params">
<div class="field">
<label>Mode</label>
<select id="imageMode">
<option value="generate">generate</option>
<option value="edit">edit / inpaint</option>
</select>
</div>
<div class="field">
<label>Quality</label>
<select id="imageQuality">
<option value="balanced">balanced</option>
<option value="fast">fast</option>
<option value="quality">quality</option>
</select>
</div>
</div>
<div class="params">
<div class="field">
<label>Size</label>
<select id="imageSize">
<option value="512x512">512 x 512</option>
<option value="768x768">768 x 768</option>
<option value="1024x1024">1024 x 1024</option>
<option value="768x1024">768 x 1024</option>
<option value="1024x768">1024 x 768</option>
</select>
</div>
<div class="field">
<label>Guidance</label>
<input id="imageGuidance" type="number" min="1" max="15" step="0.5" value="6">
</div>
</div>
<div class="params">
<div class="field">
<label>Steps</label>
<input id="imageSteps" type="number" min="1" max="80" step="1" value="28">
</div>
<div class="field">
<label>Seed</label>
<input id="imageSeed" type="number" step="1" placeholder="auto">
</div>
</div>
<div class="field">
<label>Negative prompt</label>
<input id="imageNegative" spellcheck="false" placeholder="low quality, blurry, artifacts">
</div>
<div class="field">
<label>Исходное фото</label>
<input id="editImageFile" type="file" accept="image/*,.png,.jpg,.jpeg,.webp">
</div>
<div class="params">
<div class="field">
<label>Маска</label>
<input id="editMaskFile" type="file" accept="image/*,.png,.jpg,.jpeg,.webp">
</div>
<div class="field">
<label>Strength</label>
<input id="imageStrength" type="number" min="0" max="1" step="0.05" value="0.95">
</div>
</div>
<div class="mask-editor" id="maskEditor">
<div class="mask-stage" id="maskStage">
<canvas id="maskImageCanvas" width="512" height="512"></canvas>
<canvas id="maskPaintCanvas" width="512" height="512"></canvas>
</div>
<div class="params">
<div class="field">
<label>Brush</label>
<input id="maskBrush" type="range" min="8" max="120" step="1" value="42">
</div>
<div class="field">
<label>Mask mode</label>
<select id="maskMode">
<option value="paint">paint</option>
<option value="erase">erase</option>
</select>
</div>
</div>
<div class="mask-tools">
<button id="clearMask" type="button">Clear</button>
<button id="fillMask" type="button">Fill</button>
<button id="useCanvasMask" type="button">Use mask</button>
</div>
</div>
</div>
<div class="gallery-panel" id="galleryPanel" data-pages="photo">
<div class="gallery-actions">
<button id="refreshGallery">Галерея</button>
<button id="refreshImageJobs">Задачи</button>
<button id="repeatImage">Повторить</button>
</div>
<div class="image-jobs" id="imageJobs"></div>
<div class="gallery-grid" id="imageGallery"></div>
</div>
</div>
</section>
<section class="control-section" data-pages="models workspace photo">
<div class="control-section-head">
<div>
<h2>Реестр и загрузка</h2>
<p>Операции с моделями и библиотека image-моделей вынесены в отдельную группу.</p>
</div>
</div>
<div class="control-section-body">
<details class="ingest-panel" id="ingestGroup" data-pages="models workspace photo" open>
<summary>Модели и Реестр</summary>
<div class="field">
<label>ID / имя</label>
<input id="ingestModelId" placeholder="my-model-q4">
</div>
<div class="params">
<div class="field">
<label>Плагин</label>
<select id="ingestPlugin">
<option value="text">Текст</option>
<option value="translation">Перевод</option>
<option value="audio">Звук</option>
<option value="video">Видео</option>
<option value="image">Фото</option>
<option value="1c">1С</option>
</select>
</div>
<div class="field">
<label>Формат</label>
<input id="ingestFormat" placeholder="gguf">
</div>
</div>
<div class="field">
<label>Файл</label>
<input id="ingestFile" type="file">
</div>
<div class="field">
<label>URL или путь сервера</label>
<input id="ingestSource" spellcheck="false" placeholder="https://... или \\nas\...">
</div>
<div class="ingest-actions">
<button id="uploadModel">Загрузить</button>
<button id="importModel">Скачать</button>
<button id="verifyModel">Проверить</button>
<button id="registerModel">В реестр</button>
<button id="refreshJobs">Статусы</button>
</div>
<div class="status" id="ingestStatus">idle</div>
<div class="jobs-list" id="ingestJobs"></div>
</details>
<section class="model-library" id="modelLibrary" data-pages="models">
<div class="model-library-head">
<div>
<h2>Библиотека image-моделей</h2>
<p>Карточки показывают, что есть в реестре, что выбрано сейчас и что можно запустить для фото.</p>
</div>
</div>
<div class="model-library-grid" id="modelLibraryGrid"></div>
</section>
</div>
</section>
</div>
</aside>
<section class="main">
<header class="topbar">
<div class="topbar-title">
<strong id="chatTitle">Выберите модель</strong>
<span id="chatSubtitle">registry/index.json</span>
</div>
<div class="topbar-actions">
<button class="send-button" id="sendTop">Отправить</button>
</div>
</header>
<nav class="main-page-nav" id="mainPageNav">
<button type="button" data-page-id="overview">Обзор</button>
<button type="button" data-page-id="workspace">Рабочее место</button>
<button type="button" data-page-id="chat">Текст</button>
<button type="button" data-page-id="rag">1С / RAG</button>
<button type="button" data-page-id="photo">Фото</button>
<button type="button" data-page-id="media">Медиа</button>
<button type="button" data-page-id="benchmarks">Тесты</button>
<button type="button" data-page-id="models">Модели</button>
</nav>
<section class="overview page-screen" id="overviewPage">
<article class="overview-card overview-hero">
<h2>Обзор</h2>
<strong id="overviewHeroTitle">Загрузка состояния платформы</strong>
<div class="overview-route" id="overviewHeroRoute"></div>
<div class="overview-note" id="overviewHeroNote">Собираем статус endpoint'ов, моделей и сервисов.</div>
</article>
<article class="overview-card overview-runtime">
<h2>Сводка</h2>
<div class="overview-status-grid" id="overviewStats"></div>
<div class="overview-runtime-list" id="overviewRuntime"></div>
</article>
<article class="overview-card overview-model">
<h2>Текущая модель</h2>
<div class="overview-model-list" id="overviewCurrentModel"></div>
</article>
<article class="overview-card overview-services">
<h2>Что работает сейчас</h2>
<div class="service-grid" id="overviewServices"></div>
</article>
</section>
<section class="conversation-page page-screen" id="conversationPage">
<section class="composer">
<textarea id="prompt" placeholder="Введите проверочный запрос..."></textarea>
<div class="composer-actions">
<button class="send-button" id="send">Отправить</button>
<button class="icon-button" id="stop" disabled title="Запрос выполняется">...</button>
</div>
</section>
<section class="chat" id="chat"></section>
</section>
</section>
</main>
<script>
const state = {
catalog: null,
plugin: "text",
activePage: "overview",
runtimeProfileId: null,
selectedModelId: null,
selectedTestId: null,
messages: [],
ingestJobs: [],
benchmarkHistory: [],
imageGallery: [],
imageJobs: [],
selectedImageArtifact: null,
activeImageJob: null,
canvasMaskDataUrl: "",
health: null,
busy: false
};
const els = {
status: document.querySelector("#status"),
pageNav: document.querySelector("#pageNav"),
mainPageNav: document.querySelector("#mainPageNav"),
healthHost: document.querySelector("#healthHost"),
healthGpu: document.querySelector("#healthGpu"),
healthVllm: document.querySelector("#healthVllm"),
healthRag: document.querySelector("#healthRag"),
healthPreflight: document.querySelector("#healthPreflight"),
healthRoutes: document.querySelector("#healthRoutes"),
runtimeProfile: document.querySelector("#runtimeProfile"),
runtimeHint: document.querySelector("#runtimeHint"),
baseUrl: document.querySelector("#baseUrl"),
endpointPreset: document.querySelector("#endpointPreset"),
endpointStatus: document.querySelector("#endpointStatus"),
checkEndpoint: document.querySelector("#checkEndpoint"),
pluginTabs: document.querySelector("#pluginTabs"),
modelSelect: document.querySelector("#modelSelect"),
serviceStatus: document.querySelector("#serviceStatus"),
profileHint: document.querySelector("#profileHint"),
runtimeBenchmark: document.querySelector("#runtimeBenchmark"),
benchmarkTokens: document.querySelector("#benchmarkTokens"),
benchmarkHistory: document.querySelector("#benchmarkHistory"),
compareModels: document.querySelector("#compareModels"),
ragProfileField: document.querySelector("#ragProfileField"),
ragProfile: document.querySelector("#ragProfile"),
audioTool: document.querySelector("#audioTool"),
audioFile: document.querySelector("#audioFile"),
audioLanguage: document.querySelector("#audioLanguage"),
audioTask: document.querySelector("#audioTask"),
videoTool: document.querySelector("#videoTool"),
imageFile: document.querySelector("#imageFile"),
imageTool: document.querySelector("#imageTool"),
imageModel: document.querySelector("#imageModel"),
imageModelWarning: document.querySelector("#imageModelWarning"),
imageMode: document.querySelector("#imageMode"),
imageQuality: document.querySelector("#imageQuality"),
imageSize: document.querySelector("#imageSize"),
imageSteps: document.querySelector("#imageSteps"),
imageSeed: document.querySelector("#imageSeed"),
imageGuidance: document.querySelector("#imageGuidance"),
imageStrength: document.querySelector("#imageStrength"),
imageNegative: document.querySelector("#imageNegative"),
editImageFile: document.querySelector("#editImageFile"),
editMaskFile: document.querySelector("#editMaskFile"),
maskEditor: document.querySelector("#maskEditor"),
maskStage: document.querySelector("#maskStage"),
maskImageCanvas: document.querySelector("#maskImageCanvas"),
maskPaintCanvas: document.querySelector("#maskPaintCanvas"),
maskBrush: document.querySelector("#maskBrush"),
maskMode: document.querySelector("#maskMode"),
clearMask: document.querySelector("#clearMask"),
fillMask: document.querySelector("#fillMask"),
useCanvasMask: document.querySelector("#useCanvasMask"),
galleryPanel: document.querySelector("#galleryPanel"),
imageGallery: document.querySelector("#imageGallery"),
imageJobs: document.querySelector("#imageJobs"),
refreshGallery: document.querySelector("#refreshGallery"),
refreshImageJobs: document.querySelector("#refreshImageJobs"),
repeatImage: document.querySelector("#repeatImage"),
photoConsoleStatus: document.querySelector("#photoConsoleStatus"),
photoModelMeta: document.querySelector("#photoModelMeta"),
photoConsoleNote: document.querySelector("#photoConsoleNote"),
photoStartModel: document.querySelector("#photoStartModel"),
photoCheckEndpoint: document.querySelector("#photoCheckEndpoint"),
photoRefreshStatus: document.querySelector("#photoRefreshStatus"),
modelLibraryGrid: document.querySelector("#modelLibraryGrid"),
modelMeta: document.querySelector("#modelMeta"),
temperature: document.querySelector("#temperature"),
maxTokens: document.querySelector("#maxTokens"),
systemPrompt: document.querySelector("#systemPrompt"),
testSelect: document.querySelector("#testSelect"),
prompt: document.querySelector("#prompt"),
chat: document.querySelector("#chat"),
chatTitle: document.querySelector("#chatTitle"),
chatSubtitle: document.querySelector("#chatSubtitle"),
overviewPage: document.querySelector("#overviewPage"),
overviewHeroTitle: document.querySelector("#overviewHeroTitle"),
overviewHeroRoute: document.querySelector("#overviewHeroRoute"),
overviewHeroNote: document.querySelector("#overviewHeroNote"),
overviewStats: document.querySelector("#overviewStats"),
overviewRuntime: document.querySelector("#overviewRuntime"),
overviewCurrentModel: document.querySelector("#overviewCurrentModel"),
overviewServices: document.querySelector("#overviewServices"),
conversationPage: document.querySelector("#conversationPage"),
send: document.querySelector("#send"),
sendTop: document.querySelector("#sendTop"),
stop: document.querySelector("#stop"),
clearChat: document.querySelector("#clearChat"),
reloadCatalog: document.querySelector("#reloadCatalog"),
routeTask: document.querySelector("#routeTask"),
compareRun: document.querySelector("#compareRun"),
qualityRun: document.querySelector("#qualityRun"),
ragPrompt: document.querySelector("#ragPrompt")
};
els.ingestModelId = document.querySelector("#ingestModelId");
els.ingestPlugin = document.querySelector("#ingestPlugin");
els.ingestFormat = document.querySelector("#ingestFormat");
els.ingestFile = document.querySelector("#ingestFile");
els.ingestSource = document.querySelector("#ingestSource");
els.uploadModel = document.querySelector("#uploadModel");
els.importModel = document.querySelector("#importModel");
els.verifyModel = document.querySelector("#verifyModel");
els.registerModel = document.querySelector("#registerModel");
els.refreshJobs = document.querySelector("#refreshJobs");
els.ingestStatus = document.querySelector("#ingestStatus");
els.ingestJobs = document.querySelector("#ingestJobs");
els.ragAnswer = document.querySelector("#ragAnswer");
const PAGE_CONFIG = {
overview: {
label: "Обзор",
subtitle: "Что поднято, какая модель выбрана и какие сервисы сейчас активны."
},
workspace: {
label: "Рабочее место",
subtitle: "Маршрут модели, endpoint, runtime и управление сервисом."
},
chat: {
label: "Текстовый чат",
subtitle: "Обычный текстовый и code workflow без лишних панелей."
},
rag: {
label: "1С / RAG",
subtitle: "RAG-профиль, быстрые 1С проверки и ответ через контекст."
},
photo: {
label: "Фото",
subtitle: "Отдельный сценарий для генерации, inpaint и выбора image-моделей."
},
media: {
label: "Медиа",
subtitle: "Аудио и vision-инструменты отдельно от фото."
},
benchmarks: {
label: "Сравнение и quality",
subtitle: "Сравнение моделей, runtime benchmark и quality smoke."
},
models: {
label: "Реестр моделей",
subtitle: "Загрузка, импорт и регистрация моделей без шума от чата."
}
};
function setStatus(text, type = "") {
els.status.textContent = text;
els.status.className = `status ${type}`.trim();
}
function recommendedPageForPlugin(plugin = state.plugin) {
if (plugin === "1c") return "rag";
if (plugin === "image") return "photo";
if (["audio", "video"].includes(plugin)) return "media";
return "chat";
}
function pageHasConversation(page = state.activePage) {
return ["chat", "rag", "photo", "media", "benchmarks"].includes(page);
}
function pageAllowsDirectSend(page = state.activePage) {
return ["chat", "rag", "photo", "media"].includes(page);
}
function visibleOnPage(node, page = state.activePage) {
const raw = node?.dataset?.pages || "";
if (!raw) return true;
const pages = raw.split(/\s+/).filter(Boolean);
return pages.includes("all") || pages.includes(page);
}
function renderPageNav() {
document.querySelectorAll("[data-page-id]").forEach(button => {
if (!button.dataset.bound) {
button.addEventListener("click", () => setActivePage(button.dataset.pageId || "overview"));
button.dataset.bound = "1";
}
button.classList.toggle("active", button.dataset.pageId === state.activePage);
});
}
function setActivePage(pageId) {
if (!PAGE_CONFIG[pageId]) return;
let shouldRerenderAll = false;
state.activePage = pageId;
if ((pageId === "photo" || pageId === "models") && imageModels().length) {
state.plugin = "image";
if (!imageModels().some(model => model.id === state.selectedModelId)) {
state.selectedModelId = imageModels()[0]?.id || state.selectedModelId;
}
if (els.modelSelect && state.selectedModelId) els.modelSelect.value = state.selectedModelId;
if (els.imageModel && state.selectedModelId) els.imageModel.value = state.selectedModelId;
syncEndpointToSelectedModel();
shouldRerenderAll = true;
}
if (shouldRerenderAll) {
renderAll();
return;
}
renderPageLayout();
}
function renderPageLayout() {
document.querySelectorAll("[data-pages]").forEach(node => {
node.hidden = !visibleOnPage(node);
});
els.overviewPage.hidden = state.activePage !== "overview";
els.conversationPage.hidden = !pageHasConversation();
const page = PAGE_CONFIG[state.activePage] || PAGE_CONFIG.chat;
const model = currentModel();
if (state.activePage === "overview") {
els.chatTitle.textContent = page.label;
els.chatSubtitle.textContent = page.subtitle;
} else if (state.activePage === "workspace" || state.activePage === "photo" || state.activePage === "models") {
els.chatTitle.textContent = page.label;
els.chatSubtitle.textContent = model
? `${servedNameForModel(model)} · ${endpointForModel(model) || "-"}`
: page.subtitle;
} else if (state.activePage === "benchmarks") {
els.chatTitle.textContent = `${page.label}${model ? ` · ${model.name || model.id}` : ""}`;
els.chatSubtitle.textContent = "История результатов остаётся в чате, а настройки вынесены в отдельную страницу.";
} else {
els.chatTitle.textContent = model?.name || "Выберите модель";
els.chatSubtitle.textContent = model
? `${page.label} · ${servedNameForModel(model)} · ${model.runtime || "runtime?"}`
: page.subtitle;
}
const sendVisible = pageAllowsDirectSend();
els.sendTop.classList.toggle("hidden", !sendVisible);
els.send.classList.toggle("hidden", !sendVisible);
els.send.disabled = !sendVisible || state.busy;
els.sendTop.disabled = !sendVisible || state.busy;
renderPageNav();
renderOverview();
}
function currentModel() {
return (state.catalog?.models || []).find(model => model.id === state.selectedModelId);
}
function modelById(modelId) {
return (state.catalog?.models || []).find(model => model.id === modelId);
}
function activePlugin(model = currentModel()) {
if (state.plugin && (!model?.plugins?.length || model.plugins.includes(state.plugin))) {
return state.plugin;
}
return model?.plugins?.[0] || state.plugin || "text";
}
function runtimeProfiles() {
return state.catalog?.runtime_profiles?.profiles || {};
}
function currentRuntimeProfile() {
const profiles = runtimeProfiles();
const defaultId = state.catalog?.runtime_profiles?.default || Object.keys(profiles)[0] || "";
return profiles[state.runtimeProfileId] || profiles[defaultId] || null;
}
function currentRuntimeOverride(model = currentModel()) {
const profile = currentRuntimeProfile();
if (!model || !profile) return {};
return profile.model_overrides?.[model.id] || {};
}
function endpointForModel(model = currentModel()) {
const profile = currentRuntimeProfile();
const override = currentRuntimeOverride(model);
return override.base_url
|| profile?.endpoints?.[state.plugin]
|| currentServiceInfo()?.service?.base_url
|| "";
}
function servedNameForModel(model = currentModel()) {
return currentRuntimeOverride(model).served_model_name || model?.served_model_name || model?.id || "";
}
function imageModels() {
return modelsForPlugin("image");
}
function currentImageModel() {
const imageModelId = els.imageModel?.value || (state.plugin === "image" ? state.selectedModelId : "");
return modelById(imageModelId) || (state.plugin === "image" ? currentModel() : null);
}
function selectPhotoModel(modelId) {
const model = modelById(modelId);
if (!model) return;
state.plugin = "image";
state.selectedModelId = model.id;
if (els.modelSelect) els.modelSelect.value = model.id;
if (els.imageModel) els.imageModel.value = model.id;
if (state.activePage !== "models") state.activePage = "photo";
renderAll();
syncEndpointToSelectedModel();
}
function serviceInfoForModel(model) {
return state.health?.model_services?.find(item => item.id === model?.id) || null;
}
function currentServiceInfo() {
return serviceInfoForModel(currentModel());
}
function imageModelAvailability(model) {
if (!model) return { available: false, reason: "Выберите image model." };
const service = serviceInfoForModel(model);
const endpointModels = service?.endpoint_models || [];
const served = servedNameForModel(model);
if (endpointModels.length && served && !endpointModels.includes(served)) {
return {
available: false,
reason: `Endpoint сейчас обслуживает ${endpointModels.join(", ")}, а не ${served}.`
};
}
if (service && service.online === false) {
return {
available: false,
reason: "Модель есть на диске, но соответствующий endpoint не запущен."
};
}
return { available: true, reason: "" };
}
function imageModelStorageStatus(model) {
const service = serviceInfoForModel(model);
return service?.storage || { exists: false, status: "missing", reason: "storage path is missing" };
}
function firstAvailableImageModel(candidates = []) {
for (const modelId of candidates) {
const model = modelById(modelId);
if (!model) continue;
const storage = imageModelStorageStatus(model);
if (storage.exists) return model;
}
return candidates.length ? modelById(candidates[0]) : null;
}
function modelLoadSummary(serviceInfo) {
const health = serviceInfo?.service_health || {};
const loadState = health.load_state || {};
const key = state.plugin === "image" && (currentModel()?.tasks || []).includes("image-editing") ? "image_edit" : state.plugin;
const stateItem = loadState[key] || loadState.image || null;
if (!stateItem?.status) return "";
const latency = stateItem.latency_ms ? ` · ${Math.round(stateItem.latency_ms / 1000)}s` : "";
return `${stateItem.status}${latency}`;
}
function gpuSummary() {
const gpus = state.health?.gpu?.gpus || [];
if (!gpus.length) return "gpu: unavailable";
return gpus.map(gpu => {
const free = gpu.memory_free_mib ?? ((gpu.memory_total_mib || 0) - (gpu.memory_used_mib || 0));
return `GPU${gpu.index} ${free}/${gpu.memory_total_mib} MiB free`;
}).join(" · ");
}
function profileForPlugin(plugin) {
const profileByPlugin = {
text: "text",
"1c": "gguf-1c",
translation: "default",
audio: "audio",
video: "video",
image: "image"
};
const profileId = profileByPlugin[plugin] || "default";
return state.catalog?.gpu_profiles?.[profileId] || null;
}
function profileRuntimeStatus(profile) {
const status = state.health?.gpu_profile_status?.[profile.id];
if (!status) return "status: unknown";
if (status.ready) return "status: ready";
const parts = [];
if (status.missing?.length) parts.push(`start: ${status.missing.join(", ")}`);
if (status.conflicts?.length) parts.push(`stop: ${status.conflicts.join(", ")}`);
return parts.join(" · ") || "status: pending";
}
function preflightSummary(preflight) {
if (!preflight) return "-";
if (preflight.status === "missing") return "missing";
const when = preflight.created_at ? new Date(preflight.created_at).toLocaleString("ru-RU") : "-";
const failed = preflight.failed_steps?.length ? ` · failed: ${preflight.failed_steps.join(", ")}` : "";
return `${preflight.status} · ${when} · ${preflight.step_count || 0} steps${failed}`;
}
function renderOverview() {
const model = currentModel();
const service = currentServiceInfo();
const profile = currentRuntimeProfile();
const plugin = state.plugin || activePlugin(model);
const services = state.health?.model_services || [];
const online = services.filter(item => item.status === "online").length;
const ready = services.filter(item => item.status === "ready_to_start").length;
const blocked = services.filter(item => item.status === "blocked").length;
const loaded = services.filter(item => modelLoadSummary(item)).length;
const focusStatus = blocked
? `${blocked} blocked`
: online
? `${online} online`
: ready
? `${ready} ready to start`
: "ожидает запуска";
els.overviewHeroTitle.textContent = model
? `${model.name || model.id}`
: "Каталог моделей не загружен";
els.overviewHeroRoute.innerHTML = "";
[
`page: ${state.activePage}`,
`plugin: ${plugin || "-"}`,
`served: ${servedNameForModel(model) || "-"}`,
`endpoint: ${endpointForModel(model) || "-"}`,
`status: ${service?.status || focusStatus}`
].forEach(line => {
const row = document.createElement("div");
row.textContent = line;
els.overviewHeroRoute.appendChild(row);
});
els.overviewHeroNote.textContent = profile
? `Профиль ${profile.label} на ${profile.host || "-"}; наверху показан активный маршрут, ниже видно, что уже online, что просто готово к запуску и где есть блокеры.`
: "Выберите runtime profile, чтобы увидеть маршрут модели и активные сервисы.";
els.overviewStats.innerHTML = [
["online", online],
["ready to start", ready],
["blocked", blocked],
["loaded", loaded]
].map(([label, value]) => (
`<div class="overview-stat"><strong>${value}</strong><span>${label}</span></div>`
)).join("");
els.overviewRuntime.innerHTML = [
["Профиль", profile?.label || "-"],
["Хост", profile?.host || state.health?.host || "-"],
["GPU", state.health?.gpu?.summary || "unavailable"],
["Маршрут", plugin || "-"],
["Preflight", preflightSummary(state.health?.model_chat_preflight)],
["RAG", state.health?.rag_1c_index?.exists ? "index ready" : "index missing"]
].map(([key, value]) => `<div><span>${escapeHtml(key)}</span><span>${escapeHtml(value)}</span></div>`).join("");
els.overviewCurrentModel.innerHTML = model
? [
["Модель", model.name || model.id],
["Served name", servedNameForModel(model)],
["Runtime", model.runtime || "-"],
["Статус", model.status || "-"],
["Endpoint", endpointForModel(model) || "-"],
["Сервис", service?.status || "-"],
["Загрузка", modelLoadSummary(service) || "not loaded"],
["Задачи", (model.tasks || []).join(", ") || "-"]
].map(([key, value]) => `<div><span>${escapeHtml(key)}</span><span>${escapeHtml(value)}</span></div>`).join("")
: "<div class=\"overview-note\">Выберите плагин и модель, чтобы увидеть текущий маршрут.</div>";
els.overviewServices.innerHTML = "";
if (!services.length) {
els.overviewServices.innerHTML = "<div class=\"overview-note\">Данных о сервисах пока нет.</div>";
return;
}
for (const item of services) {
const card = document.createElement("article");
card.className = "service-tile";
const statusClass = String(item.status || "").replace(/[^a-z-]/g, "");
const endpointModels = (item.endpoint_models || []).join(", ");
const load = modelLoadSummary(item) || "not loaded";
card.innerHTML = `
<div class="service-tile-head">
<strong>${escapeHtml(item.name || item.id || "service")}</strong>
<span class="service-badge ${statusClass}">${escapeHtml(item.status || "-")}</span>
</div>
<p>${escapeHtml(item.service?.base_url || item.service_health?.base_url || "endpoint missing")}</p>
<code>served: ${escapeHtml(endpointModels || item.id || "-")}</code>
<code>storage: ${escapeHtml(item.storage?.status || "-")} · load: ${escapeHtml(load)}</code>
`;
els.overviewServices.appendChild(card);
}
}
function renderProfileHint(serviceStatus = "-") {
const profile = profileForPlugin(state.plugin);
if (!profile) {
els.profileHint.textContent = "";
return;
}
const coldStart = state.plugin === "video" && serviceStatus !== "online"
? " Сначала включите профиль video; первая загрузка Qwen2.5-VL может занять несколько минут."
: state.plugin === "image" && serviceStatus !== "online"
? " Сначала включите профиль image; первая генерация SDXL может занять несколько минут."
: "";
const runtimeStatus = profileRuntimeStatus(profile);
els.profileHint.innerHTML = `GPU profile: ${escapeHtml(profile.label)} · ${escapeHtml(runtimeStatus)}.${escapeHtml(coldStart)}<code>${escapeHtml(profile.command)}</code>`;
}
function syncEndpointToSelectedModel() {
const fallbackEndpoints = {
text: "http://docker-gpu.cin.su:8000",
"1c": "http://docker-gpu.cin.su:8000",
translation: "http://docker-gpu.cin.su:8010",
audio: "http://docker-gpu.cin.su:8020",
video: "http://docker-gpu.cin.su:8030",
image: "http://docker-gpu.cin.su:8040"
};
const serviceBaseUrl = endpointForModel() || fallbackEndpoints[state.plugin];
if (!serviceBaseUrl) return;
const presetValues = new Set([...els.endpointPreset.options].map(option => option.value).filter(Boolean));
if (els.baseUrl.value && !presetValues.has(els.baseUrl.value)) return;
els.baseUrl.value = serviceBaseUrl;
const matchingPreset = [...els.endpointPreset.options].find(option => option.value === serviceBaseUrl);
els.endpointPreset.value = matchingPreset ? serviceBaseUrl : "";
els.endpointStatus.textContent = "not checked";
els.endpointStatus.className = "status";
}
function modelsForPlugin(plugin) {
const modelById = new Map((state.catalog?.models || []).map(model => [model.id, model]));
const pluginEntry = (state.catalog?.plugins || []).find(item => item.id === plugin);
if (pluginEntry?.models?.length) {
return pluginEntry.models.map(id => modelById.get(id)).filter(Boolean);
}
return (state.catalog?.models || []).filter(model => model.plugins.includes(plugin));
}
function testsForPlugin(plugin) {
return state.catalog?.test_packs?.[plugin] || [];
}
function currentTest() {
return testsForPlugin(state.plugin).find(test => test.id === state.selectedTestId);
}
function renderEndpointPresets() {
els.endpointPreset.innerHTML = "";
for (const preset of state.catalog.endpoint_presets || []) {
const option = document.createElement("option");
option.value = preset.base_url;
option.textContent = preset.label;
els.endpointPreset.appendChild(option);
}
const custom = document.createElement("option");
custom.value = "";
custom.textContent = "custom";
els.endpointPreset.appendChild(custom);
}
function renderRuntimeProfiles() {
els.runtimeProfile.innerHTML = "";
const profiles = runtimeProfiles();
const defaultId = state.catalog?.runtime_profiles?.default || Object.keys(profiles)[0] || "";
if (!state.runtimeProfileId || !profiles[state.runtimeProfileId]) {
state.runtimeProfileId = defaultId;
}
for (const profile of Object.values(profiles)) {
const option = document.createElement("option");
option.value = profile.id;
option.textContent = `${profile.label}${profile.role ? ` · ${profile.role}` : ""}`;
els.runtimeProfile.appendChild(option);
}
els.runtimeProfile.value = state.runtimeProfileId || "";
renderRuntimeHint();
}
function renderRuntimeHint() {
const profile = currentRuntimeProfile();
const model = currentModel();
const override = currentRuntimeOverride(model);
if (!profile) {
els.runtimeHint.textContent = "";
return;
}
const endpoint = endpointForModel(model) || "-";
const served = servedNameForModel(model) || "-";
const startHint = override.start_hint ? `<code>${escapeHtml(override.start_hint)}</code>` : "";
els.runtimeHint.innerHTML = `${escapeHtml(profile.host || profile.id)} · ${escapeHtml(endpoint)} · model=${escapeHtml(served)}${startHint}`;
}
function renderPlugins() {
els.pluginTabs.innerHTML = "";
for (const plugin of state.catalog.plugins) {
const button = document.createElement("button");
button.textContent = plugin.label;
button.className = plugin.id === state.plugin ? "active" : "";
button.addEventListener("click", () => {
state.plugin = plugin.id;
if (state.activePage !== "overview" && state.activePage !== "workspace" && state.activePage !== "models") {
state.activePage = recommendedPageForPlugin(plugin.id);
}
const models = modelsForPlugin(plugin.id);
state.selectedModelId = models[0]?.id || null;
state.selectedTestId = testsForPlugin(plugin.id)[0]?.id || null;
renderAll();
syncEndpointToSelectedModel();
loadBenchmarkHistory().catch(() => {});
fillDefaultPrompt("selected");
});
els.pluginTabs.appendChild(button);
}
}
function renderModels() {
const models = modelsForPlugin(state.plugin);
els.modelSelect.innerHTML = "";
for (const model of models) {
const option = document.createElement("option");
option.value = model.id;
option.textContent = `${model.name || model.id} [${model.status}]`;
els.modelSelect.appendChild(option);
}
if (!state.selectedModelId && models.length) state.selectedModelId = models[0].id;
els.modelSelect.value = state.selectedModelId || "";
}
function renderImageModels() {
if (!els.imageModel) return;
const models = imageModels();
els.imageModel.innerHTML = "";
for (const model of models) {
const option = document.createElement("option");
option.value = model.id;
option.textContent = `${model.name || model.id} [${model.status}]`;
els.imageModel.appendChild(option);
}
if (state.plugin === "image" && !models.some(model => model.id === state.selectedModelId)) {
state.selectedModelId = models[0]?.id || state.selectedModelId;
}
els.imageModel.value = state.plugin === "image" ? (state.selectedModelId || "") : (models[0]?.id || "");
renderImageModelWarning();
}
function renderImageModelWarning() {
if (!els.imageModelWarning) return;
const model = currentImageModel();
const availability = imageModelAvailability(model);
const isQwen = model?.id === "qwen-image-edit";
const warnings = [];
if (!availability.available) warnings.push(availability.reason);
if (isQwen) {
warnings.push("Qwen Image Edit экспериментален: на RTX 4090 512x512 edit 1 step не завершился за 1800s.");
}
els.imageModelWarning.textContent = warnings.join(" ");
els.imageModelWarning.classList.toggle("active", state.plugin === "image" && warnings.length > 0);
}
function renderPhotoConsole() {
if (!els.photoModelMeta || !els.photoConsoleStatus || !els.photoConsoleNote) return;
const model = currentImageModel();
const availability = imageModelAvailability(model);
const serviceInfo = serviceInfoForModel(model);
const loadSummary = modelLoadSummary(serviceInfo);
if (els.photoStartModel) els.photoStartModel.disabled = !model || state.busy;
if (els.photoCheckEndpoint) els.photoCheckEndpoint.disabled = !model || state.busy;
if (els.photoRefreshStatus) els.photoRefreshStatus.disabled = state.busy;
if (!model) {
els.photoConsoleStatus.textContent = "image: нет модели";
els.photoConsoleStatus.className = "status err";
els.photoModelMeta.innerHTML = "<div class=\"meta-row\"><span>Фото-модель</span><span>не выбрана</span></div>";
els.photoConsoleNote.textContent = "В каталоге пока нет image-моделей или они не привязаны к плагину image.";
return;
}
els.photoConsoleStatus.textContent = availability.available ? "image: готово" : "image: требуется запуск";
els.photoConsoleStatus.className = `status ${availability.available ? "ok" : "err"}`;
els.photoModelMeta.innerHTML = [
["model", model.name || model.id],
["id", model.id],
["status", model.status || "-"],
["service", serviceInfo?.status || "-"],
...(loadSummary ? [["load", loadSummary]] : []),
["endpoint", endpointForModel(model) || serviceInfo?.service?.base_url || "-"],
["tasks", (model.tasks || []).join(", ") || "-"]
].map(([key, value]) => `<div class="meta-row"><span>${key}</span><span>${escapeHtml(value)}</span></div>`).join("");
const tips = [];
if (!availability.available) tips.push(availability.reason);
if (model.id === "realvisxl-v5_0") tips.push("Лучший выбор для реалистичного фото.");
if (model.id === "animagine-xl-4_0") tips.push("Лучший быстрый вариант для аниме на 24 GB VRAM.");
if (model.id === "illustriousxl") tips.push("Подходит для стилизованной иллюстрации и арта.");
if (model.id === "sdxl-inpainting-1.0") tips.push("Используйте для редактирования, замены объектов и маски.");
if (state.health?.service_control?.available === false) {
tips.push(`Прямое API-управление недоступно, используйте ${state.health?.service_control?.fallback || "scripts/manage_model_service.ps1"}.`);
}
els.photoConsoleNote.textContent = tips.join(" ") || "Модель выбрана и готова к работе.";
}
function renderModelLibrary() {
if (!els.modelLibraryGrid) return;
const models = imageModels();
els.modelLibraryGrid.innerHTML = "";
if (!models.length) {
els.modelLibraryGrid.innerHTML = "<div class=\"photo-note\">В реестре пока нет image-моделей.</div>";
return;
}
for (const model of models) {
const availability = imageModelAvailability(model);
const serviceInfo = serviceInfoForModel(model);
const storage = imageModelStorageStatus(model);
const card = document.createElement("article");
card.className = `model-card${model.id === currentImageModel()?.id ? " active" : ""}`;
card.innerHTML = `
<div>
<h3>${escapeHtml(model.name || model.id)}</h3>
<p>${escapeHtml((model.tags || []).slice(0, 4).join(", ") || model.id)}</p>
</div>
<div class="meta-row"><span>id</span><span>${escapeHtml(model.id)}</span></div>
<div class="meta-row"><span>status</span><span>${escapeHtml(model.status || "-")}</span></div>
<div class="meta-row"><span>storage</span><span>${escapeHtml(storage.exists ? "ok" : "missing")}</span></div>
<div class="meta-row"><span>service</span><span>${escapeHtml(serviceInfo?.status || "-")}</span></div>
<div class="meta-row"><span>endpoint</span><span>${escapeHtml(endpointForModel(model) || serviceInfo?.service?.base_url || "-")}</span></div>
<div class="meta-row"><span>tasks</span><span>${escapeHtml((model.tasks || []).join(", ") || "-")}</span></div>
<p>${escapeHtml(storage.exists ? (availability.available ? "Готова к работе." : availability.reason) : `Нужно загрузить модель: ${storage.reason || "storage path is missing"}`)}</p>
<div class="model-card-actions">
<button type="button" data-library-model="${escapeHtml(model.id)}">Открыть фото</button>
<button type="button" data-library-start="${escapeHtml(model.id)}">Start</button>
</div>
`;
els.modelLibraryGrid.appendChild(card);
}
document.querySelectorAll("[data-library-model]").forEach(button => {
if (button.dataset.bound) return;
button.addEventListener("click", async () => {
const modelId = button.dataset.libraryModel || "";
selectPhotoModel(modelId);
const model = modelById(modelId);
const storage = imageModelStorageStatus(model);
if (!storage.exists) {
state.messages.push({
role: "assistant",
content: `${model?.name || modelId}: модель еще не загружена на сервер (${storage.reason || "storage path is missing"}).`,
time: now(),
error: true
});
renderMessages();
return;
}
const availability = imageModelAvailability(model);
if (!availability.available) {
await controlService("start");
}
});
button.dataset.bound = "1";
});
document.querySelectorAll("[data-library-start]").forEach(button => {
if (button.dataset.bound) return;
button.addEventListener("click", async () => {
selectPhotoModel(button.dataset.libraryStart || "");
await controlService("start");
});
button.dataset.bound = "1";
});
}
function renderTests() {
const tests = testsForPlugin(state.plugin);
els.testSelect.innerHTML = "";
for (const test of tests) {
const option = document.createElement("option");
option.value = test.id;
option.textContent = test.label;
els.testSelect.appendChild(option);
}
if (!state.selectedTestId && tests.length) state.selectedTestId = tests[0].id;
els.testSelect.value = state.selectedTestId || "";
}
function renderCompareModels() {
const models = modelsForPlugin(state.plugin);
els.compareModels.innerHTML = "";
for (const [index, model] of models.entries()) {
const id = `cmp-${model.id}`;
const label = document.createElement("label");
label.className = "check-row";
const input = document.createElement("input");
input.type = "checkbox";
input.value = model.id;
input.checked = index < 3;
input.id = id;
const text = document.createElement("span");
text.textContent = `${model.name || model.id} · ${servedNameForModel(model)}`;
label.append(input, text);
els.compareModels.appendChild(label);
}
}
function renderRagProfiles() {
const profiles = state.catalog?.rag_profiles?.[state.plugin] || [];
els.ragProfile.innerHTML = "";
els.ragProfileField.style.display = profiles.length ? "grid" : "none";
for (const profile of profiles) {
const option = document.createElement("option");
option.value = profile.id;
option.textContent = profile.label || profile.id;
option.title = profile.description || "";
els.ragProfile.appendChild(option);
}
if (profiles.some(profile => profile.id === "auto")) {
els.ragProfile.value = "auto";
}
}
function renderPluginTools() {
els.audioTool.classList.toggle("active", state.plugin === "audio");
els.videoTool.classList.toggle("active", state.plugin === "video");
els.imageTool.classList.toggle("active", state.plugin === "image");
els.galleryPanel.style.display = state.activePage === "photo" && state.plugin === "image" ? "grid" : "none";
}
function renderMeta() {
const model = currentModel();
const controlAvailable = Boolean(state.health?.service_control?.available);
document.querySelectorAll("[data-service-action]").forEach(button => {
button.disabled = !controlAvailable || state.busy;
button.title = controlAvailable
? "Управление сервисом"
: `Используйте ${state.health?.service_control?.fallback || "scripts/manage_model_service.ps1"}`;
});
els.runtimeBenchmark.disabled = state.busy;
if (!model) {
els.modelMeta.innerHTML = "<div class=\"meta-row\"><span>Нет моделей</span><span>-</span></div>";
els.serviceStatus.textContent = "service: -";
els.profileHint.textContent = "";
els.runtimeBenchmark.disabled = true;
renderPhotoConsole();
renderModelLibrary();
renderPageLayout();
return;
}
const selectedServedName = servedNameForModel(model);
const selectedEndpoint = endpointForModel(model);
const serviceInfo = state.health?.model_services?.find(item => item.id === model.id);
const serviceStatus = serviceInfo?.status || "-";
const storageStatus = serviceInfo?.storage?.status || "-";
const serviceBaseUrl = selectedEndpoint || serviceInfo?.service?.base_url || "-";
const loadSummary = modelLoadSummary(serviceInfo);
els.modelMeta.innerHTML = [
["served", selectedServedName],
["runtime", model.runtime || "-"],
["status", model.status || "-"],
["profile", currentRuntimeProfile()?.label || "-"],
["service", serviceStatus],
...(loadSummary ? [["load", loadSummary]] : []),
["storage", storageStatus],
["endpoint", serviceBaseUrl],
["tasks", (model.tasks || []).join(", ")]
].map(([key, value]) => `<div class="meta-row"><span>${key}</span><span>${escapeHtml(value)}</span></div>`).join("");
const controlText = controlAvailable ? "control: api" : "control: script";
const loadText = loadSummary ? ` · load: ${loadSummary}` : "";
els.serviceStatus.textContent = `service: ${serviceStatus}${loadText} · ${serviceBaseUrl} · ${gpuSummary()} · ${controlText}`;
renderProfileHint(serviceStatus);
renderRuntimeHint();
renderPhotoConsole();
renderModelLibrary();
renderPageLayout();
}
function renderMessages() {
els.chat.innerHTML = "";
for (const message of [...state.messages].reverse()) {
const wrap = document.createElement("article");
wrap.className = `message ${message.role}${message.error ? " error" : ""}`;
const head = document.createElement("div");
head.className = "message-head";
head.innerHTML = `<span>${message.role}</span><span>${message.time || ""}</span>`;
const body = document.createElement("div");
body.className = "message-body";
body.textContent = message.content;
wrap.append(head, body);
if (message.imageUrl) {
const preview = document.createElement("div");
preview.className = "image-preview";
const image = document.createElement("img");
image.src = message.imageUrl;
image.alt = message.content || "generated image";
preview.appendChild(image);
wrap.appendChild(preview);
}
if (message.role === "assistant" && message.reportId && !message.error) {
const foot = document.createElement("div");
foot.className = "message-foot";
for (const rating of ["ok", "needs_review", "bad"]) {
const button = document.createElement("button");
button.textContent = rating;
button.className = message.rating === rating ? "active" : "";
button.addEventListener("click", () => rateMessage(message, rating));
foot.appendChild(button);
}
wrap.appendChild(foot);
}
els.chat.appendChild(wrap);
}
els.chat.scrollTop = 0;
}
function renderBenchmarkHistory(items = state.benchmarkHistory) {
state.benchmarkHistory = items || [];
els.benchmarkHistory.innerHTML = "";
if (!state.benchmarkHistory.length) {
els.benchmarkHistory.textContent = "benchmark history: -";
return;
}
for (const item of state.benchmarkHistory.slice(0, 5)) {
const row = document.createElement("div");
row.className = "benchmark-row";
const speeds = (item.results || [])
.map(result => result.status === "ok"
? `${result.profile_id}: ${result.output_tokens_per_sec} tok/s`
: `${result.profile_id}: error`)
.join(" · ");
const ratio = item.speedup?.gpu_vs_cpu_ratio ? ` · GPU/CPU x${item.speedup.gpu_vs_cpu_ratio}` : "";
const when = item.created_at ? new Date(item.created_at).toLocaleString("ru-RU") : item.updated_at || "";
const text = document.createElement("span");
text.textContent = `${item.model_id || "-"} · fastest=${item.fastest_profile_id || "-"}${ratio} · ${speeds || item.status || "-"} · ${when}`;
row.appendChild(text);
const reportName = String(item.path || "").split("/").pop();
if (reportName) {
for (const format of ["md", "json"]) {
const link = document.createElement("a");
const route = format === "md" ? "/api/benchmark/report.md" : "/api/benchmark/report";
link.href = `${route}?name=${encodeURIComponent(reportName)}`;
link.target = "_blank";
link.rel = "noopener";
link.textContent = format;
row.appendChild(link);
}
}
els.benchmarkHistory.appendChild(row);
}
}
function renderAll() {
renderRuntimeProfiles();
renderPageNav();
renderPlugins();
renderModels();
renderImageModels();
renderPhotoConsole();
renderModelLibrary();
renderTests();
renderCompareModels();
renderRagProfiles();
renderPluginTools();
renderMeta();
renderBenchmarkHistory();
renderMessages();
renderOverview();
renderPageLayout();
}
function renderIngestJobs(jobs) {
state.ingestJobs = jobs;
els.ingestJobs.innerHTML = "";
if (!jobs.length) {
els.ingestJobs.textContent = "Заявок пока нет";
return;
}
for (const job of jobs) {
const row = document.createElement("div");
row.className = "job-row";
const file = job.files?.[0]?.path || job.source || job.target_dir || "";
const size = job.total_size_bytes ? ` · ${Math.round(job.total_size_bytes / 1024 / 1024)} MB` : "";
row.textContent = `${job.status} · ${job.model_id}${size} · ${file}`;
els.ingestJobs.appendChild(row);
}
}
function renderImageJobs(jobs) {
state.imageJobs = jobs;
els.imageJobs.innerHTML = "";
if (!jobs.length) {
els.imageJobs.textContent = "Задач пока нет";
return;
}
for (const job of jobs.slice(0, 8)) {
const row = document.createElement("div");
row.className = "job-meta";
const latency = job.latency_ms ? ` · ${job.latency_ms}ms` : "";
row.textContent = `${job.status || "-"} · ${job.type || job.kind || "image"}${latency} · ${job.id || ""}`;
els.imageJobs.appendChild(row);
}
}
function renderImageGallery(images) {
state.imageGallery = images;
els.imageGallery.innerHTML = "";
if (!images.length) {
els.imageGallery.textContent = "Галерея пуста";
return;
}
for (const item of images.slice(0, 12)) {
const card = document.createElement("div");
card.className = "gallery-item";
const img = document.createElement("img");
img.src = item.url;
img.alt = item.prompt || item.kind || "image";
const meta = document.createElement("div");
meta.className = "gallery-meta";
meta.textContent = `${item.kind || "image"} · ${item.width || "?"}x${item.height || "?"} · seed=${item.seed ?? "auto"}`;
const open = document.createElement("button");
open.type = "button";
open.textContent = "Открыть";
open.addEventListener("click", () => window.open(item.url, "_blank", "noopener"));
const repeat = document.createElement("button");
repeat.type = "button";
repeat.textContent = "Повтор";
repeat.addEventListener("click", () => applyImageArtifact(item));
card.append(img, meta, open, repeat);
els.imageGallery.appendChild(card);
}
}
async function loadImageGallery() {
const response = await fetch("/api/image/gallery");
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
renderImageGallery(data.images || []);
}
async function loadImageJobs() {
const response = await fetch("/api/image/jobs");
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
renderImageJobs(data.jobs || []);
}
async function loadBenchmarkHistory() {
const model = currentModel();
const params = new URLSearchParams({ limit: "8" });
if (model?.id) params.set("model_id", model.id);
params.set("plugin", activePlugin(model));
const query = `?${params.toString()}`;
const response = await fetch(`/api/benchmark/history${query}`);
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
renderBenchmarkHistory(data.benchmarks || []);
}
function applyImageArtifact(item) {
state.selectedImageArtifact = item;
state.plugin = "image";
const models = modelsForPlugin("image");
state.selectedModelId = models[0]?.id || state.selectedModelId;
state.selectedTestId = testsForPlugin("image")[0]?.id || null;
if (item.prompt) els.prompt.value = item.prompt;
if (item.width && item.height) {
const size = `${item.width}x${item.height}`;
if ([...els.imageSize.options].some(option => option.value === size)) els.imageSize.value = size;
}
if (item.steps) els.imageSteps.value = item.steps;
if (item.guidance_scale) els.imageGuidance.value = item.guidance_scale;
els.imageSeed.value = item.seed ?? "";
els.imageNegative.value = item.negative_prompt || "";
renderAll();
syncEndpointToSelectedModel();
setStatus("image loaded", "ok");
}
async function loadIngestJobs() {
const response = await fetch("/api/model-ingest/jobs");
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
renderIngestJobs(data.jobs || []);
}
async function loadHealth() {
try {
const response = await fetch("/api/health");
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
state.health = data;
const vllm = (data.endpoints || []).find(item => item.id === "vllm-text");
const services = data.model_services || [];
const online = services.filter(item => item.status === "online").length;
const ready = services.filter(item => item.status === "ready_to_start").length;
const pending = services.filter(item => item.status === "service_not_implemented").length;
const blocked = services.filter(item => item.status === "blocked").length;
els.healthHost.textContent = `${data.host || "-"} · ${data.status}`;
els.healthGpu.textContent = data.gpu?.summary || gpuSummary();
els.healthVllm.textContent = vllm?.status === "ok"
? `${(vllm.models || []).join(", ") || "no models"} · ${vllm.latency_ms}ms`
: vllm?.error || "error";
els.healthRag.textContent = data.rag_1c_index?.exists ? "index ready" : "index missing";
els.healthPreflight.textContent = preflightSummary(data.model_chat_preflight);
els.healthRoutes.textContent = `${online} online · ${ready} ready · ${pending} pending · ${blocked} blocked`;
renderMeta();
syncEndpointToSelectedModel();
} catch (error) {
state.health = null;
els.healthHost.textContent = "health error";
els.healthGpu.textContent = "-";
els.healthVllm.textContent = String(error.message || error);
els.healthRag.textContent = "-";
els.healthPreflight.textContent = "-";
els.healthRoutes.textContent = "-";
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
function fillDefaultPrompt(type = "default") {
const defaults = state.catalog?.default_prompts || {};
const pluginPrompt = defaults[state.plugin] || defaults.text || "Проверь модель коротким ответом.";
const selectedPrompt = currentTest()?.prompt || pluginPrompt;
const prompts = {
default: pluginPrompt,
selected: selectedPrompt,
sanity: "Ответь одним предложением: модель доступна и готова к проверке.",
compare: "Оцени свой ответ: какие риски, ограничения или недостающие данные нужно учесть?",
json: "Верни JSON с полями status, summary, risks. Без markdown."
};
els.prompt.value = prompts[type] || pluginPrompt;
els.prompt.focus();
}
function now() {
return new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
async function loadCatalog() {
setStatus("load");
const response = await fetch("/api/catalog");
if (!response.ok) throw new Error(`catalog ${response.status}`);
state.catalog = await response.json();
state.runtimeProfileId = state.catalog.runtime_profiles?.default || Object.keys(state.catalog.runtime_profiles?.profiles || {})[0] || null;
els.baseUrl.value = state.catalog.default_base_url || "http://docker-gpu.cin.su:8000";
renderEndpointPresets();
els.endpointPreset.value = els.baseUrl.value;
const firstPlugin = state.catalog.plugins[0]?.id || "text";
state.plugin = state.catalog.plugins.some(plugin => plugin.id === state.plugin) ? state.plugin : firstPlugin;
state.selectedModelId = modelsForPlugin(state.plugin)[0]?.id || state.catalog.models[0]?.id || null;
state.selectedTestId = testsForPlugin(state.plugin)[0]?.id || null;
setStatus("ready", "ok");
renderAll();
loadIngestJobs().catch(() => {});
loadBenchmarkHistory().catch(() => {});
loadImageGallery().catch(() => {});
loadImageJobs().catch(() => {});
loadHealth().catch(() => {});
fillDefaultPrompt("selected");
}
function ingestBasePayload() {
const modelId = els.ingestModelId.value.trim();
return {
model_id: modelId,
name: modelId,
plugin: els.ingestPlugin.value || state.plugin || "text",
format: els.ingestFormat.value.trim()
};
}
async function uploadModelFile() {
const file = els.ingestFile.files?.[0];
if (!file) {
els.ingestStatus.textContent = "file required";
els.ingestStatus.className = "status err";
return;
}
const payload = ingestBasePayload();
const params = new URLSearchParams({
filename: file.name,
model_id: payload.model_id || file.name.replace(/\.[^.]+$/, ""),
name: payload.name || file.name,
plugin: payload.plugin,
format: payload.format || file.name.split(".").pop() || ""
});
els.uploadModel.disabled = true;
els.ingestStatus.textContent = "uploading";
els.ingestStatus.className = "status";
try {
const response = await fetch(`/api/model-ingest/upload?${params.toString()}`, {
method: "POST",
body: file
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
els.ingestStatus.textContent = data.status;
els.ingestStatus.className = "status ok";
await loadIngestJobs();
} catch (error) {
els.ingestStatus.textContent = "error";
els.ingestStatus.className = "status err";
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
renderMessages();
} finally {
els.uploadModel.disabled = false;
}
}
async function importModelSource() {
const source = els.ingestSource.value.trim();
if (!source) {
els.ingestStatus.textContent = "source required";
els.ingestStatus.className = "status err";
return;
}
els.importModel.disabled = true;
els.ingestStatus.textContent = "importing";
els.ingestStatus.className = "status";
try {
const response = await fetch("/api/model-ingest/source", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...ingestBasePayload(), source })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
els.ingestStatus.textContent = data.status;
els.ingestStatus.className = data.status.includes("failed") ? "status err" : "status ok";
await loadIngestJobs();
} catch (error) {
els.ingestStatus.textContent = "error";
els.ingestStatus.className = "status err";
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
renderMessages();
} finally {
els.importModel.disabled = false;
}
}
async function verifyLatestModel() {
const job = state.ingestJobs?.[0];
if (!job) {
els.ingestStatus.textContent = "no jobs";
els.ingestStatus.className = "status err";
return;
}
els.verifyModel.disabled = true;
els.ingestStatus.textContent = "checking";
els.ingestStatus.className = "status";
try {
const response = await fetch("/api/model-ingest/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_id: job.id })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.status || `HTTP ${response.status}`);
els.ingestStatus.textContent = data.status;
els.ingestStatus.className = "status ok";
await loadIngestJobs();
} catch (error) {
els.ingestStatus.textContent = "verify error";
els.ingestStatus.className = "status err";
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
renderMessages();
} finally {
els.verifyModel.disabled = false;
}
}
async function registerLatestModel() {
const job = state.ingestJobs?.[0];
if (!job) {
els.ingestStatus.textContent = "no jobs";
els.ingestStatus.className = "status err";
return;
}
els.registerModel.disabled = true;
els.ingestStatus.textContent = "registering";
els.ingestStatus.className = "status";
try {
const response = await fetch("/api/model-ingest/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_id: job.id })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.status || `HTTP ${response.status}`);
els.ingestStatus.textContent = data.status;
els.ingestStatus.className = data.status.includes("failed") ? "status err" : "status ok";
await loadIngestJobs();
await loadCatalog();
} catch (error) {
els.ingestStatus.textContent = "register error";
els.ingestStatus.className = "status err";
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
renderMessages();
} finally {
els.registerModel.disabled = false;
}
}
async function checkEndpoint() {
const model = currentModel();
if (!model) return;
els.endpointStatus.textContent = "checking";
els.endpointStatus.className = "status";
try {
const response = await fetch("/api/models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ base_url: els.baseUrl.value.trim(), model: servedNameForModel(model) })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
els.endpointStatus.textContent = data.available ? `ok ${data.latency_ms}ms` : "model missing";
els.endpointStatus.className = `status ${data.available ? "ok" : "err"}`;
} catch (error) {
els.endpointStatus.textContent = "error";
els.endpointStatus.className = "status err";
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
renderMessages();
}
}
async function controlService(action) {
const model = currentModel();
if (!model || state.busy) return;
state.busy = true;
setStatus(action);
els.serviceStatus.textContent = `service: ${action}...`;
try {
const response = await fetch("/api/service-control", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_id: model.id, action })
});
const data = await response.json();
const output = data.result
? [data.result.stdout, data.result.stderr].filter(Boolean).join("\n")
: data.error || `HTTP ${response.status}`;
if (!response.ok) throw new Error(output || `HTTP ${response.status}`);
state.messages.push({
role: "assistant",
content: `[service ${action}] ${model.name || model.id}\n${output || "ok"}`,
time: `${now()} · ${data.result?.latency_ms || 0}ms`,
reportId: data.report_id,
rating: null
});
setStatus("ready", "ok");
await loadHealth();
} catch (error) {
state.messages.push({ role: "assistant", content: `[service ${action}]\n${String(error.message || error)}`, time: now(), error: true });
setStatus("error", "err");
} finally {
state.busy = false;
renderMessages();
renderMeta();
}
}
function benchmarkSummary(data) {
const benchmark = data.benchmark || {};
const rows = benchmark.results || [];
const lines = rows.map(row => {
if (row.status !== "ok") return `${row.profile_id}: error · ${row.error || "-"}`;
return `${row.profile_id}: ${row.output_tokens_per_sec} tok/s · ${row.elapsed_sec}s · ${row.target?.served_model_name || "-"}`;
});
const fastest = benchmark.fastest_profile_id ? `\nfastest: ${benchmark.fastest_profile_id}` : "";
const ratio = benchmark.speedup?.gpu_vs_cpu_ratio ? `\nGPU/CPU: x${benchmark.speedup.gpu_vs_cpu_ratio}` : "";
const path = data.report_path ? `\nreport: ${data.report_path}` : "";
return `[runtime benchmark] ${benchmark.model_id || currentModel()?.id || ""}${fastest}${ratio}${path}\n${lines.join("\n") || "no results"}`;
}
function benchmarkTokenLimit() {
const raw = Number(els.benchmarkTokens?.value || 384);
if (!Number.isFinite(raw)) return 384;
return Math.max(64, Math.min(1024, Math.round(raw)));
}
async function preflightRuntimeBenchmark(model, plugin, profiles = ["gpu-fast", "cpu-test"]) {
els.serviceStatus.textContent = "benchmark: checking endpoints...";
const response = await fetch("/api/benchmark/preflight", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_id: model.id, plugin, profiles })
});
const data = await response.json();
const summary = (data.checks || [])
.map(check => `${check.profile_id}: ${check.available ? `ok ${check.latency_ms || 0}ms` : check.error || check.status || "blocked"}`)
.join(" · ");
if (!response.ok || !data.ready) {
throw new Error(`benchmark preflight failed · ${summary}`);
}
return summary;
}
async function runRuntimeBenchmark() {
const model = currentModel();
if (!model || state.busy) return;
state.busy = true;
els.runtimeBenchmark.disabled = true;
setStatus("benchmark");
const maxTokens = benchmarkTokenLimit();
if (els.benchmarkTokens) els.benchmarkTokens.value = String(maxTokens);
els.serviceStatus.textContent = `benchmark: running ${maxTokens} tokens...`;
try {
const plugin = activePlugin(model);
const preflight = await preflightRuntimeBenchmark(model, plugin);
els.serviceStatus.textContent = `benchmark: running ${maxTokens} tokens · ${preflight}`;
const response = await fetch("/api/benchmark/runtime", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_id: model.id,
plugin,
profiles: ["gpu-fast", "cpu-test"],
prompt: els.prompt.value.trim() || currentTest()?.prompt || state.catalog?.default_prompts?.[plugin] || "",
temperature: Number(els.temperature.value || 0.1),
max_tokens: maxTokens
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || data.result?.stderr || `HTTP ${response.status}`);
state.messages.push({
role: "assistant",
content: benchmarkSummary(data),
time: `${now()} · ${data.result?.latency_ms || 0}ms`,
reportId: data.report_id,
rating: null
});
setStatus("ready", "ok");
await loadBenchmarkHistory().catch(() => {});
} catch (error) {
state.messages.push({ role: "assistant", content: `[runtime benchmark]\n${String(error.message || error)}`, time: now(), error: true });
setStatus("error", "err");
} finally {
state.busy = false;
els.runtimeBenchmark.disabled = false;
renderMessages();
renderMeta();
}
}
async function routeTask() {
try {
const response = await fetch("/api/route", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plugin: state.plugin })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
state.selectedModelId = data.model.id;
renderAll();
els.baseUrl.value = endpointForModel() || data.base_url;
const served = servedNameForModel();
state.messages.push({
role: "assistant",
content: `[route]\nplugin=${data.plugin}\nmodel=${data.model.name || data.model.id}\nserved=${served}\nendpoint=${els.baseUrl.value}\n${data.reason}`,
time: now()
});
setStatus("routed", "ok");
renderMessages();
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
renderMessages();
}
}
async function runQuality() {
if (state.busy) return;
const model = currentModel();
if (!model) return;
state.busy = true;
els.qualityRun.disabled = true;
setStatus("quality");
try {
const response = await fetch("/api/quality/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
plugin: state.plugin,
model: servedNameForModel(model),
base_url: els.baseUrl.value.trim(),
tests: testsForPlugin(state.plugin)
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
const lines = data.results.map(result => {
const mark = result.passed ? "ok" : "fail";
return `${mark} · ${result.label || result.test_id} · ${result.latency_ms}ms · ${result.check_reason}`;
});
state.messages.push({
role: "assistant",
content: `[quality ${data.passed}/${data.total}]\n${lines.join("\n")}`,
time: now(),
reportId: data.report_id,
rating: null
});
setStatus("ready", data.passed === data.total ? "ok" : "err");
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
} finally {
state.busy = false;
els.qualityRun.disabled = false;
renderMessages();
}
}
async function rateMessage(message, rating) {
if (!message.reportId) return;
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ report_id: message.reportId, rating })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
message.rating = rating;
setStatus("saved", "ok");
renderMessages();
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
renderMessages();
}
}
function buildMessages(prompt, includeHistory = false) {
const messages = [];
const systemPrompt = els.systemPrompt.value.trim();
if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
if (includeHistory) {
for (const message of state.messages.filter(item => !item.error)) {
if (message.role === "user" || message.role === "assistant") {
messages.push({ role: message.role, content: message.content });
}
}
} else {
messages.push({ role: "user", content: prompt });
}
return messages;
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("file read failed"));
reader.readAsDataURL(file);
});
}
function applyImageQuality() {
const presets = {
fast: { steps: 8, guidance: 5, size: "512x512" },
balanced: { steps: 28, guidance: 6, size: "1024x1024" },
quality: { steps: 40, guidance: 7, size: "1024x1024" }
};
const preset = presets[els.imageQuality.value] || presets.balanced;
els.imageSteps.value = preset.steps;
els.imageGuidance.value = preset.guidance;
if ([...els.imageSize.options].some(option => option.value === preset.size)) {
els.imageSize.value = preset.size;
}
}
function applyPhotoWorkflow(workflowId) {
const workflows = {
"real-photo": {
modelCandidates: ["realvisxl-v5_0", "sdxl-base-1_0"],
mode: "generate",
quality: "quality",
size: "1024x1024",
steps: 36,
guidance: 6.5,
strength: 0.95,
negative: "low quality, blurry, overprocessed skin, deformed eyes, extra fingers, artifacts",
prompt: "Сделай реалистичное фото с естественным светом, натуральной кожей, точной анатомией и чистым фоном."
},
anime: {
modelCandidates: ["animagine-xl-4_0", "illustriousxl", "sdxl-base-1_0"],
mode: "generate",
quality: "quality",
size: "1024x1024",
steps: 32,
guidance: 7,
strength: 0.95,
negative: "low quality, blurry, bad hands, extra limbs, messy face, artifacts, realistic photo",
prompt: "Нарисуй чистый anime key visual с аккуратными линиями, выразительным лицом и детализированной одеждой."
},
"replace-object": {
modelCandidates: ["sdxl-inpainting-1_0"],
mode: "edit",
quality: "balanced",
size: "1024x1024",
steps: 28,
guidance: 6,
strength: 0.92,
negative: "low quality, blurry, seams, mismatch lighting, artifacts",
prompt: "Замени объект в выделенной зоне так, чтобы перспектива, свет и стиль сцены полностью совпадали."
},
"retouch-face": {
modelCandidates: ["sdxl-inpainting-1_0"],
mode: "edit",
quality: "balanced",
size: "1024x1024",
steps: 22,
guidance: 5.5,
strength: 0.55,
negative: "plastic skin, uncanny face, asymmetry, blurry eyes, artifacts",
prompt: "Сделай деликатную ретушь лица: выровняй кожу, сохрани естественные черты, текстуру и освещение."
}
};
const workflow = workflows[workflowId];
if (!workflow) return;
const selectedModel = firstAvailableImageModel(workflow.modelCandidates || []);
if (!selectedModel) return;
selectPhotoModel(selectedModel.id);
els.imageMode.value = workflow.mode;
els.imageQuality.value = workflow.quality;
if ([...els.imageSize.options].some(option => option.value === workflow.size)) {
els.imageSize.value = workflow.size;
}
els.imageSteps.value = workflow.steps;
els.imageGuidance.value = workflow.guidance;
els.imageStrength.value = workflow.strength;
els.imageNegative.value = workflow.negative;
if (!els.prompt.value.trim()) {
els.prompt.value = workflow.prompt;
}
els.maskEditor.classList.toggle("active", workflow.mode === "edit" && Boolean(els.editImageFile.files?.[0]));
const preferred = workflow.modelCandidates?.[0];
if (preferred && selectedModel.id !== preferred) {
const preferredModel = modelById(preferred);
const preferredName = preferredModel?.name || preferred;
const fallbackName = selectedModel.name || selectedModel.id;
els.photoConsoleNote.textContent = `${preferredName} пока не загружена на сервер, поэтому выбран fallback: ${fallbackName}.`;
}
renderPhotoConsole();
renderImageModelWarning();
setStatus(`preset: ${workflowId}`, "ok");
}
function resizeMaskCanvases(width = 512, height = 512) {
for (const canvas of [els.maskImageCanvas, els.maskPaintCanvas]) {
canvas.width = width;
canvas.height = height;
}
const paint = els.maskPaintCanvas.getContext("2d");
paint.clearRect(0, 0, width, height);
state.canvasMaskDataUrl = "";
}
async function loadMaskEditorImage(file) {
if (!file) {
els.maskEditor.classList.remove("active");
return;
}
const dataUrl = await readFileAsDataUrl(file);
const image = new Image();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
image.src = dataUrl;
});
const { width, height } = imageRequestSize();
resizeMaskCanvases(width, height);
const ctx = els.maskImageCanvas.getContext("2d");
ctx.clearRect(0, 0, width, height);
ctx.drawImage(image, 0, 0, width, height);
els.maskEditor.classList.add("active");
}
function paintMaskAt(event) {
if (!els.maskEditor.classList.contains("active")) return;
const rect = els.maskPaintCanvas.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * els.maskPaintCanvas.width;
const y = ((event.clientY - rect.top) / rect.height) * els.maskPaintCanvas.height;
const ctx = els.maskPaintCanvas.getContext("2d");
const radius = Number(els.maskBrush.value || 42);
ctx.save();
if (els.maskMode.value === "erase") {
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(255,255,255,1)";
} else {
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(255,255,255,0.72)";
}
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function canvasMaskDataUrl() {
const source = els.maskPaintCanvas;
const mask = document.createElement("canvas");
mask.width = source.width;
mask.height = source.height;
const out = mask.getContext("2d");
out.fillStyle = "#000";
out.fillRect(0, 0, mask.width, mask.height);
out.drawImage(source, 0, 0);
return mask.toDataURL("image/png");
}
function clearCanvasMask() {
const ctx = els.maskPaintCanvas.getContext("2d");
ctx.clearRect(0, 0, els.maskPaintCanvas.width, els.maskPaintCanvas.height);
state.canvasMaskDataUrl = "";
}
function fillCanvasMask() {
const ctx = els.maskPaintCanvas.getContext("2d");
ctx.fillStyle = "rgba(255,255,255,0.72)";
ctx.fillRect(0, 0, els.maskPaintCanvas.width, els.maskPaintCanvas.height);
state.canvasMaskDataUrl = canvasMaskDataUrl();
}
async function sendAudioTranscription(model) {
const file = els.audioFile.files?.[0];
if (!file) {
state.messages.push({ role: "assistant", content: "Выберите аудиофайл для распознавания.", time: now(), error: true });
renderMessages();
return;
}
state.busy = true;
setStatus("audio");
els.send.disabled = true;
els.sendTop.disabled = true;
els.stop.disabled = false;
els.stop.classList.add("loading");
state.messages.push({ role: "user", content: `[audio] ${file.name} · ${Math.round(file.size / 1024)} KB`, time: now() });
renderMessages();
try {
const audioBase64 = await readFileAsDataUrl(file);
const response = await fetch("/api/audio/transcribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
base_url: els.baseUrl.value.trim(),
model: servedNameForModel(model),
filename: file.name,
audio_base64: audioBase64,
language: els.audioLanguage.value,
task: els.audioTask.value
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
state.messages.push({
role: "assistant",
content: data.text || "(пустой результат)",
time: `${now()} · ${data.latency_ms}ms`,
reportId: data.report_id,
rating: null
});
setStatus("ready", "ok");
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
} finally {
state.busy = false;
els.send.disabled = false;
els.sendTop.disabled = false;
els.stop.disabled = true;
els.stop.classList.remove("loading");
renderMessages();
}
}
async function sendVisionAnalysis(model) {
const file = els.imageFile.files?.[0];
const prompt = els.prompt.value.trim() || "Опиши изображение и перечисли важные детали.";
if (!file) {
state.messages.push({ role: "assistant", content: "Выберите изображение для анализа.", time: now(), error: true });
renderMessages();
return;
}
state.busy = true;
setStatus("vision");
els.send.disabled = true;
els.sendTop.disabled = true;
els.stop.disabled = false;
els.stop.classList.add("loading");
state.messages.push({ role: "user", content: `[image] ${file.name} · ${Math.round(file.size / 1024)} KB\n${prompt}`, time: now() });
renderMessages();
try {
const imageBase64 = await readFileAsDataUrl(file);
const response = await fetch("/api/video/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
base_url: els.baseUrl.value.trim(),
model: servedNameForModel(model),
filename: file.name,
image_base64: imageBase64,
prompt,
max_tokens: Number(els.maxTokens.value || 512)
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
const size = data.width && data.height ? ` · ${data.width}x${data.height}` : "";
state.messages.push({
role: "assistant",
content: data.text || "(пустой результат)",
time: `${now()} · ${data.latency_ms}ms${size}`,
reportId: data.report_id,
rating: null
});
setStatus("ready", "ok");
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
} finally {
state.busy = false;
els.send.disabled = false;
els.sendTop.disabled = false;
els.stop.disabled = true;
els.stop.classList.remove("loading");
renderMessages();
}
}
function imageRequestSize() {
const [width, height] = String(els.imageSize.value || "1024x1024").split("x").map(Number);
return { width: width || 1024, height: height || 1024 };
}
async function sendImageRequest(model) {
const prompt = els.prompt.value.trim();
if (!prompt) return;
const imageModel = currentImageModel() || model;
const availability = imageModelAvailability(imageModel);
if (!availability.available) {
state.messages.push({ role: "assistant", content: availability.reason, time: now(), error: true });
renderImageModelWarning();
renderMessages();
return;
}
const mode = els.imageMode.value || "generate";
const { width, height } = imageRequestSize();
const seed = els.imageSeed.value.trim();
const basePayload = {
base_url: els.baseUrl.value.trim(),
model_id: imageModel?.id || model?.id || "",
model_mode: imageModel?.id === "qwen-image-edit" ? "qwen-image-edit" : "sdxl",
model: servedNameForModel(imageModel || model),
prompt,
negative_prompt: els.imageNegative.value.trim(),
width,
height,
steps: Number(els.imageSteps.value || 28),
guidance_scale: Number(els.imageGuidance.value || 6),
seed: seed ? Number(seed) : null
};
if (mode === "edit" && !els.editImageFile.files?.[0]) {
state.messages.push({ role: "assistant", content: "Для редактирования выберите исходное фото.", time: now(), error: true });
renderMessages();
return;
}
if (mode === "edit" && !els.editMaskFile.files?.[0] && !state.canvasMaskDataUrl) {
state.messages.push({ role: "assistant", content: "Для редактирования загрузите маску или нарисуйте ее на canvas и нажмите Use mask.", time: now(), error: true });
renderMessages();
return;
}
state.busy = true;
setStatus(mode === "edit" ? "image edit" : "image");
els.send.disabled = true;
els.sendTop.disabled = true;
els.stop.disabled = false;
els.stop.classList.add("loading");
state.messages.push({ role: "user", content: `[image ${mode} · ${imageModel?.name || imageModel?.id || "model"}] ${width}x${height}\n${prompt}`, time: now() });
renderMessages();
try {
let operation = "generate";
let payload = basePayload;
if (mode === "edit") {
operation = "edit";
payload = {
...basePayload,
image_base64: await readFileAsDataUrl(els.editImageFile.files[0]),
mask_base64: els.editMaskFile.files?.[0] ? await readFileAsDataUrl(els.editMaskFile.files[0]) : state.canvasMaskDataUrl,
strength: Number(els.imageStrength.value || 0.95)
};
}
const response = await fetch("/api/image/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...payload, operation })
});
const submitted = await response.json();
if (!response.ok) throw new Error(submitted.error || `HTTP ${response.status}`);
state.activeImageJob = {
jobId: submitted.id || submitted.report_id,
reportId: submitted.report_id || submitted.id,
baseUrl: submitted.base_url || payload.base_url,
operation
};
setStatus("image queued");
let data = submitted;
for (let attempt = 0; attempt < 900; attempt += 1) {
if (!state.activeImageJob) throw new Error("Генерация отменена.");
await new Promise(resolve => setTimeout(resolve, attempt < 3 ? 700 : 1200));
const statusUrl = `/api/image/job?job_id=${encodeURIComponent(state.activeImageJob.jobId)}&base_url=${encodeURIComponent(state.activeImageJob.baseUrl)}`;
const statusResponse = await fetch(statusUrl);
data = await statusResponse.json();
if (!statusResponse.ok) throw new Error(data.error || `HTTP ${statusResponse.status}`);
if (data.status === "queued" || data.status === "running" || data.status === "loading_model" || data.status === "cancel_requested") {
setStatus(data.status === "cancel_requested" ? "cancel requested" : `image ${data.status}`);
continue;
}
break;
}
if (data.status === "cancelled") {
state.messages.push({ role: "assistant", content: "Генерация отменена.", time: now() });
setStatus("cancelled");
await loadImageJobs().catch(() => {});
return;
}
if (data.status !== "completed") throw new Error(data.error || `Image job ${data.status || "timeout"}`);
const result = data.result || data;
const size = result.width && result.height ? `${result.width}x${result.height}` : `${width}x${height}`;
state.messages.push({
role: "assistant",
content: `[${result.model || servedNameForModel(model)}] ${size} · seed=${result.seed ?? "auto"}`,
imageUrl: data.artifact?.url || result.artifact?.url || result.image_base64,
time: `${now()} · ${data.latency_ms || result.latency_ms || "-"}ms`,
reportId: data.report_id || state.activeImageJob?.reportId,
rating: null
});
setStatus("ready", "ok");
await loadImageGallery().catch(() => {});
await loadImageJobs().catch(() => {});
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
} finally {
state.activeImageJob = null;
state.busy = false;
els.send.disabled = false;
els.sendTop.disabled = false;
els.stop.disabled = true;
els.stop.classList.remove("loading");
renderMessages();
}
}
async function cancelActiveRequest() {
if (!state.activeImageJob) return;
const job = state.activeImageJob;
job.cancelRequested = true;
els.stop.disabled = true;
setStatus("cancel requested");
try {
const response = await fetch("/api/image/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_id: job.jobId, base_url: job.baseUrl })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
renderMessages();
}
}
function selectedCompareModels() {
const selected = [...els.compareModels.querySelectorAll("input:checked")].map(input => input.value);
return modelsForPlugin(state.plugin).filter(model => selected.includes(model.id));
}
async function comparePrompt() {
if (state.busy) return;
const prompt = els.prompt.value.trim();
const models = selectedCompareModels();
if (!prompt || !models.length) return;
state.busy = true;
setStatus("compare");
els.compareRun.disabled = true;
const userMessage = { role: "user", content: `[compare] ${prompt}`, time: now() };
state.messages.push(userMessage);
renderMessages();
try {
const response = await fetch("/api/compare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
base_url: els.baseUrl.value.trim(),
models: models.map(item => ({
...item,
served_model_name: servedNameForModel(item)
})),
plugin: state.plugin,
test_id: state.selectedTestId,
messages: buildMessages(prompt, false),
temperature: Number(els.temperature.value || 0.2),
max_tokens: Number(els.maxTokens.value || 1000)
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
for (const result of data.results) {
const label = result.name || result.registry_model_id || result.served_model_name;
const content = result.status === "answered" ? result.answer : result.error;
state.messages.push({
role: "assistant",
content: `[${label}]\n${content}`,
time: `${now()} · ${result.latency_ms}ms`,
error: result.status !== "answered"
});
}
setStatus("ready", "ok");
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
} finally {
state.busy = false;
els.compareRun.disabled = false;
renderMessages();
}
}
async function buildRagPrompt() {
const question = els.prompt.value.trim();
if (!question) return;
try {
const response = await fetch("/api/1c/rag-prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
question,
profile: els.ragProfile.value || "auto"
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
state.messages.push({
role: "assistant",
content: `[1C RAG prompt · profile=${data.profile || "auto"} · sources=${data.context_count}]\n${data.prompt}`,
time: now(),
reportId: data.report_id,
rating: null
});
setStatus("ready", "ok");
renderMessages();
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
renderMessages();
}
}
async function sendRagAnswer() {
if (state.busy) return;
const model = currentModel();
const question = els.prompt.value.trim();
if (!model || !question) return;
state.busy = true;
els.ragAnswer.disabled = true;
setStatus("rag");
state.messages.push({ role: "user", content: `[1C RAG] ${question}`, time: now() });
renderMessages();
try {
const response = await fetch("/api/1c/rag-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
question,
profile: els.ragProfile.value || "auto",
base_url: els.baseUrl.value.trim(),
model: servedNameForModel(model),
temperature: Number(els.temperature.value || 0.2),
max_tokens: Number(els.maxTokens.value || 1000)
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
const sources = (data.sources || [])
.map(source => `- ${source.title || source.source_path || "source"} · score=${source.score}`)
.join("\n");
state.messages.push({
role: "assistant",
content: `${data.answer}\n\nИсточники:\n${sources || "-"}`,
time: `${now()} · ${data.latency_ms}ms`,
reportId: data.report_id,
rating: null
});
setStatus("ready", "ok");
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
} finally {
state.busy = false;
els.ragAnswer.disabled = false;
renderMessages();
}
}
async function sendPrompt() {
if (state.busy) return;
const model = currentModel();
const prompt = els.prompt.value.trim();
if (!model) return;
if (state.plugin === "audio") {
await sendAudioTranscription(model);
return;
}
if (state.plugin === "video") {
await sendVisionAnalysis(model);
return;
}
if (state.plugin === "image") {
await sendImageRequest(model);
return;
}
if (!prompt) return;
state.busy = true;
setStatus("send");
els.send.disabled = true;
els.sendTop.disabled = true;
els.stop.disabled = false;
els.stop.classList.add("loading");
const userMessage = { role: "user", content: prompt, time: now() };
state.messages.push(userMessage);
renderMessages();
els.prompt.value = "";
const messages = buildMessages(prompt, true);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
base_url: els.baseUrl.value.trim(),
model: servedNameForModel(model),
registry_model_id: model.id,
plugin: state.plugin,
test_id: state.selectedTestId,
messages,
temperature: Number(els.temperature.value || 0.2),
max_tokens: Number(els.maxTokens.value || 1000)
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
state.messages.push({
role: "assistant",
content: data.answer,
time: `${now()} · ${data.latency_ms}ms`,
reportId: data.report_id,
rating: null
});
setStatus("ready", "ok");
} catch (error) {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
setStatus("error", "err");
} finally {
state.busy = false;
els.send.disabled = false;
els.sendTop.disabled = false;
els.stop.disabled = true;
els.stop.classList.remove("loading");
renderMessages();
}
}
els.modelSelect.addEventListener("change", () => {
state.selectedModelId = els.modelSelect.value;
if (state.plugin === "image" && els.imageModel) els.imageModel.value = state.selectedModelId;
renderMeta();
syncEndpointToSelectedModel();
loadBenchmarkHistory().catch(() => {});
});
els.imageModel.addEventListener("change", () => {
state.selectedModelId = els.imageModel.value;
renderModels();
renderMeta();
renderImageModelWarning();
syncEndpointToSelectedModel();
loadBenchmarkHistory().catch(() => {});
});
els.testSelect.addEventListener("change", () => {
state.selectedTestId = els.testSelect.value;
fillDefaultPrompt("selected");
});
els.runtimeProfile.addEventListener("change", () => {
state.runtimeProfileId = els.runtimeProfile.value;
els.baseUrl.value = "";
renderMeta();
renderCompareModels();
syncEndpointToSelectedModel();
});
els.endpointPreset.addEventListener("change", () => {
if (els.endpointPreset.value) {
els.baseUrl.value = els.endpointPreset.value;
els.endpointStatus.textContent = "not checked";
els.endpointStatus.className = "status";
}
});
els.baseUrl.addEventListener("input", () => {
els.endpointPreset.value = "";
els.endpointStatus.textContent = "not checked";
els.endpointStatus.className = "status";
});
els.checkEndpoint.addEventListener("click", checkEndpoint);
els.runtimeBenchmark.addEventListener("click", runRuntimeBenchmark);
document.querySelectorAll("[data-service-action]").forEach(button => {
button.addEventListener("click", () => controlService(button.dataset.serviceAction));
});
els.routeTask.addEventListener("click", routeTask);
els.compareRun.addEventListener("click", comparePrompt);
els.qualityRun.addEventListener("click", runQuality);
els.ragPrompt.addEventListener("click", buildRagPrompt);
els.ragAnswer.addEventListener("click", sendRagAnswer);
els.uploadModel.addEventListener("click", uploadModelFile);
els.importModel.addEventListener("click", importModelSource);
els.verifyModel.addEventListener("click", verifyLatestModel);
els.registerModel.addEventListener("click", registerLatestModel);
els.refreshJobs.addEventListener("click", loadIngestJobs);
els.refreshGallery.addEventListener("click", () => loadImageGallery().catch(error => {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
renderMessages();
}));
els.refreshImageJobs.addEventListener("click", () => loadImageJobs().catch(error => {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
renderMessages();
}));
els.repeatImage.addEventListener("click", () => {
const item = state.selectedImageArtifact || state.imageGallery[0];
if (item) applyImageArtifact(item);
});
els.imageQuality.addEventListener("change", applyImageQuality);
els.imageMode.addEventListener("change", () => {
els.maskEditor.classList.toggle("active", els.imageMode.value === "edit" && Boolean(els.editImageFile.files?.[0]));
});
els.imageSize.addEventListener("change", () => {
if (els.editImageFile.files?.[0]) loadMaskEditorImage(els.editImageFile.files[0]).catch(() => {});
});
els.editImageFile.addEventListener("change", () => {
loadMaskEditorImage(els.editImageFile.files?.[0]).catch(error => {
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
renderMessages();
});
});
els.clearMask.addEventListener("click", clearCanvasMask);
els.fillMask.addEventListener("click", fillCanvasMask);
els.useCanvasMask.addEventListener("click", () => {
state.canvasMaskDataUrl = canvasMaskDataUrl();
setStatus("mask ready", "ok");
});
let paintingMask = false;
els.maskPaintCanvas.addEventListener("pointerdown", event => {
paintingMask = true;
els.maskPaintCanvas.setPointerCapture(event.pointerId);
paintMaskAt(event);
});
els.maskPaintCanvas.addEventListener("pointermove", event => {
if (paintingMask) paintMaskAt(event);
});
els.maskPaintCanvas.addEventListener("pointerup", event => {
paintingMask = false;
try { els.maskPaintCanvas.releasePointerCapture(event.pointerId); } catch {}
state.canvasMaskDataUrl = canvasMaskDataUrl();
});
els.maskPaintCanvas.addEventListener("pointercancel", () => {
paintingMask = false;
state.canvasMaskDataUrl = canvasMaskDataUrl();
});
els.send.addEventListener("click", sendPrompt);
els.sendTop.addEventListener("click", sendPrompt);
els.stop.addEventListener("click", cancelActiveRequest);
els.prompt.addEventListener("keydown", event => {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) sendPrompt();
});
document.querySelectorAll("[data-prompt]").forEach(button => {
button.addEventListener("click", () => fillDefaultPrompt(button.dataset.prompt));
});
document.querySelectorAll("[data-photo-preset]").forEach(button => {
button.addEventListener("click", () => selectPhotoModel(button.dataset.photoPreset || ""));
});
document.querySelectorAll("[data-photo-workflow]").forEach(button => {
button.addEventListener("click", () => applyPhotoWorkflow(button.dataset.photoWorkflow || ""));
});
els.photoStartModel?.addEventListener("click", async () => {
if (currentImageModel()?.id) selectPhotoModel(currentImageModel().id);
await controlService("start");
});
els.photoCheckEndpoint?.addEventListener("click", async () => {
if (currentImageModel()?.id) selectPhotoModel(currentImageModel().id);
await checkEndpoint();
});
els.photoRefreshStatus?.addEventListener("click", () => {
loadHealth().catch(() => {});
});
els.clearChat.addEventListener("click", () => {
state.messages = [];
renderMessages();
setStatus("ready", "ok");
});
els.reloadCatalog.addEventListener("click", () => {
loadCatalog().catch(error => {
setStatus("error", "err");
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
renderMessages();
});
});
loadCatalog().catch(error => {
setStatus("error", "err");
state.messages.push({ role: "assistant", content: String(error.message || error), time: now(), error: true });
renderMessages();
});
setInterval(() => loadHealth().catch(() => {}), 30000);
</script>
</body>
</html>