`); }
}
fechaLista();
return out.join("");
}
function extrairOpcoes(texto) {
return String(texto || "")
.split("\n")
.map((l) => l.trim())
.filter((l) => /^OPCAO:/i.test(l))
.map((l) => l.replace(/^OPCAO:\s*/i, "").trim())
.filter(Boolean);
}
// ---------- Tema (automático → claro → escuro; escolha guardada no navegador) ----------
const TEMAS = ["auto", "claro", "escuro"];
const TEMA_ICONE = { auto: "🌗", claro: "☀️", escuro: "🌙" };
const TEMA_ROTULO = { auto: "automático (segue o sistema)", claro: "claro", escuro: "escuro" };
let temaAtual = localStorage.getItem("sd_tema");
if (!TEMAS.includes(temaAtual)) temaAtual = "auto";
function aplicarTema(t) {
const raiz = document.documentElement;
if (t === "claro") raiz.dataset.theme = "light";
else if (t === "escuro") raiz.dataset.theme = "dark";
else delete raiz.dataset.theme; // automático: quem manda é o sistema
$("btnTema").textContent = TEMA_ICONE[t];
$("btnTema").title = `Tema: ${TEMA_ROTULO[t]} — clique para alternar`;
}
aplicarTema(temaAtual);
$("btnTema").addEventListener("click", () => {
temaAtual = TEMAS[(TEMAS.indexOf(temaAtual) + 1) % TEMAS.length];
localStorage.setItem("sd_tema", temaAtual);
aplicarTema(temaAtual);
});
// ---------- Autenticação + navegação ----------
window.addEventListener("hashchange", rotear);
window.addEventListener("DOMContentLoaded", iniciar);
async function iniciar() {
let sess = null;
try { sess = await (await fetch("/api/sessao")).json(); } catch {}
if (!sess) { $("statusChip").textContent = "servidor indisponível"; return; }
$("btnSair").hidden = !sess.precisa_login;
if (sess.precisa_login && !sess.logado) { mostrarLogin(); return; }
await entrar();
}
async function entrar() {
esconderTodas();
await carregarStatus();
rotear();
}
function esconderTodas() {
for (const id of ["telaLogin", "telaHome", "telaConversa", "telaValidar", "telaBase"]) $(id).hidden = true;
}
function mostrarLogin() {
esconderTodas();
$("telaLogin").hidden = false;
$("loginErro").hidden = true;
$("loginUsuario").focus();
}
$("formLogin").addEventListener("submit", async (ev) => {
ev.preventDefault();
$("btnLogin").disabled = true;
const r = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ usuario: $("loginUsuario").value, senha: $("loginSenha").value }),
}).catch(() => null);
$("btnLogin").disabled = false;
if (r?.ok) { $("loginSenha").value = ""; await entrar(); }
else {
$("loginErro").textContent = "Usuário ou senha inválidos.";
$("loginErro").hidden = false;
}
});
$("btnSair").addEventListener("click", async () => {
await fetch("/api/logout", { method: "POST" }).catch(() => {});
location.hash = "#/";
mostrarLogin();
});
function exigirLogin(resp) {
// Sessão expirada/servidor reiniciado: volta para a tela de login.
if (resp && resp.status === 401) { mostrarLogin(); return true; }
return false;
}
async function carregarStatus() {
try {
const r = await fetch("/api/status");
if (exigirLogin(r)) return;
statusApp = await r.json();
$("statusChip").textContent = statusApp.configurado
? `${statusApp.provider} · ${statusApp.model} · próxima: SD-${statusApp.proximo_numero}`
: "sem chave de API";
$("avisoConfig").hidden = statusApp.configurado;
popularSeletoresModelo();
} catch {
$("statusChip").textContent = "servidor indisponível";
}
}
// Seletores de modelo (Nova SD e Base de Conhecimento) — lista do .env (LLM_MODEL + LLM_MODELOS).
function popularSeletoresModelo() {
const modelos = statusApp?.modelos || [];
for (const id of ["selModelo", "selModeloBase"]) {
const sel = $(id);
const anterior = sel.value;
sel.innerHTML = modelos
.map((m) => ``)
.join("");
// preserva a escolha do usuário entre recargas de status; senão, usa o padrão
sel.value = modelos.includes(anterior) ? anterior : statusApp.model;
}
}
function moverPainel(slotId) {
const painel = $("painel");
const slot = $(slotId);
if (painel.parentElement !== slot) slot.appendChild(painel);
}
function rotear() {
if ($("telaLogin") && !$("telaLogin").hidden) return; // aguardando login
const m = location.hash.match(/^#\/sd\/(.+)$/);
if (location.hash === "#/validar") mostrarValidar();
else if (location.hash === "#/base") mostrarBase();
else if (m) abrirConversa(m[1]);
else mostrarHome();
}
// ---------- Home ----------
async function mostrarHome() {
esconderTodas();
$("telaHome").hidden = false;
sdAtual = null;
modoExterno = false;
const rList = await fetch("/api/sds");
if (exigirLogin(rList)) return;
const lista = await rList.json();
$("listaSds").innerHTML = lista.length
? lista
.sort((a, b) => (a.criadaEm < b.criadaEm ? 1 : -1))
.map(
(s) => `SD-${s.numero}${esc(s.titulo)}${s.status}`
)
.join("")
: '
Nenhuma SD ainda — cole a primeira demanda acima.
';
$("listaSds").querySelectorAll(".btn-excluir").forEach((b) =>
b.addEventListener("click", async (ev) => {
ev.preventDefault(); // o botão vive dentro do link da SD
ev.stopPropagation();
const { id, status, titulo } = ev.currentTarget.dataset;
const aviso = status === "enviada"
? `Excluir a conversa de "${titulo}" da lista?\n\nEla já foi ENVIADA: o registro na base de conhecimento (saldos e precedentes) NÃO será apagado — só a conversa aqui do app.`
: `Excluir o rascunho "${titulo}"?\n\nA conversa e o rascunho serão apagados de vez.`;
if (!confirm(aviso)) return;
const r = await fetch(`/api/sds/${id}`, { method: "DELETE" });
if (exigirLogin(r)) return;
if (!r.ok) { alert("Não foi possível excluir."); return; }
await carregarStatus(); // numeração provisória pode mudar
await mostrarHome();
})
);
if (statusApp?.saldos) {
$("saldosBox").innerHTML = Object.entries(statusApp.saldos)
.map(
([item, v]) => `
${esc(item)}
${v.saldo.toLocaleString("pt-BR")} UST
de ${v.pool_total.toLocaleString("pt-BR")} · consumido ${v.consumido.toLocaleString("pt-BR")}
`
)
.join("");
}
}
$("btnIniciar").addEventListener("click", async () => {
const demanda = $("demandaInput").value.trim();
if (!demanda || ocupado) return;
$("btnIniciar").disabled = true;
// Prepara a tela de conversa e transmite o primeiro turno
esconderTodas();
$("telaConversa").hidden = false;
modoExterno = false;
moverPainel("slotPainelConversa");
$("mensagens").innerHTML = "";
$("opcoes").innerHTML = "";
resetPainel();
addMsgUser(demanda);
const resp = await fetch("/api/sds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ demanda, modelo: $("selModelo").value }),
});
if (exigirLogin(resp)) return;
const sdId = resp.headers.get("X-Sd-Id");
history.replaceState(null, "", `#/sd/${sdId}`);
sdAtual = { id: sdId, chat: [], rascunho: null };
$("demandaInput").value = "";
$("btnIniciar").disabled = false;
await consumirStream(resp);
await recarregarSd(); // pega numero/status/uso consolidados
});
// ---------- Conversa ----------
async function abrirConversa(id) {
if (sdAtual?.id === id && ocupado) return; // já está na tela, streaming em curso
esconderTodas();
$("telaConversa").hidden = false;
modoExterno = false;
moverPainel("slotPainelConversa");
sdAtual = { id };
await recarregarSd(true);
}
async function recarregarSd(renderChat = false) {
const r = await fetch(`/api/sds/${sdAtual.id}`);
if (exigirLogin(r)) return;
if (!r.ok) { location.hash = "#/"; return; }
const d = await r.json();
sdAtual = d;
if (renderChat) {
$("mensagens").innerHTML = "";
$("opcoes").innerHTML = "";
for (const m of d.chat || []) {
if (m.role === "user") addMsgUser(m.texto);
else addMsgAssistant(m.texto, true);
}
const ultimo = (d.chat || []).filter((m) => m.role === "assistant").pop();
if (ultimo) renderOpcoes(extrairOpcoes(ultimo.texto));
}
renderPainel(d.rascunho, d.validacao, d.bloqueios);
renderUso(d.uso);
const soLeitura = d.status === "enviada";
$("msgInput").disabled = soLeitura;
$("btnEnviarMsg").disabled = soLeitura;
if (soLeitura) $("msgInput").placeholder = "SD enviada — somente leitura.";
rolarChat();
}
function addMsgUser(texto) {
const div = document.createElement("div");
div.className = "msg user";
div.textContent = texto;
$("mensagens").appendChild(div);
rolarChat();
}
function addMsgAssistant(texto, pronto = false) {
const div = document.createElement("div");
div.className = "msg assistant";
div.innerHTML = pronto ? renderMd(texto) : '▍';
$("mensagens").appendChild(div);
rolarChat();
return div;
}
function renderOpcoes(opcoes) {
$("opcoes").innerHTML = "";
for (const o of opcoes) {
const b = document.createElement("button");
b.type = "button";
b.textContent = o;
b.addEventListener("click", () => enviarMensagem(o));
$("opcoes").appendChild(b);
}
}
function rolarChat() {
const el = $("mensagens");
el.scrollTop = el.scrollHeight;
}
$("formMsg").addEventListener("submit", (ev) => {
ev.preventDefault();
const t = $("msgInput").value.trim();
if (t) enviarMensagem(t);
});
$("msgInput").addEventListener("keydown", (ev) => {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
$("formMsg").requestSubmit();
}
});
async function enviarMensagem(texto) {
if (ocupado || !sdAtual?.id) return;
$("msgInput").value = "";
$("opcoes").innerHTML = "";
addMsgUser(texto);
const resp = await fetch(`/api/sds/${sdAtual.id}/mensagem`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ texto }),
});
if (exigirLogin(resp)) return;
if (!resp.ok && !resp.body) {
addMsgAssistant("⚠ Erro ao enviar a mensagem.", true);
return;
}
await consumirStream(resp);
}
async function consumirStream(resp) {
ocupado = true;
$("btnEnviarMsg").disabled = true;
const bolha = addMsgAssistant("");
let texto = "";
// "pensando…" cobre o silêncio do raciocínio interno do modelo: antes da
// primeira palavra e entre as chamadas de ferramenta (validação do rascunho).
let pensando = true;
const pinta = () => {
bolha.innerHTML =
renderMd(texto) +
(pensando ? 'pensando…' : '▍');
rolarChat();
};
pinta();
try {
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const linha = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!linha) continue;
let ev;
try { ev = JSON.parse(linha); } catch { continue; }
if (ev.t === "delta") {
texto += ev.text;
pensando = false;
pinta();
} else if (ev.t === "rascunho") {
renderPainel(ev.sd, ev.validacao, ev.bloqueios);
pensando = true; // o modelo volta a raciocinar sobre o resultado da validação
pinta();
} else if (ev.t === "uso") {
renderUso(ev.uso);
} else if (ev.t === "erro") {
texto += `\n\n⚠ ${ev.msg}`;
pensando = false;
pinta();
}
}
}
} catch (e) {
texto += "\n\n⚠ Conexão interrompida — recarregue a página para retomar.";
}
bolha.innerHTML = renderMd(texto);
renderOpcoes(extrairOpcoes(texto));
ocupado = false;
$("btnEnviarMsg").disabled = false;
rolarChat();
}
// ---------- Painel do documento ----------
function resetPainel() {
$("painelVazio").hidden = false;
$("painelConteudo").hidden = true;
}
function renderPainel(sd, validacao, bloqueios) {
if (!sd) { resetPainel(); return; }
ultimosBloqueios = bloqueios || [];
if (!modoExterno && sdAtual) { sdAtual.rascunho = sd; }
$("painelVazio").hidden = true;
$("painelConteudo").hidden = false;
const E = sd.entregaveis || [];
const cab = sd.cabecalho || {};
const R = validacao?.resultados || {};
const totais = validacao?.totais || {};
// Resumo
$("pResumo").innerHTML = `
${esc(cab.titulo || cab.projeto || "SD em elaboração")}