Files
RAG_helper/static/settings.html
T
AR 15 M4 3c71372ec8 docs+ui: страница «Документация», единый стиль заголовков, перевод на оператора
Добавлена /docs.html — обзор мультиагентной системы для оператора. Все
термины в формате «русский (english)», жирным: ветка (intent), маршрутизатор
(router), пошаговый сценарий (state machine), шаг (step), допустимые
переходы (allowed_next), слоты (slots), условия выхода (exit conditions),
переключение ветки (hard handoff), удержание в ветке (sticky state machine),
структурированный ответ (structured output), отложенный сценарий
(suspended/resume), защита от петли (routing loop guard), состояние диалога
(thread state). Плюс пошаговая схема обработки реплики и резюме защитных
механизмов. Ссылка «Документация» добавлена в шапку всех страниц.

Унификация заголовков под стиль «Версии» в правом сайдбаре Настроек: убран
uppercase, переход на 13px / var(--fg) / font-weight 600 / зажатый
letter-spacing. Применилось к .col-head во всех колонках, .field label в
редакторе, .section-header в списке веток, заголовкам столбцов на странице
Отладки и заголовкам секций RAG-результата. Бейджи (АКТИВНАЯ, система)
оставлены прежними — это статусные метки, не заголовки.

Переименование ветки escalate_human для согласованности с русским UI:
«Эскалация на оператора» → «Перевод на оператора», описание тоже. Точечная
миграция при старте (intent_service.migrate_intent_copy) обновляет
существующие записи в БД, только если поле в точности совпадает со старым
значением — операторские правки не затираются.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:41:58 +05:00

1020 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Agent for Patients — Настройки</title>
<style>
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--border: #e1e4ea;
--muted: #6b7280;
--fg: #111827;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--ok: #16a34a;
--warn: #d97706;
--err: #dc2626;
--chip-bg: #eef2ff;
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
font-size: 14px;
line-height: 1.5;
display: flex;
flex-direction: column;
}
header {
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
header h1 { margin: 0; font-size: 16px; font-weight: 600; }
.nav { display: flex; gap: 4px; }
.nav-link {
text-decoration: none;
color: var(--muted);
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.nav-link:hover { background: var(--chip-bg); color: var(--fg); }
.nav-link.active { background: var(--accent); color: #fff; }
.stats {
margin-left: auto;
font-size: 13px;
color: var(--muted);
}
main {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr 380px;
gap: 0;
min-height: 0;
}
.col-panel {
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.col-panel:last-child {
border-right: none;
border-left: 1px solid var(--border);
background: var(--bg);
}
/* Правая колонка Настроек — серый фон, заголовок без рамки и стек карточек */
.col-panel:last-child > .col-head {
border-bottom: none;
padding: 16px 14px 8px 14px;
}
.col-panel:last-child > .col-head #versions-intent {
color: var(--muted);
font-weight: normal;
}
.col-panel:last-child > .col-body {
padding: 0 14px 18px 14px;
}
.col-head {
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--fg);
font-weight: 600;
letter-spacing: -0.01em;
}
.col-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Список веток */
.section-header {
padding: 10px 16px 6px;
font-size: 11px;
color: var(--muted);
font-weight: 600;
background: #fafbfd;
border-bottom: 1px solid var(--border);
}
.system-badge {
background: #fef3c7;
color: #92400e;
padding: 1px 6px;
border-radius: 10px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
}
.intent-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.intent-item:hover { background: #f9fafb; }
.intent-item.active { background: var(--chip-bg); }
.intent-item.disabled .intent-name { color: var(--muted); text-decoration: line-through; }
.intent-top {
display: flex;
align-items: center;
gap: 8px;
}
.intent-name { font-weight: 500; font-size: 13px; flex: 1; }
.intent-code { font-family: var(--mono); font-size: 10px; color: var(--muted); }
.intent-desc {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
.intent-version {
font-size: 11px;
color: var(--accent);
font-weight: 500;
margin-top: 2px;
}
.intent-version.empty { color: var(--muted); font-weight: normal; }
/* Редактор */
.editor {
padding: 20px 24px;
max-width: 900px;
}
.editor-empty {
margin: auto;
text-align: center;
color: var(--muted);
padding: 60px 20px;
font-style: italic;
}
.editor h2 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
}
.editor .sub {
font-size: 12px;
color: var(--muted);
margin-bottom: 18px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.toolbar button {
background: none;
border: 1px solid var(--border);
padding: 5px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
color: var(--fg);
}
.toolbar button:hover { background: #f9fafb; }
.toolbar .toggle { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
.field { margin-bottom: 14px; position: relative; }
.field label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--fg);
letter-spacing: -0.01em;
margin-bottom: 6px;
}
.field label.with-hint {
display: flex;
align-items: center;
gap: 6px;
}
.hint-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--border);
background: #fff;
color: var(--muted);
cursor: pointer;
font-size: 10px;
font-weight: 600;
line-height: 1;
padding: 0;
font-family: serif;
font-style: italic;
}
.hint-btn:hover { background: var(--chip-bg); color: var(--accent); border-color: var(--accent); }
.hint-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.hint-popover {
display: none;
position: absolute;
z-index: 50;
top: 28px;
left: 0;
right: 0;
max-width: 560px;
background: #fff;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
box-shadow: 0 6px 20px rgba(17, 24, 39, 0.12);
font-size: 12px;
line-height: 1.55;
color: var(--fg);
}
.hint-popover.show { display: block; }
.hint-popover h4 {
margin: 0 0 6px 0;
font-size: 12px;
font-weight: 600;
color: var(--fg);
}
.hint-popover p { margin: 0 0 6px 0; }
.hint-popover ul { margin: 4px 0 6px 0; padding-left: 18px; }
.hint-popover li { margin: 2px 0; }
.hint-popover code {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 5px;
border-radius: 4px;
font-size: 11px;
font-family: var(--mono);
}
.hint-popover .hint-close {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
color: var(--muted);
font-size: 16px;
cursor: pointer;
line-height: 1;
padding: 2px 6px;
}
.hint-popover .hint-close:hover { color: var(--fg); }
.field input[type=text], .field textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font: inherit;
font-size: 13px;
background: #fff;
outline: none;
}
.field input[type=text]:focus, .field textarea:focus { border-color: var(--accent); }
.field textarea {
font-family: var(--mono);
resize: vertical;
min-height: 200px;
}
.field textarea.prompt { min-height: 300px; }
.field textarea.rules { min-height: 140px; }
.editor-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding-top: 6px;
border-top: 1px solid var(--border);
margin-top: 10px;
padding: 14px 0 0 0;
}
.editor-actions button {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.editor-actions button:hover { background: var(--accent-hover); }
.editor-actions button.secondary { background: none; color: var(--fg); border: 1px solid var(--border); }
.editor-actions button.secondary:hover { background: #f9fafb; }
.editor-actions label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
cursor: pointer;
}
/* Версии */
.versions {
display: flex;
flex-direction: column;
gap: 10px;
}
.version-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.version-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.version-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.v-num {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 7px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
}
.v-name { font-weight: 500; font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v-name.empty { color: var(--muted); font-style: italic; font-weight: normal; }
.v-active-badge {
background: var(--ok);
color: #fff;
padding: 1px 6px;
border-radius: 10px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
}
.v-meta { font-size: 10px; color: var(--muted); margin-bottom: 6px; }
.v-actions { display: flex; gap: 4px; flex-wrap: wrap; }
.v-actions button {
background: none;
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
color: var(--fg);
}
.v-actions button:hover { background: #fff; }
.v-actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.v-actions button.primary:hover { background: var(--accent-hover); }
.v-actions button.del:hover { border-color: var(--err); color: var(--err); }
.v-actions button:disabled { opacity: 0.4; cursor: not-allowed; }
.mini { color: var(--muted); font-size: 12px; font-style: italic; padding: 14px; }
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: #111827;
color: #fff;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
z-index: 100;
}
.toast.show { opacity: 1; }
.toast.err { background: var(--err); }
/* Вкладки Промпт / Шаги */
.editor-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.editor-tab {
padding: 6px 14px;
font-size: 13px;
border: none;
background: none;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.editor-tab:hover { color: var(--fg); }
.editor-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 500;
}
/* Список шагов */
.steps-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.step-chip {
padding: 5px 11px;
border-radius: 14px;
border: 1px solid var(--border);
background: #fafbfd;
font-size: 12px;
cursor: pointer;
font-family: var(--mono);
}
.step-chip:hover { background: #fff; }
.step-chip.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.step-order { opacity: 0.6; margin-right: 4px; }
.allowed-next {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 10px 12px;
background: #fafbfd;
border: 1px solid var(--border);
border-radius: 6px;
}
.allowed-next label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-family: var(--mono);
cursor: pointer;
}
/* Свитч включён/выключен */
.switch {
position: relative;
display: inline-block;
width: 34px;
height: 18px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: #cbd5e1;
border-radius: 10px;
transition: 0.2s;
}
.slider:before {
content: "";
position: absolute;
height: 14px; width: 14px;
left: 2px; bottom: 2px;
background: white;
border-radius: 50%;
transition: 0.2s;
}
.switch input:checked + .slider { background: var(--ok); }
.switch input:checked + .slider:before { transform: translateX(16px); }
</style>
</head>
<body>
<header>
<h1>Chat Agent for Patients</h1>
<nav class="nav">
<a href="/" class="nav-link">Отладка</a>
<a href="/sandbox.html" class="nav-link">Песочница</a>
<a href="/settings.html" class="nav-link active">Настройки</a>
<a href="/docs.html" class="nav-link">Документация</a>
</nav>
<span class="stats" id="stats"></span>
</header>
<main>
<aside class="col-panel">
<div class="col-head">Ветки (intents)</div>
<div class="col-body" id="intents-list">
<div class="mini">загружаю…</div>
</div>
</aside>
<section class="col-panel">
<div class="col-head" id="editor-head">Выберите ветку слева</div>
<div class="col-body">
<div class="editor" id="editor">
<div class="editor-empty">Слева — список веток. Выберите, чтобы увидеть и отредактировать её активный промпт.</div>
</div>
</div>
</section>
<aside class="col-panel">
<div class="col-head">Версии <span id="versions-intent" style="color:var(--fg);text-transform:none;font-weight:normal;"></span></div>
<div class="col-body" id="versions">
<div class="mini">— выберите ветку —</div>
</div>
</aside>
</main>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let intents = [];
let currentIntentCode = null;
let versions = [];
let currentSteps = []; // шаги выбранной ветки (если state machine)
let currentStepCode = null; // выбранный шаг в редакторе
let activeTab = "prompt"; // "prompt" | "steps"
function toast(msg, kind = "ok") {
const t = $("toast");
t.textContent = msg;
t.className = "toast show" + (kind === "err" ? " err" : "");
setTimeout(() => t.className = "toast", 2500);
}
async function api(path, opts = {}) {
const res = await fetch(path, opts);
if (!res.ok) {
let msg = `${res.status}`;
try { const d = await res.json(); msg = d.detail || JSON.stringify(d); } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function fmtDate(iso) {
try {
const d = new Date(iso);
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit" });
} catch (_) { return iso; }
}
/* ---------- intents list ---------- */
async function refreshIntents() {
try {
const d = await api("/intents");
intents = d.intents;
renderIntents();
} catch (e) {
$("intents-list").innerHTML = `<div class="mini" style="color:var(--err)">${esc(e.message)}</div>`;
}
}
function renderIntents() {
const responders = intents.filter(i => !i.code.startsWith("_"));
const system = intents.filter(i => i.code.startsWith("_"));
const renderItem = (i, isSystem) => `
<div class="intent-item ${i.code === currentIntentCode ? 'active' : ''} ${i.is_enabled ? '' : 'disabled'}" onclick="selectIntent('${i.code}')">
<div class="intent-top">
<span class="intent-name">${esc(i.name)}</span>
${isSystem
? '<span class="system-badge" title="Системная ветка — не выключается">система</span>'
: `<label class="switch" onclick="event.stopPropagation();">
<input type="checkbox" ${i.is_enabled ? 'checked' : ''} onchange="toggleIntent('${i.code}', this.checked)">
<span class="slider"></span>
</label>`}
</div>
<div class="intent-desc">${esc(i.description)}</div>
<div class="intent-version ${i.active_config_version ? '' : 'empty'}">
<span class="intent-code">${esc(i.code)}</span>
${i.active_config_version ? `· активна v${i.active_config_version}` : '· нет активной'}
</div>
</div>
`;
let html = '<div class="section-header">Ветки-ответчики</div>';
html += responders.map(i => renderItem(i, false)).join("");
if (system.length) {
html += '<div class="section-header">Системные</div>';
html += system.map(i => renderItem(i, true)).join("");
}
$("intents-list").innerHTML = html;
}
async function toggleIntent(code, enabled) {
try {
await api(`/intents/${code}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_enabled: enabled }),
});
toast(`${code}: ${enabled ? 'включена' : 'выключена'}`);
refreshIntents();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
/* ---------- select intent ---------- */
async function selectIntent(code) {
currentIntentCode = code;
activeTab = "prompt";
currentStepCode = null;
renderIntents();
await refreshSteps(code);
renderEditor();
await refreshVersions(code);
}
async function refreshSteps(code) {
try {
const d = await api(`/intents/${encodeURIComponent(code)}/steps`);
currentSteps = d.steps || [];
} catch (_) {
currentSteps = [];
}
}
function renderEditor() {
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent) {
$("editor").innerHTML = '<div class="editor-empty">Выберите ветку слева.</div>';
$("editor-head").textContent = "Выберите ветку слева";
return;
}
$("editor-head").textContent = `${intent.name} · редактор`;
const hasSteps = currentSteps.length > 0;
const tabs = hasSteps
? `<div class="editor-tabs">
<button class="editor-tab ${activeTab === 'prompt' ? 'active' : ''}" onclick="switchTab('prompt')">Промпт</button>
<button class="editor-tab ${activeTab === 'steps' ? 'active' : ''}" onclick="switchTab('steps')">Шаги (${currentSteps.length})</button>
</div>`
: "";
let body;
if (hasSteps && activeTab === "steps") {
body = renderStepsPanel();
} else {
body = renderPromptPanel(intent);
}
$("editor").innerHTML = `
<h2>${esc(intent.name)}</h2>
<div class="sub"><code>${esc(intent.code)}</code> · ${esc(intent.description)}</div>
${tabs}
${body}
`;
// Если перешли на вкладку шагов и шаг не выбран — выбираем первый.
if (hasSteps && activeTab === "steps") {
if (!currentStepCode) currentStepCode = currentSteps[0].code;
renderStepEditor();
} else if (activeTab === "prompt") {
loadActiveIntoEditor();
}
}
function switchTab(tab) {
activeTab = tab;
renderEditor();
}
function toggleHint(key, force) {
const pop = document.getElementById(`${key}-hint-popover`);
const btn = document.getElementById(`${key}-hint-btn`);
if (!pop || !btn) return;
const willShow = typeof force === "boolean" ? force : !pop.classList.contains("show");
// Закрываем все остальные открытые подсказки — чтобы не накладывались.
document.querySelectorAll(".hint-popover.show").forEach(p => {
if (p !== pop) p.classList.remove("show");
});
document.querySelectorAll(".hint-btn.active").forEach(b => {
if (b !== btn) b.classList.remove("active");
});
pop.classList.toggle("show", willShow);
btn.classList.toggle("active", willShow);
}
// Клик вне любого popover-а — закрываем все.
document.addEventListener("click", (e) => {
const opened = document.querySelectorAll(".hint-popover.show");
if (!opened.length) return;
for (const pop of opened) {
const btn = document.getElementById(pop.id.replace("-popover", "-btn"));
if (pop.contains(e.target) || (btn && btn.contains(e.target))) return;
}
document.querySelectorAll(".hint-popover.show").forEach(p => p.classList.remove("show"));
document.querySelectorAll(".hint-btn.active").forEach(b => b.classList.remove("active"));
});
function renderPromptPanel(intent) {
return `
<div class="field">
<label for="f-name">Имя версии (необязательно)</label>
<input type="text" id="f-name" placeholder="например: после фидбэка операторов 24.04" maxlength="200">
</div>
<div class="field">
<label for="f-prompt">Системный промпт ветки</label>
<textarea id="f-prompt" class="prompt" spellcheck="false"></textarea>
</div>
<div class="field">
<label for="f-rules" class="with-hint">
<span>Правила (дополнение к промпту; свободная markdown-форма)</span>
<button type="button" class="hint-btn" id="rules-hint-btn" onclick="toggleHint('rules')" aria-label="Подсказка">i</button>
</label>
<div class="hint-popover" id="rules-hint-popover">
<button type="button" class="hint-close" onclick="toggleHint('rules', false)" aria-label="Закрыть">×</button>
<h4>Что писать в «Правила»</h4>
<p>Точечные дополнения к системному промпту в свободной markdown-форме. Технически склеиваются с основным промптом в один текст для модели — граница условная и нужна для оператора, чтобы не лазать в каркас при правке мелочей.</p>
<p><b>Что нормально писать:</b></p>
<ul>
<li>«Если пациент уже наблюдается у конкретного врача — записывай к нему по умолчанию».</li>
<li>«Бесплатная парковка для пациентов 2 часа — упомяни, если спросят».</li>
<li>«После 19:00 предлагай только следующий рабочий день».</li>
<li>«Дети до 14 лет — сразу <code>[INTENT_CHANGE: escalate_human]</code>, у нас нет педиатра».</li>
</ul>
<p><b>Что сюда не стоит:</b> изменения роли агента, тона или формата ответа — они в основном промпте. Условия выхода — отдельное поле ниже.</p>
</div>
<textarea id="f-rules" class="rules" spellcheck="false"></textarea>
</div>
<div class="field">
<label for="f-exits" class="with-hint">
<span>Условия выхода (когда отдать управление другой ветке)</span>
<button type="button" class="hint-btn" id="exits-hint-btn" onclick="toggleHint('exits')" aria-label="Подсказка">i</button>
</label>
<div class="hint-popover" id="exits-hint-popover">
<button type="button" class="hint-close" onclick="toggleHint('exits', false)" aria-label="Закрыть">×</button>
<h4>Что писать в «Условия выхода»</h4>
<p>Список ситуаций, когда ветка должна вместо обычного ответа выдать служебную строку <code>[INTENT_CHANGE: &lt;код_ветки&gt;]</code> и передать диалог другой ветке. Пишется в свободной markdown-форме, склеивается с системным промптом перед отправкой в модель.</p>
<p><b>Примеры:</b></p>
<ul>
<li>«Пациент описывает острое состояние (сильная боль, кровотечение, одышка) → <code>[INTENT_CHANGE: escalate_human]</code>».</li>
<li>«Спрашивает про цены, ДМС, оплату → <code>[INTENT_CHANGE: price_question]</code>».</li>
<li>«Просит соединить с оператором → <code>[INTENT_CHANGE: escalate_human]</code>».</li>
</ul>
<p><b>Не нужно:</b> правила для штатного хода диалога — это в «Правила». Тут только переключения между ветками.</p>
</div>
<textarea id="f-exits" class="rules" spellcheck="false"></textarea>
</div>
<div class="editor-actions">
<button onclick="saveVersion()">Сохранить как новую версию</button>
<button class="secondary" onclick="loadActiveIntoEditor()">Перезагрузить активную</button>
<label><input type="checkbox" id="chk-activate"> Сразу сделать активной</label>
</div>
`;
}
function renderStepsPanel() {
const chips = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
onclick="selectStep('${esc(s.code)}')">
<span class="step-order">${s.order_index + 1}.</span>${esc(s.code)}
</div>
`).join("");
return `
<div class="steps-chips">${chips}</div>
<div id="step-editor"></div>
`;
}
function renderStepEditor() {
const step = currentSteps.find(s => s.code === currentStepCode);
if (!step) {
$("step-editor").innerHTML = '<div class="mini">Выберите шаг выше.</div>';
return;
}
const otherCodes = currentSteps.map(s => s.code);
const allowedSet = new Set(step.allowed_next || []);
const checkboxes = otherCodes.map(code => `
<label>
<input type="checkbox" value="${esc(code)}" ${allowedSet.has(code) ? 'checked' : ''}>
${esc(code)}
</label>
`).join("");
$("step-editor").innerHTML = `
<div class="field">
<label for="f-step-name">Имя шага</label>
<input type="text" id="f-step-name" maxlength="200" value="${esc(step.name)}">
</div>
<div class="field">
<label for="f-step-prompt">Промпт шага (склеивается с промптом ветки)</label>
<textarea id="f-step-prompt" class="prompt" spellcheck="false">${esc(step.system_prompt)}</textarea>
</div>
<div class="field">
<label>Допустимые переходы (allowed_next)</label>
<div class="allowed-next" id="f-step-allowed">${checkboxes}</div>
</div>
<div class="field">
<label for="f-step-guards">Guards (JSON, наполняется в 6b — пока можно оставить <code>{}</code>)</label>
<textarea id="f-step-guards" class="rules" spellcheck="false">${esc(JSON.stringify(step.guards || {}, null, 2))}</textarea>
</div>
<div class="editor-actions">
<button onclick="saveStep()">Сохранить шаг</button>
<button class="secondary" onclick="selectStep('${esc(step.code)}')">Отменить правки</button>
</div>
`;
}
function selectStep(code) {
currentStepCode = code;
// Rerender chips для подсветки, редактор обновляется отдельно.
const panel = $("editor");
if (!panel) return;
// Перерисуем только секцию шагов.
const chipsRoot = panel.querySelector(".steps-chips");
if (chipsRoot) {
chipsRoot.innerHTML = currentSteps.map(s => `
<div class="step-chip ${s.code === currentStepCode ? 'active' : ''}"
onclick="selectStep('${esc(s.code)}')">
<span class="step-order">${s.order_index + 1}.</span>${esc(s.code)}
</div>
`).join("");
}
renderStepEditor();
}
async function saveStep() {
if (!currentIntentCode || !currentStepCode) return;
const name = $("f-step-name").value.trim();
const system_prompt = $("f-step-prompt").value;
const allowed_next = Array.from($("f-step-allowed").querySelectorAll("input[type=checkbox]:checked"))
.map(cb => cb.value);
let guards = {};
try {
guards = JSON.parse($("f-step-guards").value.trim() || "{}");
if (typeof guards !== "object" || Array.isArray(guards)) throw new Error("JSON должен быть объектом");
} catch (e) {
toast("Guards: невалидный JSON — " + e.message, "err");
return;
}
try {
const r = await api(`/intents/${encodeURIComponent(currentIntentCode)}/steps/${encodeURIComponent(currentStepCode)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, system_prompt, allowed_next, guards }),
});
toast(`Шаг ${r.code} сохранён`);
await refreshSteps(currentIntentCode);
renderEditor();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function loadActiveIntoEditor() {
if (!currentIntentCode) return;
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent || !intent.active_config_id) {
// Новая ветка без активной версии — пусто.
if ($("f-name")) {
$("f-name").value = "";
$("f-prompt").value = "";
$("f-rules").value = "";
if ($("f-exits")) $("f-exits").value = "";
}
return;
}
try {
const c = await api(`/configs/active?intent_code=${encodeURIComponent(currentIntentCode)}`);
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
$("f-prompt").value = c.system_prompt;
$("f-rules").value = c.rules_text || "";
if ($("f-exits")) $("f-exits").value = c.exit_conditions_text || "";
} catch (e) {
toast("Не удалось загрузить активную: " + e.message, "err");
}
}
function loadIntoEditor(configId) {
const c = versions.find(x => x.id === configId);
if (!c) return;
$("f-name").value = c.name ? `${c.name} (на основе v${c.version})` : `v${c.version} — копия`;
$("f-prompt").value = c.system_prompt;
$("f-rules").value = c.rules_text || "";
if ($("f-exits")) $("f-exits").value = c.exit_conditions_text || "";
toast(`Загружена v${c.version}`);
window.scrollTo({ top: 0, behavior: "smooth" });
}
/* ---------- versions ---------- */
async function refreshVersions(code) {
const intent = intents.find(i => i.code === code);
$("versions-intent").textContent = intent ? `${intent.name}` : "";
try {
const d = await api(`/configs?intent_code=${encodeURIComponent(code)}`);
versions = d.configs;
renderVersions();
} catch (e) {
$("versions").innerHTML = `<div class="mini" style="color:var(--err)">${esc(e.message)}</div>`;
}
}
function renderVersions() {
if (!versions.length) {
$("versions").innerHTML = '<div class="mini">версий ещё нет</div>';
return;
}
$("versions").innerHTML = `<div class="versions">` + versions.map(c => `
<div class="version-card ${c.is_active ? "active" : ""}">
<div class="version-head">
<span class="v-num">v${c.version}</span>
<span class="v-name ${c.name ? "" : "empty"}" title="${esc(c.name || '')}">${esc(c.name || "без имени")}</span>
${c.is_active ? '<span class="v-active-badge">активная</span>' : ""}
</div>
<div class="v-meta">${esc(fmtDate(c.created_at))} · промпт ${c.system_prompt.length} симв.${c.rules_text ? " · правил " + c.rules_text.length : ""}</div>
<div class="v-actions">
<button onclick="loadIntoEditor(${c.id})">Загрузить</button>
${!c.is_active ? `<button class="primary" onclick="activateVersion(${c.id})">Активировать</button>` : ""}
${!c.is_active ? `<button class="del" onclick="deleteVersion(${c.id}, ${c.version})">Удалить</button>` : '<button class="del" disabled title="Активную удалить нельзя">Удалить</button>'}
</div>
</div>
`).join("") + `</div>`;
}
/* ---------- save / activate / delete ---------- */
async function saveVersion() {
if (!currentIntentCode) return;
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent) return;
const name = $("f-name").value.trim();
const system_prompt = $("f-prompt").value.trim();
const rules_text = $("f-rules").value.trim();
const exit_conditions_text = $("f-exits") ? $("f-exits").value.trim() : "";
const activate = $("chk-activate").checked;
if (!system_prompt) {
toast("Системный промпт не может быть пустым", "err");
return;
}
try {
const r = await api("/configs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent_id: intent.id,
name: name || null,
system_prompt,
rules_text,
exit_conditions_text,
activate,
}),
});
toast(`v${r.version} сохранена${activate ? " · активирована" : ""}`);
$("chk-activate").checked = false;
await refreshIntents();
await refreshVersions(currentIntentCode);
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function activateVersion(id) {
try {
const r = await api(`/configs/${id}/activate`, { method: "POST" });
toast(`Активирована v${r.version}`);
await refreshIntents();
await refreshVersions(currentIntentCode);
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function deleteVersion(id, version) {
if (!confirm(`Удалить v${version}?`)) return;
try {
await api(`/configs/${id}`, { method: "DELETE" });
toast(`v${version} удалена`);
await refreshVersions(currentIntentCode);
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
/* ---------- init ---------- */
(async function init() {
await refreshIntents();
if (intents.length) selectIntent(intents[0].code);
})();
</script>
</body>
</html>