Files
RAG_helper/static/settings.html
T
AR 15 M4 52b46bc53e feat(sprint6c+sprint7): терминология, сверка примеров с кодом, мульти-RAG (часть A)
Спринт 6c — терминология и сверка документации с реальным кодом:
- Словарь терминов в static/docs.html: «маршрутизатор» вместо «роутер»,
  «защитное условие» вместо «guard», «пошаговая ветка» вместо «многошаговая».
  Разделены концепты «намерение» (intent) и «ветка» (branch) с пометкой,
  что в коде они хранятся как одна сущность 1:1.
- Песочница: «Решение маршрутизатора» виден всегда (зелёный/жёлтый),
  счётчик переключений «N из 3» отдельной плашкой, бейджи под словарь.
- Настройки: «Условия перехода» → «Защитные условия (guards, JSON)».
- GRAPH_ARCHITECTURE_v4.md: имена полей thread_state и слоты приведены
  к реальной БД (db/models/thread_state.py) и таксономии промптов шагов
  (prompts/intents/new_booking/steps/). Ссылки на *_v2 примеры. На v3
  поставлена шапка «устарело».
- 4 примера переписаны как *_v2: реальные current_intent_code/
  current_step_code/slots_json, реальные allowed_next без двойных переходов,
  реальная таксономия слотов name/reason/specialist/preferred_time/confirmed.
  Удалены вымышленные CRM tool calls и слоты, которых нет в коде.
- static/example.html — параметризованная страница с навигацией между
  4 примерами; роут GET /api/docs/examples/{name} в main.py отдаёт
  markdown без дублирования файлов.
- Редактирование документов в Отладке: GET/PUT /documents/{id}/raw,
  textarea с переразметкой и обновлением Chroma при сохранении.

Спринт 7, часть A — мульти-RAG через подписку ветка↔документы:
- Миграция: таблица intent_documents (M:N), модель IntentDocument,
  индекс по document_id для обратного поиска.
- API: GET/PUT /intents/{code}/documents и GET/PUT /documents/{id}/intents
  с PUT-семантикой «полный список», атомарно. Сервис
  services/intent_document_service.py.
- Retrieval-фильтр в chat_service: подтягивает document_ids активной
  ветки и передаёт в vectorstore.query(). Дефолт пустой подписки —
  document_ids=[] (= 0 чанков), не «вся коллекция»: пустая подписка
  означает «ветка не настроена», подмешивать случайное хуже, чем
  ничего. vectorstore.query() различает None (нет фильтра) и [] (0).
- UI Настроек: блок «Документы базы знаний» в правом сайдбаре,
  всегда видим независимо от вкладки, сортировка по имени, счётчик
  «N из M», PUT при сохранении.
- UI Отладки: третья кнопка «привязка» рядом с «удалить» —
  раскрывашка со списком веток (галочки), быстрая привязка прямо
  на странице загрузки.
- Песочница: блок «Срез RAG» с подпиской/найдено, ворнинг при пустой
  подписке. Поле rag_subscription в QueryResponse и ChatResponse.
- Системный промпт страницы Отладки переехал в обычную ветку _debug
  («Страница отладки»). Удалён prompts/system_prompt.md и логика
  DEFAULT_SYSTEM_PROMPT в llm_client. routers/query.py подтягивает
  активный конфиг ветки _debug и её подписки. Дефолт пустой подписки
  для _debug — None (вся коллекция), не [] как для пациентских — чтобы
  Отладка работала «из коробки». На странице Отладки info-bar показывает
  активную версию и счётчик подписок, ссылка → Настройки.
- Тест-блок «Тест-вопрос» в центре Настроек: расширил /query
  параметрами intent_code (default _debug), system_prompt (override
  для теста черновика из textarea), disable_rag (для _router).
  Редактор промпта обёрнут в <details open> — можно свернуть до
  одной строки. Под ним — три колонки результата (RAG / промпт /
  ответ). Для _router показывается подсказка про отсутствие RAG.

Документы:
- data/datasets/*.md — наработки по 6 веткам (рабочие материалы оператора).
- docs/BRANCH_MAP_AND_PROMPTS_v1.md, docs/OPTIMIZATION_CONVERSION_v1.md,
  docs/guides/state_machine_and_slots.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:00:44 +05:00

1467 lines
49 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; }
/* Сворачиваемый блок промпта — Спринт 7 */
.prompt-block {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
margin-bottom: 24px;
}
.prompt-block > .prompt-block-summary {
list-style: none;
cursor: pointer;
padding: 12px 16px;
font-size: 14px;
font-weight: 600;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
.prompt-block > .prompt-block-summary::-webkit-details-marker { display: none; }
.prompt-block > .prompt-block-summary::before {
content: "▶";
font-size: 10px;
color: var(--muted);
transition: transform 0.15s;
}
.prompt-block[open] > .prompt-block-summary::before { transform: rotate(90deg); }
.prompt-block > .prompt-block-summary:hover { background: #f9fafb; }
.prompt-block[open] > .prompt-block-summary { border-bottom: 1px solid var(--border); }
.prompt-block .pbs-hint { color: var(--muted); font-weight: 400; font-size: 12px; }
.prompt-block > .field,
.prompt-block > .editor-actions { padding-left: 16px; padding-right: 16px; }
.prompt-block > .field:first-of-type { padding-top: 14px; }
.prompt-block > .editor-actions { padding-bottom: 14px; }
/* Тест-вопрос пациента — секция в центре Настроек, Спринт 7 */
.test-query {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
padding: 14px 16px 16px;
}
.test-query h3 {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: baseline;
gap: 8px;
}
.test-query .tq-meta {
font-weight: 400;
font-size: 12px;
color: var(--muted);
}
.test-query .tq-meta code {
background: var(--chip-bg);
padding: 1px 5px;
border-radius: 3px;
font-family: var(--mono);
font-size: 11.5px;
color: var(--accent);
}
.test-query .tq-rag-note {
font-size: 11.5px;
color: var(--muted);
margin-bottom: 10px;
padding: 6px 10px;
background: #fafbfd;
border-radius: 4px;
}
.test-query textarea {
width: 100%;
min-height: 70px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
resize: vertical;
}
.test-query .tq-row {
display: flex;
align-items: center;
gap: 14px;
margin: 10px 0 14px;
flex-wrap: wrap;
}
.test-query .tq-row label {
font-size: 12px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 6px;
}
.test-query .tq-num {
width: 64px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 5px;
font-size: 13px;
}
.test-query button.primary {
background: var(--accent);
color: #fff;
border: none;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.test-query button.primary:hover { background: var(--accent-hover); }
.test-query button.primary:disabled { opacity: 0.6; cursor: not-allowed; }
.test-query .tq-cols {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
@media (max-width: 1100px) {
.test-query .tq-cols { grid-template-columns: 1fr; }
}
.test-query .tq-col h4 {
margin: 0 0 6px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.test-query .tq-pane {
min-height: 80px;
max-height: 360px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
background: #fafbfd;
padding: 8px 10px;
font-size: 12.5px;
line-height: 1.5;
}
.test-query .tq-pane pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.45;
}
.test-query .tq-chunk {
border-bottom: 1px solid var(--border);
padding: 6px 0;
}
.test-query .tq-chunk:first-child { padding-top: 0; }
.test-query .tq-chunk:last-child { border-bottom: none; }
.test-query .tq-chunk-head {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
margin-bottom: 3px;
}
.test-query .tq-score { color: var(--accent); font-weight: 600; }
.test-query .tq-chunk-text { font-size: 12px; }
.test-query .tq-answer-text {
white-space: pre-wrap;
font-size: 13px;
color: var(--fg);
}
.test-query .tq-answer-meta {
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--muted);
}
.spinner {
display: inline-block;
width: 11px;
height: 11px;
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); } }
.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;
}
/* Подписка ветки на документы (Спринт 7) — в правом сайдбаре */
#docs-subscription-counter { color: var(--muted); font-size: 12px; font-weight: normal; }
#docs-subscription-counter b { color: var(--fg); font-weight: 600; }
.ds-list {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
overflow-y: auto;
max-height: 320px;
}
.ds-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-bottom: 1px solid var(--border);
cursor: pointer;
font-size: 12.5px;
line-height: 1.35;
}
.ds-item:last-child { border-bottom: none; }
.ds-item:hover { background: #f9fafb; }
.ds-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-meta {
font-size: 10.5px;
color: var(--muted);
font-family: var(--mono);
flex-shrink: 0;
}
.ds-empty {
padding: 12px;
text-align: center;
color: var(--muted);
font-size: 12px;
}
.ds-actions {
margin-top: 10px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.ds-actions button {
padding: 5px 10px;
font-size: 12px;
border: 1px solid var(--border);
background: var(--panel);
border-radius: 5px;
cursor: pointer;
}
.ds-actions button.primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.ds-hint {
font-size: 11px;
color: var(--muted);
margin-bottom: 8px;
line-height: 1.4;
}
/* Свитч включён/выключен */
.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>
<div class="col-head" style="border-top:1px solid var(--border);">Документы базы знаний <span id="docs-subscription-counter" style="color:var(--fg);text-transform:none;font-weight:normal;"></span></div>
<div class="col-body" id="docs-subscription-sidebar">
<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);
loadDocumentsForCurrentIntent();
}
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 `
<details class="prompt-block" open>
<summary class="prompt-block-summary">Системный промпт ветки <span class="pbs-hint">— редактирование, версии</span></summary>
<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>
</details>
${renderTestQueryPanel(intent)}
`;
}
function renderTestQueryPanel(intent) {
const isRouter = intent.code === "_router";
const ragHint = isRouter
? '<div class="tq-rag-note">У маршрутизатора нет RAG — тест идёт без чанков.</div>'
: '<div class="tq-rag-note">Промпт берётся из черновика выше (даже если он не сохранён). Подписки на документы — те, что сохранены в правом сайдбаре.</div>';
return `
<div class="test-query">
<h3>Тест-вопрос от пациента <span class="tq-meta">— ветка <code>${esc(intent.code)}</code></span></h3>
${ragHint}
<textarea id="tq-text" placeholder="Например: где вы находитесь?"></textarea>
<div class="tq-row">
<label>top_k <input type="number" class="tq-num" id="tq-top-k" value="5" min="1" max="20"></label>
<label>temperature <input type="number" class="tq-num" id="tq-temp" value="0.2" min="0" max="2" step="0.1"></label>
<button class="primary" id="tq-btn" onclick="runTestQuery()">Отправить</button>
<span id="tq-status" class="mini"></span>
</div>
<div class="tq-cols">
<div class="tq-col">
<h4>Что нашёл RAG</h4>
<div id="tq-chunks" class="tq-pane"><div class="mini">— пока пусто —</div></div>
</div>
<div class="tq-col">
<h4>Собранный промпт</h4>
<div id="tq-prompt" class="tq-pane"><div class="mini">— пока пусто —</div></div>
</div>
<div class="tq-col">
<h4>Ответ агента</h4>
<div id="tq-answer" class="tq-pane"><div class="mini">— пока пусто —</div></div>
</div>
</div>
</div>
`;
}
async function runTestQuery() {
const intent = intents.find(i => i.code === currentIntentCode);
if (!intent) return;
const text = $("tq-text").value.trim();
if (!text) { toast("Введите вопрос", "err"); return; }
// Собираем черновик промпта из 3 textarea — то, что оператор сейчас видит на экране.
const promptParts = [];
const fp = $("f-prompt"); if (fp && fp.value.trim()) promptParts.push(fp.value.trim());
const fr = $("f-rules"); if (fr && fr.value.trim()) promptParts.push("\n## Правила\n\n" + fr.value.trim());
const fe = $("f-exits"); if (fe && fe.value.trim()) promptParts.push("\n## Условия выхода\n\n" + fe.value.trim());
const draftPrompt = promptParts.join("\n");
const isRouter = intent.code === "_router";
const btn = $("tq-btn");
btn.disabled = true;
$("tq-status").innerHTML = '<span class="spinner"></span> думаю…';
$("tq-chunks").innerHTML = '<div class="mini">…</div>';
$("tq-prompt").innerHTML = '<div class="mini">…</div>';
$("tq-answer").innerHTML = '<div class="mini">…</div>';
try {
const r = await api("/query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text,
intent_code: intent.code,
system_prompt: draftPrompt,
disable_rag: isRouter,
top_k: parseInt($("tq-top-k").value, 10) || 5,
temperature: parseFloat($("tq-temp").value),
}),
});
$("tq-chunks").innerHTML = r.sources.length
? r.sources.map((s, i) => `
<div class="tq-chunk">
<div class="tq-chunk-head">
<span>[${i + 1}] ${esc(s.document_name)}${s.section ? " · " + esc(s.section) : ""}</span>
<span class="tq-score">${(s.relevance_score * 100).toFixed(1)}%</span>
</div>
<div class="tq-chunk-text">${esc(s.chunk_text)}</div>
</div>`).join("")
: '<div class="mini">— нет чанков —</div>';
$("tq-prompt").innerHTML = `<pre>${esc(r.assembled_prompt)}</pre>`;
const ragInfo = r.rag_subscription
? `подписано ${r.rag_subscription.subscribed_count}, найдено ${r.rag_subscription.found_count}`
: "";
$("tq-answer").innerHTML = `
<div class="tq-answer-text">${esc(r.answer)}</div>
<div class="tq-answer-meta">модель: ${esc(r.model_used)} · ${ragInfo}</div>
`;
$("tq-status").textContent = "";
} catch (e) {
$("tq-answer").innerHTML = `<div class="mini" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
$("tq-status").textContent = "";
toast("Ошибка: " + e.message, "err");
} finally {
btn.disabled = false;
}
}
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) — блокируют переход на следующий шаг, пока не заполнены нужные слоты. Пример: <code>{"require_legal_rep": {"trigger_slot": "is_child", "trigger_value": true, "required_slots": ["legal_rep_name", "legal_rep_phone"], "description": "..."}}</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("Защитные условия: невалидный 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" });
}
/* ---------- docs subscription (Спринт 7, часть A) — в правом сайдбаре ---------- */
async function loadDocumentsForCurrentIntent() {
const sidebar = $("docs-subscription-sidebar");
const counter = $("docs-subscription-counter");
if (!sidebar || !counter) return;
if (!currentIntentCode) {
sidebar.innerHTML = '<div class="mini">— выберите ветку —</div>';
counter.textContent = "";
return;
}
sidebar.innerHTML = '<div class="ds-empty">— загружаю —</div>';
counter.textContent = "";
let allDocs = [];
let subscribedIds = new Set();
try {
const [docsResp, subsResp] = await Promise.all([
api(`/documents`),
api(`/intents/${encodeURIComponent(currentIntentCode)}/documents`),
]);
allDocs = (docsResp.documents || []).slice().sort((a, b) =>
a.name.localeCompare(b.name, "ru")
);
subscribedIds = new Set(subsResp.document_ids || []);
} catch (e) {
sidebar.innerHTML = `<div class="ds-empty" style="color:var(--err)">Ошибка: ${esc(e.message)}</div>`;
return;
}
if (!allDocs.length) {
sidebar.innerHTML = `
<div class="ds-hint">только подписанные документы используются в RAG этой ветки</div>
<div class="ds-empty">Документов пока нет. Загрузите их на странице «Отладка».</div>
`;
counter.innerHTML = "<b>0</b> из <b>0</b>";
return;
}
const items = allDocs.map(d => `
<label class="ds-item">
<input type="checkbox" data-doc-id="${esc(d.document_id)}" ${subscribedIds.has(d.document_id) ? "checked" : ""} onchange="updateDocsCounter()">
<span class="ds-name" title="${esc(d.name)}">${esc(d.name)}</span>
<span class="ds-meta">${d.chunks_count} ч.</span>
</label>
`).join("");
sidebar.innerHTML = `
<div class="ds-hint">только подписанные документы используются в RAG этой ветки</div>
<div class="ds-list" id="docs-subscription-list">${items}</div>
<div class="ds-actions">
<button class="primary" onclick="saveDocumentsForCurrentIntent()">Сохранить</button>
<button onclick="loadDocumentsForCurrentIntent()">Сбросить</button>
</div>
`;
updateDocsCounter();
}
function updateDocsCounter() {
const counter = $("docs-subscription-counter");
const list = $("docs-subscription-list");
if (!counter || !list) return;
const all = list.querySelectorAll('input[type="checkbox"][data-doc-id]');
const checked = list.querySelectorAll('input[type="checkbox"][data-doc-id]:checked');
counter.innerHTML = `<b>${checked.length}</b> из <b>${all.length}</b>`;
}
async function saveDocumentsForCurrentIntent() {
if (!currentIntentCode) return;
const list = $("docs-subscription-list");
if (!list) return;
const document_ids = Array.from(
list.querySelectorAll('input[type="checkbox"][data-doc-id]:checked')
).map(cb => cb.dataset.docId);
try {
const r = await api(`/intents/${encodeURIComponent(currentIntentCode)}/documents`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ document_ids }),
});
toast(`Подписки сохранены: ${r.document_ids.length} документ(ов)`);
updateDocsCounter();
} catch (e) {
toast("Не удалось сохранить подписки: " + e.message, "err");
}
}
/* ---------- 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>