4261 lines
153 KiB
HTML
4261 lines
153 KiB
HTML
<!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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll("\"", """);
|
||
}
|
||
|
||
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>
|