Files
RAG_helper/static/settings.html
T
AR 15 M4 932b488bcb feat(sprint6a): блоки A2, B, C — exit_conditions, handoff_count, suspended/resume
Блок A2: вынос условий выхода из основного промпта в отдельное поле
agent_configs.exit_conditions_text. compose_full_system_prompt склеивает
system_prompt + rules_text + exit_conditions_text перед отправкой в модель.
Одноразовая миграция данных при старте: пытаемся выделить блок «Условия
выхода» из хвоста существующих system_prompt-ов и перенести в новое поле
(поддерживаются три формы заголовка: «## Условия выхода», «**Условия
выхода**», просто «Условия выхода:»). В UI «Настройки» — третья textarea
с подсказкой ⓘ на отдельной кнопке.

Блок B: защита от петель маршрутизации (v2 §4.3). В thread_state добавлена
колонка handoff_count, инкрементируется на каждом hard-handoff: либо когда
роутер переключает не-sm-ветку (state reset), либо когда sm-ветка сама
выдаёт [INTENT_CHANGE: …] (bouncing). При превышении HANDOFF_CAP=3 диалог
автоматически уводится в escalate_human с шаблонным ответом «Уточню детали
с администратором клиники, свяжемся с вами в течение ближайшего часа», LLM
не вызывается, handoff_count сбрасывается. В Песочнице видны счётчик
«переключений ветки в диалоге» и красная плашка при срабатывании защиты.
Также пофикшен баг: для не-sm-веток snapshot.current_intent_code теперь
финализируется на served_code, иначе на следующей реплике prev_intent_code
терялся и handoff_count не считался.

Блок C: suspended_intent / resumable_step_code / resumable_slots_json в
thread_state (v2 §4.4). При hard-handoff из sm-ветки через [INTENT_CHANGE]
текущий сценарий запоминается (если suspended ещё не занят). Когда роутер
на следующих репликах возвращает intent = suspended_intent — RESUME:
восстанавливаем current_intent_code, current_step_code, slots; suspended_*
очищается, handoff_count=0. Возврат имеет приоритет над sticky-логикой.
В Песочнице — синяя плашка «📌 отложен сценарий X (шаг Y)» во время detour'а
и зелёная «↩️ возврат к отложенному сценарию» в момент resume. Routing-loop
guard и роутер-driven handoff не теряют suspended (только при authoritative
сценариях вроде эскалации он сбрасывается).

Прогон вручную: detour из new_booking/qualify в price_question и обратно
восстанавливает name=Алексей, reason=болит ухо на исходном шаге.

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

1003 lines
33 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); }
.col-head {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
font-weight: 600;
}
.col-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Список веток */
.section-header {
padding: 10px 16px 6px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
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: 12px;
font-weight: 500;
color: var(--muted);
margin-bottom: 4px;
}
.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 { padding: 10px; }
.version-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
background: #fafbfd;
}
.version-card.active {
background: #fff;
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>
</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>