feat(sprint6b): блоки D+F — pending_guard, guard-индикаторы в UI, race-condition fix

- _eval_pending_guard() — вычисляет активный guard при незаполненных слотах
- pending_guard добавлен в ThreadStateInfo (ответы /chat и /threads/:id)
- ValidationEventInfo получил guard_name / missing_slots / guard_description
- Sandbox: amber-блок «guard активен», подсветка в validation-событиях
- openThread() защищён от race condition: if (activeThreadId !== id) return
  (исключает отрисовку устаревшего треда после переключения на новый)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-26 19:36:15 +05:00
parent c3b874dc37
commit d7ded5c9f1
4 changed files with 61 additions and 2 deletions
+10 -2
View File
@@ -683,6 +683,7 @@ async function openThread(id) {
activeThreadId = id;
try {
const d = await api(`/threads/${id}`);
if (activeThreadId !== id) return; // пользователь переключился пока шёл запрос
$("chat-title").className = "chat-title";
$("chat-title").textContent = d.name;
renderMessages(d.messages);
@@ -810,6 +811,13 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
📣 пациент несколько раз подряд уходит в боковые вопросы — на этой реплике ветка получила инструкцию вернуть его к шагу.
</div>`
: "";
const pendingGuard = state.pending_guard;
const pendingGuardHtml = pendingGuard
? `<div style="margin-top:8px;padding:6px 8px;border-radius:4px;background:#fef3c7;color:#78350f;font-size:11px;">
🔒 <b>guard активен: ${esc(pendingGuard.guard_name)}</b> — ждём заполнения: ${(pendingGuard.missing_slots || []).map(s => `<code>${esc(s)}</code>`).join(", ")}.<br>
<span style="opacity:.75;">${esc(pendingGuard.description || "")}</span>
</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>.
@@ -858,7 +866,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
<b>${esc(state.current_intent_code)}</b>
<span style="color:var(--muted);font-size:11px;margin-left:4px;">— без пошагового сценария</span>
</div>
${handoffHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
${handoffHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
return;
@@ -869,7 +877,7 @@ function renderState(state, bounces, validationEvents, parseError, routingLoopTr
<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}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
${handoffHtml}${pendingGuardHtml}${softNudgeHtml}${loopHtml}${suspendedHtml}${resumedHtml}${bounceHtml}${validationHtml}${parseErrorHtml}
</div>
`;
}