Files
RAG_helper/static/sandbox.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

930 lines
31 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;
--user-bg: #dbeafe;
--bot-bg: #f3f4f6;
}
* { 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; }
.active-config {
margin-left: auto;
padding: 4px 10px;
border-radius: 999px;
background: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
font-size: 12px;
cursor: pointer;
}
.active-config:empty { display: none; }
.active-config:hover { background: #d1fae5; }
.status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: var(--chip-bg);
font-size: 13px;
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.dot.ok { background: var(--ok); }
.dot.err { background: var(--err); }
main {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr 420px;
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-body {
padding: 14px 14px 18px 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.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;
display: flex;
align-items: center;
gap: 8px;
}
.col-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Список диалогов */
.threads-head-btn {
margin-left: auto;
background: var(--accent);
color: #fff;
border: none;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
.threads-head-btn:hover { background: var(--accent-hover); }
.thread-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.thread-item:hover { background: #f9fafb; }
.thread-item.active { background: var(--chip-bg); }
.thread-name {
font-weight: 500;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread-meta {
font-size: 11px;
color: var(--muted);
display: flex;
justify-content: space-between;
gap: 8px;
}
.thread-preview {
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread-actions {
display: flex;
gap: 6px;
margin-top: 4px;
opacity: 0;
transition: opacity 0.1s;
}
.thread-item:hover .thread-actions,
.thread-item.active .thread-actions { opacity: 1; }
.thread-actions button {
background: none;
border: 1px solid var(--border);
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
color: var(--muted);
}
.thread-actions button:hover { background: var(--panel); color: var(--fg); }
.thread-actions button.del:hover { border-color: var(--err); color: var(--err); }
/* Чат */
.chat-head {
padding: 12px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
background: var(--panel);
}
.chat-title {
font-weight: 500;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title.empty { color: var(--muted); font-weight: normal; }
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
background: var(--bg);
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.msg {
max-width: 75%;
padding: 10px 14px;
border-radius: 12px;
word-wrap: break-word;
}
.msg.user {
background: var(--user-bg);
align-self: flex-end;
white-space: pre-wrap;
}
.msg.assistant { background: var(--bot-bg); align-self: flex-start; }
.msg-intent {
display: inline-block;
background: var(--chip-bg);
color: var(--accent);
padding: 1px 7px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
font-family: var(--mono);
margin-right: 6px;
}
.msg.assistant p { margin: 0 0 8px 0; }
.msg.assistant p:last-child { margin-bottom: 0; }
.msg.assistant ul, .msg.assistant ol { margin: 6px 0; padding-left: 22px; }
.msg.assistant li { margin: 2px 0; }
.msg.assistant code {
background: #e5e7eb;
padding: 1px 5px;
border-radius: 3px;
font-family: var(--mono);
font-size: 12px;
}
.msg.assistant pre {
background: #e5e7eb;
padding: 8px 10px;
border-radius: 6px;
overflow-x: auto;
font-family: var(--mono);
font-size: 12px;
margin: 6px 0;
}
.msg.assistant pre code { background: none; padding: 0; }
.msg.assistant a { color: var(--accent); text-decoration: underline; }
.msg.assistant h1, .msg.assistant h2, .msg.assistant h3 {
margin: 8px 0 4px 0;
font-size: 14px;
font-weight: 600;
}
.msg.assistant blockquote {
border-left: 3px solid var(--border);
margin: 6px 0;
padding-left: 10px;
color: var(--muted);
}
.msg-meta { font-size: 10px; color: var(--muted); margin-top: 4px; }
.chat-empty {
margin: auto;
color: var(--muted);
text-align: center;
font-style: italic;
padding: 40px 20px;
}
.chat-input {
padding: 12px 20px;
border-top: 1px solid var(--border);
background: var(--panel);
display: flex;
gap: 10px;
}
.chat-input textarea {
flex: 1;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font: inherit;
font-size: 13px;
resize: none;
min-height: 44px;
max-height: 160px;
outline: none;
}
.chat-input textarea:focus { border-color: var(--accent); }
.chat-input button {
background: var(--accent);
color: #fff;
border: none;
padding: 0 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
align-self: stretch;
}
.chat-input button:hover { background: var(--accent-hover); }
.chat-input button:disabled { background: var(--muted); cursor: not-allowed; }
/* Правая панель — карточки */
.debug-section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.debug-section h3 {
font-size: 13px;
color: var(--fg);
margin: 0 0 10px 0;
font-weight: 600;
letter-spacing: -0.01em;
}
/* Сворачиваемая секция (details/summary) с тем же видом, что и обычная карточка */
.debug-section.collapsible > summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 13px;
color: var(--fg);
font-weight: 600;
letter-spacing: -0.01em;
}
.debug-section.collapsible[open] > summary { margin: 0 0 10px 0; }
.debug-section.collapsible > summary::-webkit-details-marker { display: none; }
.debug-section.collapsible > summary::after {
content: "⌄";
margin-left: auto;
font-size: 14px;
line-height: 1;
color: var(--muted);
transition: transform 0.15s;
}
.debug-section.collapsible[open] > summary::after { transform: rotate(180deg); }
.debug-section.collapsible > summary:hover { color: var(--accent); }
.debug-section.collapsible > summary .summary-count {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.chunk-card {
background: #fafbfd;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 8px;
font-size: 12px;
overflow: hidden;
}
.chunk-card > summary {
padding: 8px 10px;
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.chunk-card > summary::-webkit-details-marker { display: none; }
.chunk-card > summary::before {
content: "▸";
font-size: 10px;
color: var(--muted);
flex-shrink: 0;
transition: transform 0.15s;
}
.chunk-card[open] > summary::before { transform: rotate(90deg); }
.chunk-card > summary:hover { background: #f9fafb; }
.chunk-card-meta {
font-size: 10px;
color: var(--muted);
display: flex;
gap: 8px;
flex-wrap: wrap;
flex: 1;
min-width: 0;
align-items: center;
}
.chunk-card-meta .chunk-doc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.chunk-score {
background: var(--chip-bg);
color: var(--accent);
padding: 1px 6px;
border-radius: 10px;
font-weight: 600;
flex-shrink: 0;
}
.chunk-text {
padding: 0 10px 10px 10px;
white-space: pre-wrap;
word-break: break-word;
font-size: 11.5px;
color: var(--fg);
border-top: 1px solid var(--border);
padding-top: 8px;
margin-top: 2px;
max-height: 240px;
overflow-y: auto;
}
.prompt-box {
background: #fafbfd;
color: var(--fg);
border: 1px solid var(--border);
padding: 10px 12px;
border-radius: 6px;
font-family: var(--mono);
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
overflow-y: auto;
}
.mini { color: var(--muted); font-size: 12px; font-style: italic; }
.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); }
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.9/dist/purify.min.js"></script>
</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 active">Песочница</a>
<a href="/settings.html" class="nav-link">Настройки</a>
<a href="/docs.html" class="nav-link">Документация</a>
</nav>
<span class="status" style="margin-left:auto;"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
</header>
<main>
<aside class="col-panel">
<div class="col-head">
Диалоги
<button class="threads-head-btn" onclick="startNewThread()">+ новый</button>
</div>
<div class="col-body" id="threads-list">
<div class="mini" style="padding:14px;">загружаю…</div>
</div>
</aside>
<section class="col-panel">
<div class="chat-head">
<div class="chat-title empty" id="chat-title">— выберите диалог слева или начните новый —</div>
</div>
<div class="chat-messages" id="chat-messages">
<div class="chat-empty">Здесь появятся сообщения диалога.<br>Напишите что-нибудь снизу, чтобы начать.</div>
</div>
<form class="chat-input" id="chat-form">
<textarea id="chat-text" placeholder="Напишите реплику пациента и нажмите Enter..." rows="1"></textarea>
<button type="submit" id="chat-send">Отправить</button>
</form>
</section>
<aside class="col-panel">
<div class="col-body">
<div class="debug-section">
<h3>Состояние диалога</h3>
<div id="debug-state"><div class="mini">— пока пусто —</div></div>
</div>
<div class="debug-section">
<h3>Решение роутера</h3>
<div id="debug-router"><div class="mini">— пока пусто —</div></div>
</div>
<details class="debug-section collapsible" id="debug-chunks-section">
<summary>
<span>Найденные фрагменты</span>
<span class="summary-count" id="debug-chunks-count" style="display:none;">0</span>
</summary>
<div id="debug-chunks"><div class="mini">— пока пусто —</div></div>
</details>
<details class="debug-section collapsible" id="debug-prompt-section">
<summary>
<span>Собранный промпт</span>
</summary>
<div id="debug-prompt"><div class="mini">— пока пусто —</div></div>
</details>
</div>
</aside>
</main>
<div class="toast" id="toast"></div>
<script>
const API = "";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let activeThreadId = null;
function toast(msg, kind = "ok") {
const t = $("toast");
t.textContent = msg;
t.className = "toast show" + (kind === "err" ? " err" : "");
setTimeout(() => t.className = "toast", 2500);
}
function renderMd(text) {
try {
marked.setOptions({ breaks: true, gfm: true });
const html = marked.parse(text || "");
return DOMPurify.sanitize(html);
} catch (e) {
return esc(text);
}
}
async function api(path, opts = {}) {
const res = await fetch(API + 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();
}
/* ---------- health ---------- */
async function refreshHealth() {
try {
const h = await api("/health");
$("dot").className = "dot ok";
$("status-text").textContent = `${h.chunks_count} чанков · ${h.documents_count} док.`;
} catch (e) {
$("dot").className = "dot err";
$("status-text").textContent = "недоступен";
}
}
/* ---------- threads list ---------- */
async function refreshThreads() {
try {
const d = await api("/threads");
renderThreads(d.threads);
} catch (e) {
$("threads-list").innerHTML = `<div class="mini" style="padding:14px;color:var(--err)">${esc(e.message)}</div>`;
}
}
function renderThreads(threads) {
if (!threads.length) {
$("threads-list").innerHTML = '<div class="mini" style="padding:14px;">пока нет диалогов</div>';
return;
}
$("threads-list").innerHTML = threads.map(t => `
<div class="thread-item ${t.id === activeThreadId ? 'active' : ''}" onclick="openThread(${t.id})">
<div class="thread-name" title="${esc(t.name)}">${esc(t.name)}</div>
<div class="thread-meta">
<span>${esc(fmtDate(t.updated_at))}</span>
<span>${t.messages_count} сообщ.</span>
</div>
${t.first_message_preview ? `<div class="thread-preview">${esc(t.first_message_preview)}</div>` : ""}
<div class="thread-actions">
<button onclick="event.stopPropagation(); renameThread(${t.id}, ${JSON.stringify(t.name).replace(/"/g, '&quot;')})">переименовать</button>
<button class="del" onclick="event.stopPropagation(); deleteThread(${t.id}, ${JSON.stringify(t.name).replace(/"/g, '&quot;')})">удалить</button>
</div>
</div>
`).join("");
}
function fmtDate(iso) {
try {
const d = new Date(iso);
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
} catch (_) { return iso; }
}
/* ---------- open / new thread ---------- */
async function openThread(id) {
activeThreadId = id;
try {
const d = await api(`/threads/${id}`);
$("chat-title").className = "chat-title";
$("chat-title").textContent = d.name;
renderMessages(d.messages);
const lastAssistant = [...d.messages].reverse().find(m => m.role === "assistant");
if (lastAssistant) {
renderDebug(lastAssistant.sources, lastAssistant.assembled_prompt, lastAssistant.intent_code, lastAssistant.intent_name, null, null, null, [], d.thread_state && d.thread_state.current_step_code);
renderState(d.thread_state, [], [], null, false, false);
} else {
clearDebug();
}
refreshThreads();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
function startNewThread() {
activeThreadId = null;
$("chat-title").className = "chat-title empty";
$("chat-title").textContent = "— новый диалог (начните с первой реплики) —";
$("chat-messages").innerHTML = '<div class="chat-empty">Это новый диалог.<br>Напишите реплику пациента снизу, чтобы начать.</div>';
clearDebug();
refreshThreads();
}
function renderMessages(messages) {
const box = $("chat-messages");
if (!messages.length) {
box.innerHTML = '<div class="chat-empty">Пусто. Напишите первую реплику.</div>';
return;
}
box.innerHTML = messages.map(m => {
const isUser = m.role === "user";
const body = isUser ? esc(m.text) : renderMd(m.text);
const intentBadge = m.intent_code ? `<span class="msg-intent" title="${esc(m.intent_name || m.intent_code)}">${esc(m.intent_code)}</span>` : "";
return `
<div class="msg ${isUser ? "user" : "assistant"}">
<div class="msg-body">${body}</div>
<div class="msg-meta">${intentBadge}${esc(fmtDate(m.created_at))}</div>
</div>
`;
}).join("");
box.scrollTop = box.scrollHeight;
}
function appendMessage(role, text, iso, intentCode, intentName) {
const box = $("chat-messages");
const empty = box.querySelector(".chat-empty");
if (empty) empty.remove();
const div = document.createElement("div");
const isUser = role === "user";
div.className = "msg " + (isUser ? "user" : "assistant");
const body = isUser ? esc(text) : renderMd(text);
const intentBadge = intentCode ? `<span class="msg-intent" title="${esc(intentName || intentCode)}">${esc(intentCode)}</span>` : "";
div.innerHTML = `<div class="msg-body">${body}</div><div class="msg-meta">${intentBadge}${esc(fmtDate(iso || new Date().toISOString()))}</div>`;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
return div;
}
/* ---------- отладка ---------- */
function renderState(state, bounces, validationEvents, parseError, routingLoopTriggered, resumedFromSuspended) {
const box = $("debug-state");
if (!state || !state.current_intent_code) {
box.innerHTML = '<div class="mini">сценарий ещё не запущен</div>';
return;
}
const handoff = Number(state.handoff_count || 0);
const handoffHtml = `
<div style="margin-top:6px;font-size:11px;color:var(--muted);">
переключений ветки в диалоге: <b style="color:var(--fg);">${handoff}</b>
</div>`;
const loopHtml = routingLoopTriggered
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fee2e2;color:#7f1d1d;font-size:11px;">
🛑 защита от петли сработала: диалог уведён в <code>escalate_human</code>.
</div>`
: "";
const suspendedSlotsCount = Object.keys(state.resumable_slots || {}).length;
const suspendedHtml = state.suspended_intent
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#eff6ff;color:#1e3a8a;font-size:11px;">
📌 отложен сценарий: <code>${esc(state.suspended_intent)}</code>${state.resumable_step_code ? ' (шаг <code>' + esc(state.resumable_step_code) + '</code>)' : ''}, слотов: <b>${suspendedSlotsCount}</b>. Вернёмся, когда пациент возвратится к этой теме.
</div>`
: "";
const resumedHtml = resumedFromSuspended
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#ecfdf5;color:#065f46;font-size:11px;">
↩️ возврат к отложенному сценарию: восстановили шаг и слоты.
</div>`
: "";
const bounceHtml = (bounces && bounces.length)
? `<div style="margin-top:8px;font-size:11px;">
<div style="color:var(--muted);margin-bottom:3px;">переходы в этой реплике:</div>
${bounces.map(b => `<div>• <b>${esc(b.from)}</b> → <b>${esc(b.to)}</b>${b.preface ? ` <span style="color:var(--muted);">(«${esc(b.preface).slice(0,60)}»)</span>` : ''}</div>`).join("")}
</div>`
: "";
const validationHtml = (validationEvents && validationEvents.length)
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef2f2;color:#991b1b;font-size:11px;">
${validationEvents.map(v => `⚠️ модель просилась в <code>${esc(v.requested_step)}</code>, оставили на <code>${esc(v.current_step)}</code>. ${esc(v.reason)}`).join("<br>")}
</div>`
: "";
const parseErrorHtml = parseError
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
⚠️ парсер: ${esc(parseError)}
</div>`
: "";
// Ветки без state machine (general_info, price_question и т.д.) шаги не ведут —
// показываем только intent, чтобы не путать пустым «шаг №0 · {}».
if (!state.current_step_code) {
box.innerHTML = `
<div style="font-size:12px;">
<div>
<b>${esc(state.current_intent_code)}</b>
<span style="color:var(--muted);font-size:11px;margin-left:4px;">— без пошагового сценария</span>
</div>
${handoffHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
return;
}
const slotsJson = JSON.stringify(state.slots || {}, null, 2);
box.innerHTML = `
<div style="font-size:12px;">
<div><b>${esc(state.current_intent_code)}</b> · шаг <code>${esc(state.current_step_code)}</code></div>
<div class="prompt-box" style="margin-top:6px;max-height:200px;">${esc(slotsJson)}</div>
${handoffHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
}
function renderDebug(sources, prompt, intentCode, intentName, configVersion, routerVersion, routerIntentCode, bounces, stepCode) {
const routerVer = routerVersion != null ? `роутер v${routerVersion}` : "роутер";
const hasBounces = bounces && bounces.length > 0;
const routerDiffers = routerIntentCode && intentCode && routerIntentCode !== intentCode;
// Три разных исхода — объясняем отдельно, чтобы не путать «sticky» и «bouncing».
let verdict;
if (hasBounces) {
// Ветка сама выдала INTENT_CHANGE — bounce через [INTENT_CHANGE: ...].
const chain = bounces.map(b => `<code>${esc(b.from)}</code> → <code>${esc(b.to)}</code>`).join(", ");
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;line-height:1.5;">
${routerVer} сказал <code>${esc(routerIntentCode)}</code>.<br>
Ветка сама выдала <code>[INTENT_CHANGE]</code> и передала управление: ${chain}.
</div>`;
} else if (routerDiffers) {
// Удержались в ветке: диалог в сценарии, роутер хотел переключить, но мы остались.
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;line-height:1.5;">
${routerVer} предложил <code>${esc(routerIntentCode)}</code>.<br>
Но диалог идёт по сценарию <code>${esc(intentCode)}</code>${stepCode ? ' (шаг <code>' + esc(stepCode) + '</code>)' : ''}
<b>удержались в ветке</b>: модель получила подсказку и осталась в сценарии.
</div>`;
} else {
// Обычный случай — роутер попал в ту же ветку.
verdict = `<div style="color:var(--muted);font-size:11px;margin-top:4px;">
${routerVer} → та же ветка.
</div>`;
}
const routerLine = intentCode
? `<div style="padding:10px 14px;background:#ecfdf5;font-size:12px;border-radius:6px;">
<div><b>${esc(intentCode)}</b> — ${esc(intentName || '')}${configVersion ? ' · ветка v' + configVersion : ''}</div>
${verdict}
</div>`
: "";
$("debug-router").innerHTML = routerLine || '<div class="mini">— маршрутизация пока не выполнена —</div>';
const count = $("debug-chunks-count");
if (sources && sources.length) {
count.textContent = sources.length;
count.style.display = "";
$("debug-chunks").innerHTML = sources.map(s => `
<details class="chunk-card">
<summary>
<div class="chunk-card-meta">
<span class="chunk-score">${(s.relevance_score * 100).toFixed(1)}%</span>
<span class="chunk-doc">${esc(s.document_name || "—")}</span>
${s.section ? `<span>${esc(s.section)}</span>` : ""}
</div>
</summary>
<div class="chunk-text">${esc(s.chunk_text)}</div>
</details>
`).join("");
} else {
count.style.display = "none";
$("debug-chunks").innerHTML = '<div class="mini">источников нет</div>';
}
$("debug-prompt").innerHTML = prompt
? `<div class="prompt-box">${esc(prompt)}</div>`
: '<div class="mini">промпт пуст</div>';
}
function clearDebug() {
$("debug-state").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-router").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-chunks").innerHTML = '<div class="mini">— пока пусто —</div>';
$("debug-prompt").innerHTML = '<div class="mini">— пока пусто —</div>';
}
/* ---------- send message ---------- */
$("chat-form").addEventListener("submit", async (e) => {
e.preventDefault();
sendMessage();
});
$("chat-text").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
async function sendMessage() {
const txt = $("chat-text").value.trim();
if (!txt) return;
$("chat-text").value = "";
$("chat-send").disabled = true;
$("chat-send").innerHTML = '<span class="spinner"></span>';
const userBubble = appendMessage("user", txt);
const pending = appendMessage("assistant", "…");
pending.style.opacity = "0.6";
try {
const body = { text: txt, top_k: 5 };
if (activeThreadId) body.thread_id = activeThreadId;
const r = await api("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
activeThreadId = r.thread_id;
pending.remove();
appendMessage("assistant", r.answer, null, r.intent_code, r.intent_name);
$("chat-title").className = "chat-title";
$("chat-title").textContent = r.thread_name;
renderDebug(r.sources, r.assembled_prompt, r.intent_code, r.intent_name, r.config_version, r.router_version, r.router_intent_code, r.bounces, r.thread_state && r.thread_state.current_step_code);
renderState(r.thread_state, r.bounces, r.validation_events, r.parse_error, r.routing_loop_triggered, r.resumed_from_suspended);
refreshThreads();
} catch (e) {
// Откатываем визуально: убираем пузырь-заглушку ассистента и только что
// добавленную реплику пациента — на бекенде весь запрос уже откатился (rollback).
pending.remove();
userBubble.remove();
// Если после удаления пузырей чат стал пустым — вернём плейсхолдер.
const box = $("chat-messages");
if (!box.querySelector(".msg")) {
box.innerHTML = activeThreadId
? '<div class="chat-empty">Пусто. Напишите первую реплику.</div>'
: '<div class="chat-empty">Это новый диалог.<br>Напишите реплику пациента снизу, чтобы начать.</div>';
}
// Возвращаем текст в поле ввода — не заставлять пользователя перепечатывать.
$("chat-text").value = txt;
toast("Ошибка: " + e.message, "err");
} finally {
$("chat-send").disabled = false;
$("chat-send").textContent = "Отправить";
}
}
/* ---------- rename / delete ---------- */
async function renameThread(id, currentName) {
const newName = prompt("Новое имя диалога:", currentName);
if (!newName || newName.trim() === currentName) return;
try {
await api(`/threads/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
toast("Переименован");
if (id === activeThreadId) {
$("chat-title").textContent = newName.trim();
}
refreshThreads();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
async function deleteThread(id, name) {
if (!confirm(`Удалить диалог «${name}» со всеми сообщениями?`)) return;
try {
const r = await api(`/threads/${id}`, { method: "DELETE" });
toast(`Удалён (${r.deleted_messages} сообщ.)`);
if (id === activeThreadId) startNewThread();
else refreshThreads();
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
/* ---------- init ---------- */
refreshHealth();
refreshThreads();
setInterval(refreshHealth, 15000);
</script>
</body>
</html>