sage-api / server /static /index.html
hiasgnpsadgd's picture
Deploy current MIMIC-only SAGE backend
de0f30b verified
Raw
History Blame Contribute Delete
89.2 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SAGE SQL Generator</title>
<style>
:root {
--navy: #12365a;
--action: #2f6bff;
--cyan: #007dba;
--teal: #55c8d1;
--slate: #5e7d9c;
--gold: #f6c744;
--white: #ffffff;
--border: #d7dee8;
--bg: #f5f8fc;
--text: #172334;
--muted: #66778a;
--danger: #b42318;
--success: #087443;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 22px;
background: var(--navy);
color: var(--white);
border-bottom: 4px solid var(--gold);
}
h1 {
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 20px;
font-weight: 720;
}
button {
min-height: 36px;
border: 0;
border-radius: 7px;
padding: 8px 12px;
background: var(--action);
color: var(--white);
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 700;
}
button:hover {
filter: brightness(0.96);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.secondary {
background: #edf3fa;
color: var(--navy);
border: 1px solid var(--border);
}
.header-actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
}
.cyan {
background: var(--cyan);
}
.gold {
background: var(--gold);
color: #273444;
}
main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
height: calc(100vh - 64px);
padding: 16px;
}
.panel {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
background: var(--white);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 10px 24px rgba(18, 54, 90, 0.08);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 52px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.panel-title {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
color: var(--navy);
font-size: 15px;
font-weight: 740;
}
.accent-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--teal);
box-shadow: 0 0 0 3px rgba(85, 200, 209, 0.22);
}
.toolbar,
.footer-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--border);
}
.toolbar {
border-top: 0;
border-bottom: 1px solid var(--border);
}
.footer-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
.run-settings {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: end;
padding: 10px 14px;
border-top: 1px solid var(--border);
background: #f8fbff;
}
.run-field {
display: grid;
gap: 4px;
min-width: min(260px, 100%);
color: var(--navy);
font-size: 11px;
font-weight: 760;
text-transform: uppercase;
}
.run-field.hidden {
display: none;
}
.run-field select,
.run-field input {
min-height: 36px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--white);
color: var(--text);
padding: 7px 9px;
font: inherit;
font-size: 13px;
font-weight: 600;
text-transform: none;
}
.output-tabs {
display: flex;
gap: 6px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
background: #f8fbff;
}
.tab-button {
min-height: 32px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--navy);
padding: 6px 10px;
}
.tab-button.active {
border-color: var(--cyan);
background: #e8f5fb;
color: var(--cyan);
}
textarea {
flex: 1;
min-height: 0;
width: 100%;
resize: none;
border: 0;
outline: none;
padding: 16px;
color: var(--text);
background: var(--white);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 14px;
line-height: 1.5;
}
.sql-shell {
flex: 1;
min-height: 0;
background: var(--slate);
}
.sql-shell.idle {
display: grid;
place-items: center;
padding: 28px;
color: var(--white);
text-align: center;
}
.sql-shell.running {
display: grid;
place-items: center;
padding: 28px;
background: linear-gradient(135deg, var(--slate), var(--cyan));
color: var(--white);
text-align: center;
}
pre {
height: 100%;
margin: 0;
overflow: auto;
padding: 18px;
background: #102c49;
color: #f8fbff;
font-size: 13px;
line-height: 1.5;
white-space: pre;
}
.sql-keyword {
color: #55c8d1;
font-weight: 760;
}
.sql-string {
color: #f6c744;
}
.sql-number {
color: #a7f3d0;
}
.sql-comment {
color: #9fb6cc;
font-style: italic;
}
.sql-function {
color: #93c5fd;
}
.sql-punctuation {
color: #d7dee8;
}
.sql-clause-link {
border-bottom: 1px dotted var(--teal);
color: #bfecff;
cursor: pointer;
font-weight: 760;
}
.sql-clause-link:hover {
color: var(--gold);
}
.progress-card {
display: grid;
gap: 12px;
width: min(680px, 100%);
}
.stage-count {
color: var(--gold);
font-size: 15px;
font-weight: 800;
text-transform: uppercase;
}
.stage-label {
color: var(--white);
font-size: 22px;
font-weight: 760;
}
.stage-note {
color: #e5f4ff;
font-size: 13px;
}
.progress-segments {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
overflow: hidden;
border-radius: 7px;
}
.progress-segment {
min-height: 11px;
background: rgba(255, 255, 255, 0.28);
}
.progress-segment.stage-01.done,
.progress-segment.stage-01.running {
background: #55c8d1;
}
.progress-segment.stage-02.done,
.progress-segment.stage-02.running {
background: #f6c744;
}
.progress-segment.stage-03.done,
.progress-segment.stage-03.running {
background: #8bd17c;
}
.progress-segment.running {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5);
}
.stage-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.stage-pill {
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 7px;
padding: 8px;
text-align: left;
}
.stage-pill strong {
display: block;
color: var(--white);
font-size: 12px;
}
.stage-pill span {
color: #e5f4ff;
font-size: 11px;
text-transform: uppercase;
}
.event-note {
border-left: 3px solid var(--gold);
padding-left: 10px;
color: #f8fbff;
font-size: 13px;
text-align: left;
}
.review-view {
height: 100%;
overflow: auto;
background: var(--white);
color: var(--text);
padding: 14px;
}
.empty-state {
display: grid;
height: 100%;
place-items: center;
padding: 28px;
color: var(--muted);
text-align: center;
}
.review-list {
display: grid;
gap: 10px;
}
.review-item {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--white);
padding: 12px;
}
.review-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0;
}
.chip {
border-radius: 999px;
background: #edf3fa;
color: var(--navy);
padding: 3px 8px;
font-size: 11px;
font-weight: 760;
}
.chip-button {
min-height: 0;
border: 0;
cursor: pointer;
font: inherit;
}
.chip-button:hover {
background: #e3f2fb;
filter: none;
}
.review-text {
color: var(--text);
font-size: 13px;
line-height: 1.45;
}
.review-target {
margin-top: 2px;
color: var(--navy);
font-size: 15px;
font-weight: 780;
line-height: 1.3;
overflow-wrap: anywhere;
}
.review-requirement {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.compact-list {
margin: 8px 0 0;
padding-left: 18px;
color: var(--text);
font-size: 13px;
line-height: 1.45;
}
.candidate-table {
display: grid;
gap: 6px;
margin-top: 10px;
}
.candidate-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 8px;
align-items: center;
border: 1px solid #e8eef6;
border-radius: 7px;
padding: 8px;
background: #fbfdff;
font-size: 12px;
}
.candidate-row.selected {
border-color: var(--cyan);
box-shadow: 0 0 0 2px rgba(0, 125, 186, 0.15);
background: #f2fbff;
}
.candidate-browser {
display: grid;
gap: 8px;
}
.candidate-browser button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
width: 100%;
min-height: 32px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--white);
color: var(--text);
padding: 5px 8px;
font-size: 12px;
font-weight: 720;
text-align: left;
cursor: pointer;
}
.candidate-browser button:hover {
border-color: var(--cyan);
}
.candidate-browser-count {
color: var(--muted);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.candidate-browser-options {
display: grid;
gap: 6px;
}
.candidate-browser-actions {
display: grid;
gap: 6px;
}
.repair-row {
border-style: dashed;
}
.repair-row.has-text {
border-color: var(--gold);
background: #fffaf0;
}
.candidate-check {
display: grid;
place-items: center;
}
.candidate-check input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--cyan);
}
.repair-indicator {
width: 16px;
height: 16px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--white);
}
.repair-indicator.checked {
border-color: var(--gold);
background: var(--gold);
box-shadow: inset 0 0 0 3px var(--white);
}
.candidate-title {
min-width: 0;
}
.candidate-title strong {
display: block;
overflow-wrap: anywhere;
}
.candidate-title span {
color: var(--muted);
}
.candidate-variants {
display: grid;
gap: 3px;
margin-top: 5px;
color: var(--muted);
font-size: 11px;
line-height: 1.35;
}
.candidate-variant {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 5px;
align-items: start;
overflow-wrap: anywhere;
}
.candidate-variant input {
width: 14px;
height: 14px;
margin: 1px 0 0;
accent-color: var(--cyan);
}
.candidate-variant.selected {
color: var(--navy);
}
.candidate-variant-toggle {
width: fit-content;
min-height: 26px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--white);
color: var(--navy);
padding: 4px 7px;
font-size: 11px;
font-weight: 720;
cursor: pointer;
}
.option-target-terms {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 5px;
}
.target-term {
border: 1px solid #cfe0f1;
border-radius: 999px;
background: #f5f9fe;
color: var(--navy);
padding: 2px 7px;
font-size: 11px;
font-weight: 720;
line-height: 1.25;
}
.repair-row textarea {
width: 100%;
height: 34px;
min-height: 34px;
max-height: 220px;
margin-top: 6px;
resize: none;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 7px;
padding: 8px 9px;
font: inherit;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.repair-row textarea:focus {
min-height: 62px;
outline: 2px solid rgba(0, 125, 186, 0.24);
outline-offset: 1px;
}
.status {
display: inline-flex;
align-items: center;
min-height: 22px;
border-radius: 999px;
padding: 2px 8px;
background: #edf3f8;
color: var(--slate);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.status.succeeded {
background: #e8f6ef;
color: var(--success);
}
.status.failed {
background: #fdecec;
color: var(--danger);
}
.status.running,
.status.queued,
.status.cancel_requested {
background: #fff6d8;
color: #7a5800;
}
.sidebar {
position: fixed;
top: 64px;
bottom: 0;
left: 0;
z-index: 20;
width: 340px;
max-width: 88vw;
transform: translateX(-100%);
transition: transform 160ms ease;
background: var(--white);
border-right: 1px solid var(--border);
box-shadow: 14px 0 30px rgba(18, 54, 90, 0.18);
}
.sidebar.open {
transform: translateX(0);
}
.explanation-sidebar {
position: fixed;
top: 64px;
right: 0;
bottom: 0;
z-index: 22;
display: flex;
width: 430px;
max-width: 92vw;
transform: translateX(100%);
transition: transform 160ms ease;
flex-direction: column;
background: var(--white);
border-left: 1px solid var(--border);
box-shadow: -14px 0 30px rgba(18, 54, 90, 0.16);
}
.explanation-sidebar.open {
transform: translateX(0);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.explanation-content {
display: grid;
align-content: start;
gap: 14px;
overflow: auto;
padding: 14px;
}
.explanation-section {
display: grid;
gap: 8px;
}
.explanation-section h3 {
margin: 0;
color: var(--navy);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.explanation-sentence {
margin: 0;
color: var(--text);
font-size: 13px;
line-height: 1.45;
}
.retrieved-list {
display: grid;
gap: 7px;
}
.retrieved-item {
border: 1px solid #e8eef6;
border-radius: 7px;
background: #fbfdff;
padding: 8px;
font-size: 12px;
line-height: 1.35;
}
.retrieved-item strong {
display: block;
overflow-wrap: anywhere;
}
.retrieved-item span {
color: var(--muted);
}
.history-list {
display: grid;
gap: 8px;
height: calc(100% - 52px);
overflow: auto;
padding: 12px;
}
.history-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 28px;
gap: 8px;
align-items: start;
border: 1px solid var(--border);
border-left: 4px solid var(--slate);
border-radius: 7px;
background: var(--white);
padding: 9px;
}
.history-item.active {
border-color: var(--cyan);
border-left-color: var(--cyan);
box-shadow: 0 0 0 3px rgba(0, 125, 186, 0.12);
}
.history-main {
min-width: 0;
border: 0;
background: transparent;
color: var(--text);
padding: 0;
text-align: left;
font-weight: 650;
}
.history-delete {
min-width: 28px;
min-height: 28px;
border-radius: 50%;
padding: 0;
background: transparent;
color: var(--muted);
font-size: 16px;
line-height: 1;
}
.history-delete:hover {
background: #fdecec;
color: var(--danger);
}
.history-prompt {
display: -webkit-box;
margin-top: 6px;
overflow: hidden;
color: var(--text);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.muted {
color: var(--muted);
font-size: 12px;
}
.toast {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 30;
display: none;
max-width: 340px;
border: 1px solid var(--border);
border-left: 5px solid var(--teal);
border-radius: 8px;
background: var(--white);
box-shadow: 0 14px 32px rgba(18, 54, 90, 0.16);
padding: 12px 14px;
color: var(--navy);
font-size: 13px;
font-weight: 700;
}
@media (max-width: 860px) {
body {
overflow: auto;
}
main {
grid-template-columns: 1fr;
height: auto;
min-height: calc(100vh - 64px);
}
.panel {
min-height: 520px;
}
.stage-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
header {
gap: 10px;
padding: 0 12px;
}
h1 {
font-size: 16px;
}
.header-actions {
gap: 6px;
}
.header-actions button {
min-height: 34px;
padding: 7px 9px;
font-size: 12px;
}
}
/* detailed per-stage sub-steps in the running progress card */
.stage-steps { display: grid; gap: 11px; margin: 12px auto 4px; text-align: left; max-width: 440px; }
.stage-steps-group { display: grid; gap: 3px; }
.ssg-title { font-weight: 720; font-size: 13px; color: #fff; display: flex; align-items: center; gap: 8px; }
.ssg-state { font-size: 10px; text-transform: uppercase; padding: 1px 7px; border-radius: 999px; background: rgba(255,255,255,0.18); font-weight: 800; }
.ssg-state.done { background: rgba(8,118,67,0.5); } .ssg-state.running { background: rgba(246,199,68,0.42); color: #3a2c00; }
.substep { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #cfe2f2; padding-left: 6px; }
.substep .dot { width: 11px; height: 11px; border-radius: 50%; border: 2px solid #9fc7e8; flex: none; }
.substep.done .dot { background: var(--gold); border-color: var(--gold); }
.substep.done { color: #fff; }
.substep.active { color: #fff; font-weight: 700; }
.substep.active .dot { border-color: #fff; animation: pulse 1.1s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0.55); } 50% { box-shadow: 0 0 0 5px rgba(255,255,255,0); } }
.run-toggle { flex-direction: row !important; align-items: center; gap: 7px; cursor: pointer; }
.run-toggle input { width: 16px; height: 16px; }
/* clause-sidebar audit trace (input -> S1 -> S2 -> S3) + word->source */
.explanation-section.atrace h3 { display: flex; align-items: center; gap: 8px; }
.atrace-path { display: flex; flex-wrap: wrap; gap: 0; margin: 8px 0 10px; }
.atrace-node { position: relative; border-radius: 7px; padding: 6px 9px; color: #fff; font-size: 11px; margin: 0 20px 8px 0; max-width: 190px; }
.atrace-node:after { content: "→"; position: absolute; right: -16px; top: 50%; transform: translateY(-50%); color: #5e7d9c; font-weight: 800; font-size: 15px; }
.atrace-node:last-child:after { content: ""; }
.atrace-node .atrace-tag { font-size: 9px; font-weight: 800; text-transform: uppercase; opacity: 0.92; }
.atrace-node.n-input { background: #5e7d9c; } .atrace-node.n-s1 { background: #2f6bff; }
.atrace-node.n-s2 { background: #007dba; } .atrace-node.n-s3 { background: #087443; }
.atrace-ws { font-size: 12px; margin: 6px 0; color: #172334; line-height: 1.4; } .atrace-ws b { color: #12365a; }
.atrace-kv { font-size: 12px; margin: 2px 0; color: #44566b; }
.atrace-stages { display: inline-flex; gap: 4px; }
.atrace-stage { font-size: 9px; font-weight: 800; padding: 1px 5px; border-radius: 4px; background: #e3e9f1; color: #9aa7b8; }
.atrace-stage.on.s1 { background: #2f6bff; color: #fff; } .atrace-stage.on.s2 { background: #007dba; color: #fff; } .atrace-stage.on.s3 { background: #087443; color: #fff; }
.atrace-summary { margin: 4px 0 12px; font-size: 14px; line-height: 1.5; color: #12243a; font-weight: 600; }
.atrace-detail { margin: 10px 0; }
.atrace-tag2 { font-size: 9px; font-weight: 800; text-transform: uppercase; color: #8a98a8; margin-bottom: 4px; }
.atrace-detail blockquote { margin: 0; padding: 8px 12px; border-left: 3px solid #2f6bff; background: #f3f6fc; border-radius: 0 6px 6px 0; color: #172334; font-size: 13px; line-height: 1.45; }
.atrace-kv { margin: 3px 0; }
.atrace-kv code { background: #eef2f8; padding: 1px 5px; border-radius: 4px; font-size: 11px; color: #2f4666; }
.atrace-badge { font-size: 9px; font-weight: 800; text-transform: uppercase; background: #087443; color: #fff; padding: 1px 5px; border-radius: 4px; margin-left: 4px; }
</style>
</head>
<body>
<header>
<h1>SAGE SQL Generator</h1>
<div class="header-actions">
<button id="create-new-button" class="secondary" type="button">Create new</button>
<button id="history-toggle" class="secondary" type="button">History</button>
</div>
</header>
<aside id="history-sidebar" class="sidebar">
<div class="sidebar-header">
<strong>History</strong>
<button id="history-close" class="secondary" type="button">Close</button>
</div>
<div id="history-list" class="history-list"></div>
</aside>
<aside id="clause-sidebar" class="explanation-sidebar" aria-hidden="true">
<div class="sidebar-header">
<strong id="clause-sidebar-title">Details</strong>
<button id="clause-close" class="secondary" type="button">Close</button>
</div>
<div id="clause-sidebar-content" class="explanation-content">
<div class="empty-state">Click a generated clause name to view details.</div>
</div>
</aside>
<main>
<section class="panel">
<div class="panel-header">
<h2 class="panel-title"><span class="accent-dot"></span>Request</h2>
</div>
<div class="toolbar">
<button class="secondary" type="button" data-insert="## ">Heading</button>
<button class="secondary" type="button" data-insert="- ">Bullet</button>
<button class="secondary" type="button" data-insert="1. ">Numbered</button>
</div>
<textarea id="request-text" spellcheck="true" aria-label="Cohort request"></textarea>
<div class="run-settings">
<label class="run-field">
Model
<select id="llm-route-select" aria-label="LLM model">
<option value="">Select a model</option>
<option value="openrouter-owl-alpha-free">OpenRouter · Owl Alpha free</option>
<option value="openrouter-nemotron-3-super-free">OpenRouter · Nemotron 3 Super free</option>
<option value="azure-default">Azure OpenAI default</option>
<option value="local-vllm-default">Local vLLM default</option>
<option value="qwen-vllm-default">Qwen vLLM default</option>
<option value="openrouter-custom">OpenRouter custom model</option>
</select>
</label>
<label id="llm-custom-model-field" class="run-field hidden">
Custom model
<input id="llm-custom-model" type="text" autocomplete="off" placeholder="provider/model:variant" />
</label>
<label class="run-field run-toggle" title="Pause after Stage 2 to review/adjust the pipeline's pre-selected source candidates before generating SQL.">
<span>Human review (Stage 2)</span>
<input type="checkbox" id="hitl-toggle" checked />
</label>
</div>
<div class="footer-actions">
<button id="run-button" type="button">Run</button>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h2 class="panel-title"><span class="accent-dot"></span>Output</h2>
<span id="selected-status" class="status">Idle</span>
</div>
<div class="output-tabs" role="tablist" aria-label="Output tabs">
<button class="tab-button active" type="button" data-output-tab="sql">SQL</button>
<button class="tab-button" type="button" data-output-tab="review">Candidate review</button>
</div>
<div id="sql-container" class="sql-shell idle">
Generated SQL will appear here.
</div>
<div class="footer-actions">
<button id="copy-button" class="cyan" type="button" disabled>Copy</button>
<button id="save-review-button" class="cyan" type="button" disabled>Save checklist</button>
<button id="generate-sql-button" type="button" disabled>Generate SQL</button>
<button id="retry-button" class="gold" type="button" disabled>Retry</button>
</div>
</section>
</main>
<div id="toast" class="toast"></div>
<script>
let selectedRunId = null;
let selectedRun = null;
let currentSql = "";
let pollTimer = null;
let activeOutputTab = "sql";
let confirmationState = null;
let reviewDirty = false;
let reviewExpandedState = {};
let reviewVariantExpandedState = {};
let clauseExplanationState = null;
const terminalStates = new Set(["succeeded", "failed", "cancelled"]);
const defaultVisibleCandidateCount = 15;
const additionalCandidatePageSize = 15;
const $ = (id) => document.getElementById(id);
$("create-new-button").addEventListener("click", createNewRequest);
$("history-toggle").addEventListener("click", () => $("history-sidebar").classList.add("open"));
$("history-close").addEventListener("click", () => $("history-sidebar").classList.remove("open"));
$("clause-close").addEventListener("click", closeClauseSidebar);
$("llm-route-select").addEventListener("change", updateLlmRouteControls);
$("run-button").addEventListener("click", createRun);
$("copy-button").addEventListener("click", copySql);
$("retry-button").addEventListener("click", retryRun);
$("save-review-button").addEventListener("click", saveHumanConfirmation);
$("generate-sql-button").addEventListener("click", generateSql);
$("sql-container").addEventListener("click", handleOutputClick);
$("sql-container").addEventListener("keydown", handleOutputKeyDown);
$("sql-container").addEventListener("change", handleOutputChange);
$("sql-container").addEventListener("focusin", handleOutputFocusIn);
$("sql-container").addEventListener("focusout", handleOutputFocusOut);
$("sql-container").addEventListener("input", handleOutputInput);
for (const button of document.querySelectorAll("[data-output-tab]")) {
button.addEventListener("click", () => setActiveOutputTab(button.dataset.outputTab));
}
for (const button of document.querySelectorAll("[data-insert]")) {
button.addEventListener("click", () => insertAtCursor($("request-text"), button.dataset.insert));
}
async function createRun() {
const requestText = $("request-text").value.trim();
if (!requestText) {
showToast("Enter a request first.");
return;
}
const llmSettings = selectedLlmSettings();
if (!llmSettings) return;
$("run-button").disabled = true;
try {
const response = await fetch("/api/runs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_text: requestText,
case_id: "webui_request",
require_human_confirmation: $("hitl-toggle") ? $("hitl-toggle").checked : true,
stage2_top_k: 100,
stage2_llm_verify_results: false,
stage2_llm_filter_noise: false,
...llmSettings,
}),
});
if (!response.ok) throw new Error(await response.text());
const run = await response.json();
selectedRunId = run.run_id;
confirmationState = null;
reviewExpandedState = {};
reviewVariantExpandedState = {};
clauseExplanationState = null;
closeClauseSidebar();
reviewDirty = false;
setActiveOutputTab("sql", false);
renderPipelineState(run);
await refreshHistory();
startPolling();
} catch (error) {
showToast(error.message || String(error));
} finally {
$("run-button").disabled = false;
}
}
function updateLlmRouteControls() {
const custom = $("llm-route-select").value === "openrouter-custom";
$("llm-custom-model-field").classList.toggle("hidden", !custom);
if (custom) $("llm-custom-model").focus();
}
function selectedLlmSettings() {
const route = $("llm-route-select").value;
const customModel = $("llm-custom-model").value.trim();
if (!route) {
showToast("Select a model for this run.");
$("llm-route-select").focus();
return null;
}
if (route === "openrouter-owl-alpha-free") {
return {
llm_provider: "openrouter",
llm_model: "openrouter/owl-alpha",
llm_response_format: "json_schema",
};
}
if (route === "openrouter-nemotron-3-super-free") {
return {
llm_provider: "openrouter",
llm_model: "nvidia/nemotron-3-super-120b-a12b:free",
llm_response_format: "json_schema",
};
}
if (route === "openrouter-custom") {
if (!customModel) {
showToast("Enter an OpenRouter model id.");
$("llm-custom-model").focus();
return null;
}
return {
llm_provider: "openrouter",
llm_model: customModel,
llm_response_format: "none",
};
}
if (route === "azure-default") return { llm_provider: "azure" };
if (route === "local-vllm-default") return { llm_provider: "local-vllm" };
if (route === "qwen-vllm-default") return { llm_provider: "qwen" };
return null;
}
async function refreshHistory() {
const response = await fetch("/api/runs");
const runs = await response.json();
const list = $("history-list");
list.innerHTML = "";
if (!runs.length) {
list.innerHTML = '<div class="muted">No saved runs.</div>';
return;
}
for (const run of runs) {
const row = document.createElement("div");
row.className = "history-item" + (run.run_id === selectedRunId ? " active" : "");
const open = document.createElement("button");
open.className = "history-main";
open.type = "button";
open.innerHTML = `
<div>${escapeHtml(run.run_id)}</div>
<div class="muted">${escapeHtml(run.state)} · ${formatDate(run.updated_at)}</div>
<div class="history-prompt">${escapeHtml(run.original_prompt_preview || "")}</div>
`;
open.addEventListener("click", () => selectRun(run.run_id));
const remove = document.createElement("button");
remove.className = "history-delete";
remove.type = "button";
remove.title = "Delete";
remove.textContent = "x";
// Always deletable (force-remove): a queued/running/cancel-requested run can
// wedge and would otherwise block the single-worker queue and the UI.
remove.disabled = false;
remove.addEventListener("click", (event) => {
event.stopPropagation();
deleteRun(run.run_id);
});
row.appendChild(open);
row.appendChild(remove);
list.appendChild(row);
}
}
async function selectRun(runId) {
selectedRunId = runId;
confirmationState = null;
reviewVariantExpandedState = {};
clauseExplanationState = null;
closeClauseSidebar();
reviewDirty = false;
$("history-sidebar").classList.remove("open");
await renderSelectedRun();
await refreshHistory();
startPolling();
}
async function createNewRequest() {
resetActiveRun();
$("request-text").value = "";
$("history-sidebar").classList.remove("open");
await refreshHistory();
$("request-text").focus();
}
function resetActiveRun() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
selectedRunId = null;
selectedRun = null;
confirmationState = null;
reviewVariantExpandedState = {};
clauseExplanationState = null;
closeClauseSidebar();
reviewDirty = false;
currentSql = "";
setActiveOutputTab("sql", false);
setStatus("idle");
setIdle("Generated SQL will appear here.");
updateFooter(activeOutputTab, null);
}
async function renderSelectedRun() {
if (!selectedRunId) return;
const response = await fetch(`/api/runs/${selectedRunId}`);
if (!response.ok) return;
selectedRun = await response.json();
if (selectedRun.original_prompt) {
$("request-text").value = selectedRun.original_prompt;
}
if (isAwaitingConfirmation(selectedRun) && activeOutputTab === "sql") {
setActiveOutputTab("review", false);
}
await renderActiveOutput(selectedRun);
}
async function renderActiveOutput(run) {
updateTabButtons();
if (!terminalStates.has(run.state)) {
renderPipelineState(run);
return;
}
if (activeOutputTab === "review") {
await renderConfirmation(run);
} else {
await renderSql(run);
}
}
async function renderSql(run) {
currentSql = "";
updateFooter("sql", run);
setStatus(run.state);
const sqlPath = run.artifacts && run.artifacts.stage3_sql;
if (!sqlPath) {
setIdle(isAwaitingConfirmation(run) ? "Review and save candidate checklist items, then generate SQL." : (run.error || "No SQL output is available for this run."));
return;
}
const response = await fetch(`/api/runs/${run.run_id}/artifacts/${sqlPath}`);
if (!response.ok) {
setIdle("SQL output is not available.");
return;
}
currentSql = await response.text();
await ensureClauseExplanationData(run);
await ensureLineage(run);
buildCteClauseMap(run);
updateFooter("sql", run);
$("sql-container").className = "sql-shell";
$("sql-container").innerHTML = `<pre><code>${highlightSql(currentSql)}</code></pre>`;
}
async function renderConfirmation(run) {
currentSql = "";
updateFooter("review", run);
setStatus(run.state);
if (!confirmationState || confirmationState._run_id !== run.run_id) {
const response = await fetch(`/api/runs/${run.run_id}/human-confirmation`);
if (!response.ok) {
setViewHtml("sql-shell review-view", '<div class="empty-state">Candidate review is available after Stage 2 completes.</div>');
return;
}
confirmationState = await response.json();
confirmationState._run_id = run.run_id;
reviewExpandedState = {};
reviewVariantExpandedState = {};
prepareConfirmationState();
reviewDirty = false;
}
renderConfirmationState();
}
function renderConfirmationState() {
const items = (confirmationState && confirmationState.review_items) || [];
updateFooter("review", selectedRun);
if (!items.length) {
setViewHtml("sql-shell review-view", '<div class="empty-state">No non-metadata concept candidates need confirmation for this run.</div>');
return;
}
setViewHtml("sql-shell review-view", `
<div class="review-list">
${items.map((item, itemIndex) => renderReviewItem(item, itemIndex)).join("")}
</div>
`);
}
function renderReviewItem(item, itemIndex) {
const review = item.human_review || {};
const repairText = repairInstructions(review);
const target = item.target || {};
const targetTerms = targetTermsForItem(item);
const targetText = targetTerms.length ? targetTerms.join(" / ") : item.target_text || target.text || "";
const requirementText = item.normalized_sentence || item.semantic_summary || "";
const targetKind = item.target_kind || target.kind || "";
const appliesToCount = new Set(item.applies_to_clause_ids || []).size;
const visibleIndexes = visibleCandidateIndexes(item, itemIndex);
return `
<section class="review-item">
<div class="review-meta">
${item.clause_id ? `<button class="chip chip-button" type="button" data-explain-clause="${escapeHtml(item.clause_id)}">${escapeHtml(item.clause_id)}</button>` : ""}
<span class="chip">${escapeHtml(item.domain || "")}</span>
<span class="chip">${escapeHtml(item.source_id || "")}</span>
${targetKind ? `<span class="chip">${escapeHtml(targetKind)}</span>` : ""}
${appliesToCount > 1 ? `<span class="chip">${appliesToCount} criteria</span>` : ""}
</div>
${targetText ? `<div class="review-target">${escapeHtml(targetText)}</div>` : ""}
<div class="review-requirement">${escapeHtml(requirementText)}</div>
<div class="candidate-table">
${visibleIndexes.map((candidateIndex) => renderCandidate(item, item.candidates[candidateIndex], itemIndex, candidateIndex)).join("")}
${renderCandidateBrowser(item, itemIndex, visibleIndexes)}
<div class="candidate-row repair-row ${repairText ? "has-text" : ""}">
<span class="candidate-check repair-indicator ${repairText ? "checked" : ""}" aria-hidden="true"></span>
<div class="candidate-title">
<strong>Retrieval repair request</strong>
<textarea data-review-repair="${itemIndex}" rows="1" wrap="soft" placeholder="Type repair request">${escapeHtml(repairText)}</textarea>
</div>
</div>
</div>
</section>
`;
}
function visibleCandidateIndexes(item, itemIndex) {
const candidates = item.candidates || [];
const visible = new Set();
for (let index = 0; index < Math.min(defaultVisibleCandidateCount, candidates.length); index += 1) {
visible.add(index);
}
const selected = new Set((item.human_review && item.human_review.selected_row_uids) || []);
candidates.forEach((candidate, index) => {
if (selected.has(candidate.row_uid)) visible.add(index);
if ((candidate.child_candidates || []).some((child) => selected.has(child.row_uid))) visible.add(index);
});
return [...visible].sort((left, right) => left - right);
}
function renderCandidateBrowser(item, itemIndex, visibleIndexes) {
const candidates = item.candidates || [];
const visible = new Set(visibleIndexes);
const hiddenIndexes = candidates.map((_, index) => index).filter((index) => !visible.has(index));
if (!hiddenIndexes.length) return "";
const additionalCount = reviewAdditionalCountForItem(item, itemIndex);
const revealedIndexes = hiddenIndexes.slice(0, additionalCount);
const remainingCount = hiddenIndexes.length - revealedIndexes.length;
const shownCount = visibleIndexes.length + revealedIndexes.length;
const showMoreButton = remainingCount > 0 ? `
<button type="button" data-review-more="${itemIndex}" aria-expanded="${additionalCount > 0 ? "true" : "false"}">
<span>Show ${Math.min(additionalCandidatePageSize, remainingCount)} more options</span>
<span class="candidate-browser-count">${shownCount}/${candidates.length}</span>
</button>
` : "";
const hideButton = additionalCount > 0 ? `
<button type="button" data-review-collapse="${itemIndex}" aria-expanded="true">
<span>Hide additional retrieved options</span>
<span class="candidate-browser-count">${visibleIndexes.length}/${candidates.length}</span>
</button>
` : "";
return `
<div class="candidate-browser">
${revealedIndexes.length ? `
<div class="candidate-browser-options">
${revealedIndexes.map((candidateIndex) => renderCandidate(item, candidates[candidateIndex], itemIndex, candidateIndex)).join("")}
</div>
` : ""}
<div class="candidate-browser-actions">
${showMoreButton}
${hideButton}
</div>
</div>
`;
}
function reviewItemKey(item, itemIndex) {
return item.review_item_id || `${item.clause_id || "item"}:${item.task_id || itemIndex}`;
}
function reviewAdditionalCountForItem(item, itemIndex) {
return Number(reviewExpandedState[reviewItemKey(item, itemIndex)] || 0);
}
function renderCandidate(item, candidate, itemIndex, candidateIndex) {
const review = item.human_review || {};
const selected = new Set(review.selected_row_uids || []);
const rowUid = candidate.row_uid || "";
const isSelected = selected.has(rowUid);
const detailLine = candidateDetailLine(candidate);
return `
<div class="candidate-row ${isSelected ? "selected" : ""}">
<label class="candidate-check" title="Select for Stage 3">
<input type="checkbox" aria-label="Select ${escapeHtml(candidate.label || candidate.code || rowUid)}" data-review-selected="${itemIndex}:${candidateIndex}" ${isSelected ? "checked" : ""} />
</label>
<div class="candidate-title">
<strong>${escapeHtml(candidate.label || candidate.code || rowUid)}</strong>
<span>${escapeHtml([candidate.code, candidate.vocabulary].filter(Boolean).join(" · "))}</span>
${detailLine ? `<span>${escapeHtml(detailLine)}</span>` : ""}
${renderCandidateVariants(item, candidate, itemIndex, candidateIndex)}
</div>
</div>
`;
}
function candidateDetailLine(candidate) {
if (candidate && candidate.group_size > 1) {
const included = Number(candidate.included_variant_count || 0);
const total = Number(candidate.group_size || 0);
return included && included !== total ? `${total} variants · ${included} included` : `${total} variants`;
}
const details = (candidate && candidate.lab_details) || {};
return labDetailLine(details);
}
function renderCandidateVariants(item, candidate, itemIndex, candidateIndex) {
const children = (candidate && candidate.child_candidates) || [];
if (!children.length) return "";
const expanded = reviewVariantsExpandedForCandidate(item, candidate, itemIndex);
const visible = expanded ? children : children.slice(0, 5);
const remaining = children.length - visible.length;
return `
<div class="candidate-variants">
${visible.map((child, childIndex) => renderCandidateVariant(item, candidate, child, itemIndex, candidateIndex, childIndex)).join("")}
${children.length > 5 ? `
<button class="candidate-variant-toggle" type="button" data-review-variants="${itemIndex}:${candidateIndex}">
${expanded ? "Hide specific options" : `Show ${remaining} more specific options`}
</button>
` : ""}
</div>
`;
}
function renderCandidateVariant(item, candidate, child, itemIndex, candidateIndex, childIndex) {
const review = item.human_review || {};
const selected = new Set(review.selected_row_uids || []);
const parentSelected = selected.has(candidate.row_uid || "");
const included = new Set(candidate.included_source_row_uids || candidate.source_row_uids || []);
const childUid = child.row_uid || "";
const checked = selected.has(childUid) || (parentSelected && included.has(childUid));
return `
<label class="candidate-variant ${checked ? "selected" : ""}">
<input type="checkbox" data-review-child-selected="${itemIndex}:${candidateIndex}:${childIndex}" ${checked ? "checked" : ""} />
<span>${escapeHtml(candidateVariantLine(child))}</span>
</label>
`;
}
function candidateVariantLine(child) {
const details = child.lab_details || {};
const parts = [child.label, [child.code, child.vocabulary].filter(Boolean).join(" · "), labDetailLine(details)].filter(Boolean);
return parts.join(" · ");
}
function reviewVariantsExpandedForCandidate(item, candidate, itemIndex) {
return Boolean(reviewVariantExpandedState[reviewVariantKey(item, candidate, itemIndex)]);
}
function reviewVariantKey(item, candidate, itemIndex) {
return `${reviewItemKey(item, itemIndex)}:${candidate.row_uid || ""}`;
}
function labDetailLine(details) {
const parts = [];
if (details.specimen) parts.push(`specimen ${details.specimen}`);
if (details.topography) parts.push(`topography ${details.topography}`);
if (details.consensus) parts.push(`consensus ${details.consensus}`);
if (details.sta3n) parts.push(`sta3n ${details.sta3n}`);
return parts.join(" · ");
}
function targetTermsForItem(item) {
const target = item.target || {};
const terms = (item.target_terms && item.target_terms.length)
? item.target_terms
: target.terms && target.terms.length
? target.terms
: [];
return terms.map((term) => String(term || "").trim()).filter(Boolean).slice(0, 2);
}
async function ensureClauseExplanationData(run) {
if (!run) return null;
if (clauseExplanationState && clauseExplanationState.run_id === run.run_id) return clauseExplanationState;
const state = { run_id: run.run_id, entriesByKey: {} };
const blockPath = run.artifacts && run.artifacts.stage3_block_explanations;
if (blockPath) {
try {
const response = await fetch(`/api/runs/${run.run_id}/artifacts/${blockPath}`);
if (response.ok) {
const payload = await response.json();
for (const block of payload.blocks || []) {
const retrieval = block.retrieval_summary || {};
addClauseExplanationEntry(state, [block.clause_id, block.block_label], {
normalizedSentence: block.normalized_sentence || block.semantic_summary || "",
retrievedRows: rowsFromConcepts(retrieval.top_concepts || []),
});
}
}
} catch (_) {
// Details remain optional; the main SQL view should still render.
}
}
try {
const response = await fetch(`/api/runs/${run.run_id}/human-confirmation`);
if (response.ok) {
const payload = await response.json();
for (const item of payload.review_items || []) {
addClauseExplanationEntry(state, [item.clause_id, ...(item.applies_to_clause_ids || [])], {
normalizedSentence: item.normalized_sentence || item.semantic_summary || "",
retrievedRows: rowsFromReviewItem(item),
});
}
}
} catch (_) {
// Runs without review candidates can still expose Stage 3 details.
}
clauseExplanationState = state;
return state;
}
function addClauseExplanationEntry(state, keys, entry) {
const cleanKeys = keys.map((key) => String(key || "").trim()).filter(Boolean);
if (!cleanKeys.length) return;
const existing = cleanKeys
.map((key) => state.entriesByKey[normalizeClauseLookupKey(key)])
.find(Boolean);
const target = existing || { normalizedSentence: "", retrievedRows: [] };
if (!target.normalizedSentence && entry.normalizedSentence) {
target.normalizedSentence = entry.normalizedSentence;
}
target.retrievedRows = normalizeRetrievedRows([...(target.retrievedRows || []), ...(entry.retrievedRows || [])]);
for (const key of cleanKeys) {
state.entriesByKey[normalizeClauseLookupKey(key)] = target;
}
}
function normalizeClauseLookupKey(value) {
return String(value || "").trim().toLowerCase();
}
function clauseExplanationForKey(key) {
if (!clauseExplanationState) return null;
return clauseExplanationState.entriesByKey[normalizeClauseLookupKey(key)] || null;
}
function rowsFromConcepts(concepts) {
return concepts.map((concept) => ({
label: concept.label || concept.display || "",
code: concept.code || "",
vocabulary: concept.vocabulary || "",
}));
}
function rowsFromReviewItem(item) {
const rows = [];
for (const candidate of item.candidates || []) {
rows.push({
label: candidate.label || "",
code: candidate.code || "",
vocabulary: candidate.vocabulary || "",
});
}
return rows;
}
function normalizeRetrievedRows(rows) {
const output = [];
const seen = new Set();
for (const row of rows || []) {
const label = String(row.label || "").trim();
const code = String(row.code || "").trim();
const vocabulary = String(row.vocabulary || "").trim();
if (!label && !code) continue;
const key = `${label.toLowerCase()}|${code.toLowerCase()}|${vocabulary.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
output.push({ label, code, vocabulary });
}
return output;
}
let lineageCache = {};
let cteClauseMap = {};
let currentClauseLineage = null;
async function ensureLineage(run) {
if (lineageCache[run.run_id]) return lineageCache[run.run_id];
try {
const r = await fetch(`/api/runs/${run.run_id}/lineage`);
if (r.ok) lineageCache[run.run_id] = await r.json();
} catch (e) {}
return lineageCache[run.run_id] || null;
}
// Map every CTE name in the generated SQL to the clause (source sentence) that
// produced it, so CTE tokens become clickable and resolve to their requirement.
function buildCteClauseMap(run) {
cteClauseMap = {};
const lin = lineageCache[run.run_id];
if (!lin || !lin.clauses) return;
for (const c of lin.clauses) {
for (const cte of (c.stage3 || {}).ctes || []) {
cteClauseMap[normalizeClauseLookupKey(cte)] = c.id;
}
}
}
// Fallback explanation entry derived straight from lineage when a clicked token
// (e.g. a CTE name) has no pre-built explanation keyed by clause id.
function entryFromLineage(c) {
const s1 = c.stage1 || {}, s2 = c.stage2 || {};
const rows = [];
for (const vs of s2.value_sets || []) {
for (const code of vs.sample_codes || []) {
rows.push({ label: vs.concept, code, vocabulary: vs.vocabulary });
}
}
return {
normalizedSentence: s1.normalized_text || s1.semantic_summary || "",
retrievedRows: rows,
};
}
function findLineageClause(lin, key) {
if (!lin || !lin.clauses) return null;
return lin.clauses.find((c) => c.id === key)
|| lin.clauses.find((c) => ((c.stage3 || {}).ctes || []).includes(key))
|| null;
}
// Collapse near-duplicate value sets: a clean concept ("Type 2 Diabetes") and its
// lexically-expanded twin ("type 2 diabetes mellitus …") share the same source_id
// and leading code, so key on those. Keep the cleanest (shortest) name and the
// broadest code count; drop empty sets (they matched nothing).
function dedupeConcepts(valueSets) {
const byKey = new Map();
for (const v of valueSets || []) {
if (!String(v.concept || "").trim() || !(v.code_count > 0)) continue;
const key = String(v.source_id || "") + "|" + ((v.sample_codes || [])[0] || "");
const existing = byKey.get(key);
if (!existing) {
byKey.set(key, { ...v });
continue;
}
if (String(v.concept).length < String(existing.concept).length) existing.concept = v.concept;
if ((v.code_count || 0) > (existing.code_count || 0)) {
existing.code_count = v.code_count;
existing.sample_codes = v.sample_codes;
}
}
return [...byKey.values()];
}
// Strip the repeated synonyms a lexically-expanded retrieval query leaves behind
// ("glucose blood glucose serum glucose …" → "glucose blood serum …").
function cleanConceptName(name) {
const seen = new Set();
const out = [];
for (const w of String(name || "").trim().split(/\s+/)) {
const k = w.toLowerCase();
if (k && !seen.has(k)) { seen.add(k); out.push(w); }
}
return out.join(" ");
}
function buildAuditTrace(c) {
if (!c) return "";
const s1 = c.stage1 || {}, s2 = c.stage2 || {}, s3 = c.stage3 || {};
const src = (s1.normalized_text || "").trim();
const summary = (c.label || s1.semantic_summary || "").trim();
const operation = String(c.operation || "").replace(/_/g, " ").trim();
const valueSets = dedupeConcepts(s2.value_sets || []);
const totalCodes = valueSets.reduce((n, v) => n + (v.code_count || 0), 0);
const ctes = s3.ctes || [];
const clip = (t, n) => (t.length > n ? t.slice(0, n - 1).trimEnd() + "…" : t);
const node = (cls, tag, body) => `<div class="atrace-node ${cls}"><div class="atrace-tag">${escapeHtml(tag)}</div><div>${escapeHtml(body)}</div></div>`;
// 1) One graph — the request-to-SQL flow at a glance (no relations / SIDs / CTE names).
const matchedLabel = totalCodes ? `${totalCodes} code${totalCodes === 1 ? "" : "s"}` : (s2.present ? "concepts" : "—");
const sqlLabel = ctes.length ? `${ctes.length} step${ctes.length === 1 ? "" : "s"}` : (s3.produced ? "SQL" : "—");
const on = [!!src, !!s2.present, !!s3.produced];
const path = `<div class="atrace-path">
${node("n-input", "Request", src ? "sentence" : "request")}
${node("n-s1", "Intent", operation || "clause")}
${node("n-s2", "Matched", matchedLabel)}
${node("n-s3", "SQL", sqlLabel)}
</div>`;
// 2) One summary — the plain-English description of what this clause does.
const summaryBlock = summary ? `<p class="atrace-summary">${escapeHtml(summary)}</p>` : "";
// 3) Readable details — the source sentence + matched concepts (no internals).
const sourceBlock = src
? `<div class="atrace-detail"><div class="atrace-tag2">From the request</div><blockquote>&ldquo;${escapeHtml(src)}&rdquo;</blockquote></div>`
: "";
const conceptBlock = valueSets.length
? `<div class="atrace-detail"><div class="atrace-tag2">Matched concepts</div>${valueSets.map((v) => {
const concept = clip(cleanConceptName(v.concept) || "concept", 46);
const vocab = String(v.vocabulary || "").trim();
const readable = (v.sample_codes || []).filter((code) => /[A-Za-z.]/.test(String(code)));
const samples = readable.slice(0, 5).join(", ");
const more = readable.length > 5 ? ", …" : "";
const label = `${v.code_count || 0}${vocab ? " " + escapeHtml(vocab) : ""} code${(v.code_count === 1) ? "" : "s"}`;
return `<div class="atrace-kv"><b>${escapeHtml(concept)}</b> &middot; ${label}${samples ? ` <span class="muted">(${escapeHtml(samples)}${more})</span>` : ""}${v.curated ? ' <span class="atrace-badge">curated</span>' : ""}</div>`;
}).join("")}</div>`
: "";
const stages = `<span class="atrace-stages">${[1, 2, 3].map((n, i) => `<span class="atrace-stage s${n} ${on[i] ? "on" : ""}">S${n}</span>`).join("")}</span>`;
return `<section class="explanation-section atrace">
<h3>How this was derived ${stages}</h3>
${path}${summaryBlock}${sourceBlock}${conceptBlock}
</section>`;
}
async function openClauseExplanation(key) {
if (!selectedRun) return;
await ensureClauseExplanationData(selectedRun);
const lin = await ensureLineage(selectedRun);
currentClauseLineage = findLineageClause(lin, key);
// CTE tokens aren't keyed by their own name; resolve via the owning clause id,
// then fall back to deriving the entry from lineage.
let entry = clauseExplanationForKey(key);
if (!entry && currentClauseLineage) entry = clauseExplanationForKey(currentClauseLineage.id);
if (!entry && currentClauseLineage) entry = entryFromLineage(currentClauseLineage);
if (!entry && !currentClauseLineage) {
showToast("Details are not available for this clause.");
return;
}
renderClauseExplanation(entry || { normalizedSentence: "", retrievedRows: [] });
$("clause-sidebar").classList.add("open");
$("clause-sidebar").setAttribute("aria-hidden", "false");
}
function closeClauseSidebar() {
$("clause-sidebar").classList.remove("open");
$("clause-sidebar").setAttribute("aria-hidden", "true");
}
function renderClauseExplanation(entry) {
$("clause-sidebar-title").textContent = "Details";
// With lineage, the audit trace already tells the whole story (graph + summary +
// readable details), so show just that — no duplicate requirement or code dump.
const trace = buildAuditTrace(currentClauseLineage);
if (trace) {
$("clause-sidebar-content").innerHTML = trace;
return;
}
// Fallback when lineage is unavailable: the requirement and a few readable codes.
const sentence = entry.normalizedSentence || "No details are available for this clause.";
const rows = normalizeRetrievedRows(entry.retrievedRows || [])
.filter((r) => r.label || /[A-Za-z.]/.test(r.code || ""))
.slice(0, 8);
$("clause-sidebar-content").innerHTML = `
<section class="explanation-section">
<h3>Requirement</h3>
<p class="explanation-sentence">${escapeHtml(sentence)}</p>
</section>
${rows.length ? `
<section class="explanation-section">
<h3>Matched</h3>
<div class="retrieved-list">${rows.map(renderRetrievedRow).join("")}</div>
</section>` : ""}
`;
}
function renderRetrievedRow(row) {
const codeLine = [row.code, row.vocabulary].filter(Boolean).join(" · ");
return `
<div class="retrieved-item">
<strong>${escapeHtml(row.label || row.code)}</strong>
${codeLine ? `<span>${escapeHtml(codeLine)}</span>` : ""}
</div>
`;
}
async function saveHumanConfirmation(successMessage = "Checklist saved.") {
if (!selectedRun || !confirmationState) return false;
deriveAllReviewStatuses();
const response = await fetch(`/api/runs/${selectedRun.run_id}/human-confirmation`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(confirmationState),
});
if (!response.ok) {
showToast(await response.text());
return false;
}
confirmationState = await response.json();
confirmationState._run_id = selectedRun.run_id;
prepareConfirmationState();
reviewDirty = false;
updateFooter("review", selectedRun);
showToast(successMessage);
await refreshHistory();
renderConfirmationState();
return true;
}
async function generateSql() {
if (!selectedRun || !isAwaitingConfirmation(selectedRun)) return;
if (!confirmationReadyForStage3()) {
showToast("Select at least one candidate for every criterion and leave repair text empty, or save to request retrieval repair.");
return;
}
if (reviewDirty) {
const saved = await saveHumanConfirmation();
if (!saved) return;
}
const response = await fetch(`/api/runs/${selectedRun.run_id}/generate-sql`, { method: "POST" });
if (!response.ok) {
showToast(await response.text());
return;
}
const run = await response.json();
selectedRun = run;
clauseExplanationState = null;
closeClauseSidebar();
setActiveOutputTab("sql", false);
renderPipelineState(run);
await refreshHistory();
startPolling();
}
const STAGE_SUBSTEPS = {
stage_01: [["Extract obligations", "raw_ir"], ["Decompose & check", "stage_01_findings"], ["Finalize IR", "cohortbuild_ir"]],
stage_02: [["Plan retrieval", "retrieval_tasks"], ["Retrieve candidates", "concept_candidates"], ["Clean & verify", "clause_retrieval_context"], ["Bind sources", "binding_candidates"]],
stage_03: [["Compose clauses", "compile_trace"], ["Assemble SQL", "final.sql"]],
};
const STAGE_STEP_LABELS = { stage_01: "Stage 1 · Understand request", stage_02: "Stage 2 · Bind sources", stage_03: "Stage 3 · Generate SQL" };
async function renderPipelineState(run) {
selectedRun = run;
currentSql = "";
updateFooter(activeOutputTab, run);
setStatus(run.state);
$("sql-container").className = "sql-shell running";
const progress = stageProgress(run);
const segs = stageDetails(run);
// sub-step completion is inferred from which step artifacts already exist
let paths = [];
try { const a = await (await fetch(`/api/runs/${run.run_id}/artifacts`)).json(); paths = (a || []).map((x) => x.path || x.name || ""); } catch (e) {}
if (selectedRun !== run) return;
const hasMarker = (m) => paths.some((p) => p.includes(m));
const order = ["stage_01", "stage_02", "stage_03"];
$("sql-container").innerHTML = `
<div class="progress-card">
<div class="progress-segments">
${segs.map((stage) => `<div class="progress-segment ${stage.name} ${stage.visualState}"></div>`).join("")}
</div>
<div class="stage-count">Stage ${progress.index}/3</div>
<div class="stage-label">${escapeHtml(progress.label)}</div>
<div class="stage-note">${escapeHtml(progress.note)}</div>
<div class="stage-steps">
${order.map((name) => renderStageSteps(name, (run.stages || {})[name] || {}, hasMarker)).join("")}
</div>
<div class="event-note">${escapeHtml(currentActivity(run))}</div>
</div>
`;
}
function renderStageSteps(name, stageRec, hasMarker) {
const steps = STAGE_SUBSTEPS[name] || [];
const st = stageRec.state || "pending";
const done = steps.map(([, marker]) => hasMarker(marker) || st === "succeeded");
let activeIdx = -1;
if (st === "running") { activeIdx = done.findIndex((d) => !d); if (activeIdx === -1) activeIdx = steps.length - 1; }
const stateCls = st === "succeeded" ? "done" : st;
return `<div class="stage-steps-group">
<div class="ssg-title">${escapeHtml(STAGE_STEP_LABELS[name] || name)} <span class="ssg-state ${stateCls}">${escapeHtml(st)}</span></div>
${steps.map(([label], j) => `<div class="substep ${done[j] ? "done" : j === activeIdx ? "active" : ""}"><span class="dot"></span>${escapeHtml(label)}</div>`).join("")}
</div>`;
}
async function retryRun() {
if (!selectedRunId) return;
const response = await fetch(`/api/runs/${selectedRunId}/retry`, { method: "POST" });
if (!response.ok) {
showToast(await response.text());
return;
}
const run = await response.json();
selectedRunId = run.run_id;
confirmationState = null;
reviewVariantExpandedState = {};
clauseExplanationState = null;
closeClauseSidebar();
reviewDirty = false;
setActiveOutputTab("sql", false);
renderPipelineState(run);
await refreshHistory();
startPolling();
}
async function deleteRun(runId) {
const response = await fetch(`/api/runs/${runId}`, { method: "DELETE" });
if (!response.ok) {
showToast(await response.text());
return;
}
if (selectedRunId === runId) {
selectedRunId = null;
selectedRun = null;
confirmationState = null;
reviewVariantExpandedState = {};
clauseExplanationState = null;
closeClauseSidebar();
reviewDirty = false;
currentSql = "";
setStatus("idle");
setIdle("Generated SQL will appear here.");
updateFooter(activeOutputTab, null);
}
await refreshHistory();
}
async function copySql() {
if (!currentSql) return;
await navigator.clipboard.writeText(currentSql);
showToast("Copied.");
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async () => {
if (!selectedRunId) return;
await renderSelectedRun();
await refreshHistory();
if (selectedRun && terminalStates.has(selectedRun.state)) {
clearInterval(pollTimer);
pollTimer = null;
}
}, 2500);
}
function setActiveOutputTab(tab, shouldRender = true) {
if (tab === "explain") tab = "sql";
activeOutputTab = tab;
updateTabButtons();
if (shouldRender && selectedRun) {
renderActiveOutput(selectedRun);
}
}
function updateTabButtons() {
for (const button of document.querySelectorAll("[data-output-tab]")) {
button.classList.toggle("active", button.dataset.outputTab === activeOutputTab);
}
}
function updateFooter(tab, run) {
const awaiting = run && isAwaitingConfirmation(run);
$("copy-button").style.display = tab === "sql" ? "" : "none";
$("save-review-button").style.display = tab === "review" ? "" : "none";
$("generate-sql-button").style.display = awaiting ? "" : "none";
$("copy-button").disabled = tab !== "sql" || !currentSql;
$("save-review-button").disabled = tab !== "review" || !confirmationState || !(confirmationState.review_items || []).length;
$("generate-sql-button").disabled = !awaiting || !confirmationReadyForStage3();
$("retry-button").disabled = !(run && terminalStates.has(run.state));
}
function setStatus(state) {
$("selected-status").textContent = state === "idle" ? "Idle" : state;
$("selected-status").className = `status ${state}`;
}
function setIdle(message) {
$("sql-container").className = "sql-shell idle";
$("sql-container").textContent = message;
}
function setViewHtml(className, html) {
$("sql-container").className = className;
$("sql-container").innerHTML = html;
}
function stageProgress(run) {
const stages = run.stages || {};
const order = [
["stage_01", 1, "Understanding request"],
["stage_02", 2, "Reviewing source candidates"],
["stage_03", 3, "Generating SQL"],
];
if (isAwaitingConfirmation(run)) {
return { index: 2, label: "Awaiting candidate confirmation", note: "Verify candidate checklist items before SQL generation." };
}
if (run.state === "queued") {
return { index: 1, label: "Queued", note: "Waiting to start request interpretation." };
}
for (const [name, index, label] of order) {
if (stages[name] && stages[name].state === "running") {
return { index, label, note: stageNote(name) };
}
}
for (const [name, index, label] of order) {
if (stages[name] && stages[name].state === "pending") {
return { index, label, note: stageNote(name) };
}
}
return { index: 3, label: "Finalizing", note: "Preparing output." };
}
function stageDetails(run) {
const labels = {
stage_01: "Stage 1: intent",
stage_02: "Stage 2: evidence",
stage_03: "Stage 3: SQL",
};
return ["stage_01", "stage_02", "stage_03"].map((name) => {
const state = ((run.stages || {})[name] || {}).state || "pending";
return {
name,
label: labels[name],
state,
visualState: state === "succeeded" ? "done" : state === "running" ? "running" : "",
};
});
}
function stageNote(stageName) {
if (stageName === "stage_01") return "Converting natural language into structured cohort intent.";
if (stageName === "stage_02") return "Retrieving and binding candidate clinical concepts and source fields.";
return "Assembling SQL and preparing output.";
}
function currentActivity(run) {
const events = run.events || [];
const latest = [...events].reverse().find((event) => event && event.message);
if (latest) return latest.message;
return stageProgress(run).note;
}
function isAwaitingConfirmation(run) {
const stages = run.stages || {};
return Boolean(
terminalStates.has(run.state) &&
stages.stage_02 &&
stages.stage_02.state === "succeeded" &&
stages.stage_03 &&
["pending", "skipped"].includes(stages.stage_03.state) &&
!(run.artifacts && run.artifacts.stage3_sql)
);
}
function prepareConfirmationState() {
for (const item of confirmationState.review_items || []) {
const legacy = item.human_ranking || {};
const review = item.human_review || {};
review.review_status = canonicalReviewStatus(review.review_status || legacy.review_status || "pending");
review.selected_row_uids = Array.isArray(review.selected_row_uids)
? review.selected_row_uids
: Array.isArray(legacy.preferred_row_uids)
? legacy.preferred_row_uids
: [];
review.retrieval_selected_row_uids = Array.isArray(review.retrieval_selected_row_uids) ? review.retrieval_selected_row_uids : [];
// Pre-select the HIGH-CONFIDENCE candidates so the reviewer adjusts sensible
// defaults, not a blank list and not the full noisy pool. Only on the un-reviewed
// base artifact (a saved review is respected). The reranker's keep-decision
// (included_source_row_uids / rerank.included) is computed deterministically even
// in human-review mode, where selected_by_retrieval is empty. Lower-confidence
// near-misses stay in the pool, visible-but-unchecked, for the human to promote.
const fromBaseArtifact = !String(confirmationState._artifact_path || "").includes("review");
if (fromBaseArtifact && !review.selected_row_uids.length) {
const highConfidence = (item.candidates || [])
.filter((c) => (c.included_source_row_uids && c.included_source_row_uids.length) || (c.rerank && c.rerank.included) || c.selected_by_retrieval)
.map((c) => c.row_uid)
.filter(Boolean);
// Fall back to ALL candidates when nothing is flagged high-confidence: a real
// concept's sibling group (e.g. the ICD-9 set when only ICD-10 hit the
// confidence bar) must not be left blank, or it blocks "continue to Stage 3".
const allUids = (item.candidates || []).map((c) => c.row_uid).filter(Boolean);
const seed = review.retrieval_selected_row_uids.length
? review.retrieval_selected_row_uids
: (highConfidence.length ? highConfidence : allUids);
if (seed.length) review.selected_row_uids = [...seed];
}
review.retrieval_repair = review.retrieval_repair || {
requested: review.review_status === "needs_retrieval_repair",
instructions: legacy.notes || review.notes || "",
};
item.human_review = review;
delete item.human_ranking;
deriveItemReviewStatus(item);
}
}
function handleOutputClick(event) {
const explanationTarget = event.target.closest("[data-explain-clause]");
if (explanationTarget) {
event.preventDefault();
openClauseExplanation(explanationTarget.dataset.explainClause);
return;
}
if (!confirmationState) return;
const variantsTarget = event.target.closest("[data-review-variants]");
if (variantsTarget) {
const [itemIndexText, candidateIndexText] = variantsTarget.dataset.reviewVariants.split(":");
const item = confirmationState.review_items[Number(itemIndexText)];
const candidate = item && item.candidates[Number(candidateIndexText)];
if (!item || !candidate) return;
const key = reviewVariantKey(item, candidate, Number(itemIndexText));
reviewVariantExpandedState[key] = !reviewVariantExpandedState[key];
renderConfirmationState();
return;
}
const moreTarget = event.target.closest("[data-review-more]");
const collapseTarget = event.target.closest("[data-review-collapse]");
const target = moreTarget || collapseTarget;
if (!target) return;
const itemIndex = Number(target.dataset.reviewMore ?? target.dataset.reviewCollapse);
const item = confirmationState.review_items[itemIndex];
if (!item) return;
const key = reviewItemKey(item, itemIndex);
if (moreTarget) {
reviewExpandedState[key] = reviewAdditionalCountForItem(item, itemIndex) + additionalCandidatePageSize;
} else {
reviewExpandedState[key] = 0;
}
renderConfirmationState();
}
function handleOutputKeyDown(event) {
const explanationTarget = event.target.closest("[data-explain-clause]");
if (!explanationTarget || !["Enter", " "].includes(event.key)) return;
event.preventDefault();
openClauseExplanation(explanationTarget.dataset.explainClause);
}
function handleOutputFocusIn(event) {
const target = event.target;
if (target.dataset && target.dataset.reviewRepair !== undefined) {
expandRepairTextarea(target);
}
}
function handleOutputFocusOut(event) {
const target = event.target;
if (target.dataset && target.dataset.reviewRepair !== undefined) {
collapseRepairTextarea(target);
}
}
function handleOutputInput(event) {
if (!confirmationState) return;
const target = event.target;
if (target.dataset.reviewRepair === undefined) return;
updateRepairInstructions(Number(target.dataset.reviewRepair), target.value);
const repairRow = target.closest(".repair-row");
if (repairRow) {
repairRow.classList.toggle("has-text", Boolean(target.value.trim()));
const indicator = repairRow.querySelector(".repair-indicator");
if (indicator) indicator.classList.toggle("checked", Boolean(target.value.trim()));
}
expandRepairTextarea(target);
updateFooter("review", selectedRun);
}
function handleOutputChange(event) {
if (!confirmationState) return;
const target = event.target;
if (target.dataset.reviewRepair !== undefined) {
updateRepairInstructions(Number(target.dataset.reviewRepair), target.value);
}
if (target.dataset.reviewSelected !== undefined) {
const [itemIndexText, candidateIndexText] = target.dataset.reviewSelected.split(":");
const item = confirmationState.review_items[Number(itemIndexText)];
const candidate = item && item.candidates[Number(candidateIndexText)];
if (candidate) {
updateCandidateChoice(item, candidate, target.checked);
deriveItemReviewStatus(item);
reviewDirty = true;
renderConfirmationState();
return;
}
}
if (target.dataset.reviewChildSelected !== undefined) {
const [itemIndexText, candidateIndexText, childIndexText] = target.dataset.reviewChildSelected.split(":");
const item = confirmationState.review_items[Number(itemIndexText)];
const candidate = item && item.candidates[Number(candidateIndexText)];
const child = candidate && (candidate.child_candidates || [])[Number(childIndexText)];
if (candidate && child) {
updateChildCandidateChoice(item, candidate, child, target.checked);
deriveItemReviewStatus(item);
reviewDirty = true;
renderConfirmationState();
return;
}
}
updateFooter("review", selectedRun);
}
function updateRepairInstructions(itemIndex, value) {
const item = confirmationState.review_items[itemIndex];
if (!item) return;
item.human_review.retrieval_repair = {
requested: Boolean(value.trim()),
instructions: value,
};
deriveItemReviewStatus(item);
reviewDirty = true;
}
function expandRepairTextarea(textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight + 2, 220)}px`;
}
function collapseRepairTextarea(textarea) {
textarea.style.height = "";
}
function updateCandidateChoice(item, candidate, checked) {
const review = item.human_review;
const selected = new Set(review.selected_row_uids || []);
const rowUid = candidate.row_uid || "";
for (const child of candidate.child_candidates || []) {
if (child.row_uid) selected.delete(child.row_uid);
}
if (checked) {
selected.add(rowUid);
} else {
selected.delete(rowUid);
}
review.selected_row_uids = orderedSelectableRowUids(item).filter((uid) => selected.has(uid));
}
function updateChildCandidateChoice(item, candidate, child, checked) {
const review = item.human_review;
const selected = new Set(review.selected_row_uids || []);
const parentUid = candidate.row_uid || "";
const childUid = child.row_uid || "";
if (selected.has(parentUid)) {
selected.delete(parentUid);
const included = new Set(candidate.included_source_row_uids || candidate.source_row_uids || []);
for (const sibling of candidate.child_candidates || []) {
if (sibling.row_uid && included.has(sibling.row_uid) && sibling.row_uid !== childUid) {
selected.add(sibling.row_uid);
}
}
}
checked ? selected.add(childUid) : selected.delete(childUid);
review.selected_row_uids = orderedSelectableRowUids(item).filter((uid) => selected.has(uid));
}
function orderedSelectableRowUids(item) {
const ordered = [];
const seen = new Set();
for (const candidate of item.candidates || []) {
for (const uid of [candidate.row_uid, ...((candidate.child_candidates || []).map((child) => child.row_uid))]) {
if (uid && !seen.has(uid)) {
seen.add(uid);
ordered.push(uid);
}
}
}
return ordered;
}
function confirmationReadyForStage3() {
if (!confirmationState) return false;
const items = confirmationState.review_items || [];
if (!items.length) return true;
return items.every((item) => {
const review = item.human_review || {};
return Array.isArray(review.selected_row_uids) && review.selected_row_uids.length > 0 && !repairInstructions(review);
});
}
function deriveAllReviewStatuses() {
for (const item of confirmationState.review_items || []) {
deriveItemReviewStatus(item);
}
}
function deriveItemReviewStatus(item) {
const review = item.human_review || {};
const selected = Array.isArray(review.selected_row_uids) ? review.selected_row_uids : [];
const repairText = repairInstructions(review);
const needsRepair = !selected.length || Boolean(repairText);
review.review_status = needsRepair ? "needs_retrieval_repair" : "verified";
review.retrieval_repair = {
requested: needsRepair,
instructions: repairText,
};
item.human_review = review;
}
function repairInstructions(review) {
const repair = review && review.retrieval_repair;
return String((repair && repair.instructions) || "").trim();
}
function canonicalReviewStatus(status) {
if (status === "accepted") return "verified";
if (status === "rejected" || status === "needs_revision") return "needs_retrieval_repair";
return status || "pending";
}
function highlightSql(sql) {
const keywords = new Set([
"ADD", "ALL", "ALTER", "AND", "AS", "ASC", "BETWEEN", "BY", "CASE", "CAST", "COALESCE",
"COUNT", "CREATE", "CROSS", "DATEADD", "DELETE", "DESC", "DISTINCT", "ELSE", "END", "EXISTS",
"FROM", "FULL", "GROUP", "HAVING", "IN", "INNER", "INSERT", "INTO", "IS", "JOIN", "LEFT",
"LIKE", "MAX", "MIN", "NOT", "NULL", "ON", "OR", "ORDER", "OUTER", "OVER", "PARTITION",
"RIGHT", "ROW_NUMBER", "SELECT", "SET", "SUM", "THEN", "UNION", "UPDATE", "VALUES", "WHEN",
"WHERE", "WITH", "YEAR"
]);
const tokenPattern = /(--[^\n]*|\/\*[\s\S]*?\*\/|'(?:''|[^'])*'|\b[A-Za-z_][A-Za-z0-9_]*\b|\b\d+(?:\.\d+)?\b|[(),.;])/g;
let output = "";
let lastIndex = 0;
for (const match of sql.matchAll(tokenPattern)) {
const token = match[0];
output += escapeHtml(sql.slice(lastIndex, match.index));
const linkable = clauseExplanationForKey(token) || cteClauseMap[normalizeClauseLookupKey(token)];
output += linkable
? `<span class="sql-clause-link" role="button" tabindex="0" data-explain-clause="${escapeHtml(token)}">${escapeHtml(token)}</span>`
: colorSqlToken(token, keywords);
lastIndex = match.index + token.length;
}
output += escapeHtml(sql.slice(lastIndex));
return output;
}
function colorSqlToken(token, keywords) {
if (token.startsWith("--") || token.startsWith("/*")) return span("sql-comment", token);
if (token.startsWith("'")) return span("sql-string", token);
if (/^\d/.test(token)) return span("sql-number", token);
if (/^[(),.;]$/.test(token)) return span("sql-punctuation", token);
const upper = token.toUpperCase();
if (keywords.has(upper)) return span("sql-keyword", token);
return escapeHtml(token);
}
function span(className, text) {
return `<span class="${className}">${escapeHtml(text)}</span>`;
}
function insertAtCursor(textarea, text) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(end);
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
}
function formatDate(value) {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function showToast(message) {
const toast = $("toast");
toast.textContent = message;
toast.style.display = "block";
setTimeout(() => {
toast.style.display = "none";
}, 2600);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
updateFooter(activeOutputTab, null);
refreshHistory();
</script>
</body>
</html>