sagadeck 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/SKILL.md +148 -0
- package/bin/sagadeck.js +511 -0
- package/docs/ANDAMENTO.md +1255 -0
- package/docs/EXPERIENCIAS.md +39 -0
- package/docs/PLANO-EXPERIENCIAS.md +39 -0
- package/docs/PLANO-TESTE-AULAS-TECNICAS.md +44 -0
- package/docs/PLANO-TESTE-DESINFORMACAO.md +38 -0
- package/docs/REFERENCIA.md +1131 -0
- package/package.json +59 -0
- package/src/adaptive-layouts.js +18 -0
- package/src/ai/art-direction.js +8 -0
- package/src/ai/commands.js +130 -0
- package/src/ai/context.js +245 -0
- package/src/ai/deck-ai.js +1135 -0
- package/src/ai/directions.js +74 -0
- package/src/ai/document-materials.js +49 -0
- package/src/ai/document-visuals.js +215 -0
- package/src/ai/evaluation.js +31 -0
- package/src/ai/ground.js +55 -0
- package/src/ai/llm.js +292 -0
- package/src/ai/quality.js +51 -0
- package/src/ai/relay.js +45 -0
- package/src/ai/staged-generation.js +24 -0
- package/src/ai/transform.js +858 -0
- package/src/ai/usage.js +17 -0
- package/src/ai/variety.js +80 -0
- package/src/ai/video-frames.js +24 -0
- package/src/ai/video-generation.js +126 -0
- package/src/ai/web-operation.js +31 -0
- package/src/algo-catalog.js +153 -0
- package/src/algo-trace.js +139 -0
- package/src/api-client.js +514 -0
- package/src/api-collections.js +14 -0
- package/src/api-demo.js +222 -0
- package/src/aspect.js +40 -0
- package/src/build.js +323 -0
- package/src/carousel.js +56 -0
- package/src/chrome.js +65 -0
- package/src/code-highlight.js +68 -0
- package/src/code-lab-browser.js +22 -0
- package/src/code-lab.js +3 -0
- package/src/code-language.js +52 -0
- package/src/csv.js +74 -0
- package/src/decision-lab.js +20 -0
- package/src/deck-file.js +119 -0
- package/src/diagram/napkin-examples.js +13 -0
- package/src/diagram/napkin.js +539 -0
- package/src/docx.js +128 -0
- package/src/dynamics/git-sim.js +200 -0
- package/src/dynamics/layouts.js +112 -0
- package/src/elements.js +554 -0
- package/src/experiences.js +124 -0
- package/src/exploration.js +31 -0
- package/src/export/browser.js +53 -0
- package/src/export/estudo.js +67 -0
- package/src/export/pptx.js +475 -0
- package/src/export/roteiro.js +40 -0
- package/src/export/shots.js +182 -0
- package/src/figures/charts.js +383 -0
- package/src/figures/diagrams.js +117 -0
- package/src/figures/icons.js +166 -0
- package/src/figures/material-pictos.js +20 -0
- package/src/figures/pictos.js +255 -0
- package/src/figures/points.js +106 -0
- package/src/figures/qr.js +29 -0
- package/src/figures/ufmap.js +48 -0
- package/src/fiscal/autofix.js +377 -0
- package/src/fiscal/normalize.js +115 -0
- package/src/graph-lab.js +24 -0
- package/src/hologram.js +30 -0
- package/src/identity.js +86 -0
- package/src/import/crop.js +59 -0
- package/src/import/index.js +132 -0
- package/src/import/legacy-word.js +42 -0
- package/src/import/merge.js +45 -0
- package/src/import/office.js +123 -0
- package/src/import/omml.js +69 -0
- package/src/import/pdf-render.js +56 -0
- package/src/import/pptx.js +701 -0
- package/src/import/xml.js +42 -0
- package/src/infographic.js +401 -0
- package/src/layouts.js +723 -0
- package/src/lessons.js +171 -0
- package/src/library.js +399 -0
- package/src/markup.js +116 -0
- package/src/master.js +214 -0
- package/src/mcp/server.js +371 -0
- package/src/meeting.js +42 -0
- package/src/motion.js +56 -0
- package/src/package.js +116 -0
- package/src/playground.js +19 -0
- package/src/portal-scene.js +20 -0
- package/src/preferences.js +61 -0
- package/src/protect.js +51 -0
- package/src/purpose.js +24 -0
- package/src/pytrace.js +1092 -0
- package/src/research/duckduckgo.js +73 -0
- package/src/research/research.js +202 -0
- package/src/research/svg-image.js +17 -0
- package/src/research/visuals.js +52 -0
- package/src/runtime/api-collection-ui.js +5 -0
- package/src/runtime/api-collections.js +28 -0
- package/src/runtime/api-core.js +530 -0
- package/src/runtime/api-ui.js +966 -0
- package/src/runtime/base.css +1774 -0
- package/src/runtime/calc.js +139 -0
- package/src/runtime/code-lab.js +371 -0
- package/src/runtime/decision-lab.js +49 -0
- package/src/runtime/diagram.js +341 -0
- package/src/runtime/fit.js +268 -0
- package/src/runtime/fonts/caveat.css +2 -0
- package/src/runtime/fonts/comic-neue.css +3 -0
- package/src/runtime/fonts/fredoka.css +2 -0
- package/src/runtime/fonts/index.json +10 -0
- package/src/runtime/fonts/inter.css +2 -0
- package/src/runtime/fonts/patrick-hand.css +2 -0
- package/src/runtime/fonts/plus-jakarta-sans.css +2 -0
- package/src/runtime/fonts/press-start-2p.css +2 -0
- package/src/runtime/fonts/vt323.css +2 -0
- package/src/runtime/formula.js +148 -0
- package/src/runtime/graph-lab.js +72 -0
- package/src/runtime/meeting.css +7 -0
- package/src/runtime/meeting.js +10 -0
- package/src/runtime/motion.js +15 -0
- package/src/runtime/playground.js +11 -0
- package/src/runtime/points.js +19 -0
- package/src/runtime/portal-scene.css +6 -0
- package/src/runtime/portal-scene.js +22 -0
- package/src/runtime/runtime.js +827 -0
- package/src/runtime/science.js +89 -0
- package/src/runtime/skins/aluminio.css +31 -0
- package/src/runtime/skins/arcade.css +25 -0
- package/src/runtime/skins/aurora.css +17 -0
- package/src/runtime/skins/bauhaus.css +19 -0
- package/src/runtime/skins/editorial.css +19 -0
- package/src/runtime/skins/jornal.css +17 -0
- package/src/runtime/skins/manual-noite.css +14 -0
- package/src/runtime/skins/manual.css +14 -0
- package/src/runtime/skins/noite.css +21 -0
- package/src/runtime/skins/oceano.css +17 -0
- package/src/runtime/skins/pop.css +23 -0
- package/src/runtime/skins/prata.css +18 -0
- package/src/runtime/skins/prisma.css +28 -0
- package/src/runtime/skins/rabisco.css +18 -0
- package/src/runtime/skins/relevo.css +33 -0
- package/src/runtime/skins/sinal.css +16 -0
- package/src/runtime/skins/terminal.css +23 -0
- package/src/runtime/vendor/KATEX-LICENSE.txt +21 -0
- package/src/runtime/vendor/MERMAID-LICENSE.txt +21 -0
- package/src/runtime/vendor/PLOTLY-LICENSE.txt +21 -0
- package/src/runtime/vendor/katex.css +1 -0
- package/src/runtime/vendor/mermaid.min.js +3636 -0
- package/src/runtime/vendor/plotly.min.js +3620 -0
- package/src/runtime/video-player.js +35 -0
- package/src/science.js +162 -0
- package/src/studio/ai-response.js +36 -0
- package/src/studio/api-routes.js +152 -0
- package/src/studio/assets/contexto/leia-me.md +12 -0
- package/src/studio/assets/contexto/vendas.csv +7 -0
- package/src/studio/assets/imagens/colecao-arquitetura.png +0 -0
- package/src/studio/assets/imagens/colecao-cor.png +0 -0
- package/src/studio/assets/imagens/colecao-equipe.png +0 -0
- package/src/studio/assets/imagens/lavanda-cover.jpg +0 -0
- package/src/studio/code-routes.js +32 -0
- package/src/studio/command-approval.js +48 -0
- package/src/studio/demo-decks.js +98 -0
- package/src/studio/direction-routes.js +29 -0
- package/src/studio/errors.js +65 -0
- package/src/studio/exploration-demo.js +17 -0
- package/src/studio/exporting.js +105 -0
- package/src/studio/generation-report.js +10 -0
- package/src/studio/instance.js +48 -0
- package/src/studio/layout-samples.js +152 -0
- package/src/studio/project.js +371 -0
- package/src/studio/public/api-collections.js +28 -0
- package/src/studio/public/app.js +5265 -0
- package/src/studio/public/art-preview.js +10 -0
- package/src/studio/public/calc-fields.js +11 -0
- package/src/studio/public/chat-progress.js +47 -0
- package/src/studio/public/developer-fields.js +22 -0
- package/src/studio/public/explorer.js +443 -0
- package/src/studio/public/history.js +58 -0
- package/src/studio/public/index.html +867 -0
- package/src/studio/public/inspector.js +270 -0
- package/src/studio/public/lab-fields.js +5 -0
- package/src/studio/public/library.css +275 -0
- package/src/studio/public/library.html +55 -0
- package/src/studio/public/library.js +471 -0
- package/src/studio/public/merge-decks.js +155 -0
- package/src/studio/public/review-ui.js +100 -0
- package/src/studio/public/screenshot-editor.js +140 -0
- package/src/studio/public/share-ui.js +72 -0
- package/src/studio/public/slide-clipboard.js +23 -0
- package/src/studio/public/slide-form.js +1263 -0
- package/src/studio/public/studio-awareness.js +154 -0
- package/src/studio/public/studio-next.css +303 -0
- package/src/studio/public/style.css +1262 -0
- package/src/studio/public/ui-icons.js +2 -0
- package/src/studio/public/viewers.js +372 -0
- package/src/studio/public/visual-editor.js +326 -0
- package/src/studio/public/workspace.js +18 -0
- package/src/studio/public-files.js +1 -0
- package/src/studio/request-helpers.js +41 -0
- package/src/studio/server.js +1583 -0
- package/src/studio/share-routes.js +146 -0
- package/src/studio/slide-copy-routes.js +26 -0
- package/src/studio/slide-copy.js +30 -0
- package/src/studio/snapshot.js +160 -0
- package/src/studio/style-routes.js +103 -0
- package/src/studio/template-collections.js +97 -0
- package/src/studio/versions.js +95 -0
- package/src/studio/video-download.js +29 -0
- package/src/studio/visual-keys.js +52 -0
- package/src/studio/workspaces.js +39 -0
- package/src/table.js +93 -0
- package/src/templates/scaffold.js +372 -0
- package/src/themes.js +604 -0
- package/src/trace-view.js +450 -0
- package/src/uid.js +19 -0
- package/src/video-player.js +40 -0
- package/src/visual-edits.js +99 -0
- package/src/ws.js +134 -0
- package/templates/cenario/Texto no cen/303/241rio.yaml" +171 -0
- package/templates/cenario/imagens/cidade.svg +30 -0
- package/templates/cenario/imagens/pessoa.svg +15 -0
- package/templates/cenario-e-ciencia.yaml +47 -0
- package/templates/ensaio-api.yaml +160 -0
- package/templates/exemplo-alegre.yaml +75 -0
- package/templates/exemplo-keynote.yaml +90 -0
- package/templates/exemplo-napkin.yaml +139 -0
- package/templates/exemplo.yaml +228 -0
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
// Slide "api" na apresentação: Executar (síncrono, polling, streaming), ambiente, código que acende
|
|
2
|
+
// junto com a fase que está rodando, variáveis entre slides (save:) e a última resposta gravada.
|
|
3
|
+
// Os pedidos saem do Studio (api/http/*), nunca direto do navegador. Sem o Studio (HTML exportado,
|
|
4
|
+
// servidor multiusuário), o slide reproduz a gravação.
|
|
5
|
+
(function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
const C = window.SagadeckApiCore;
|
|
8
|
+
const roots = Array.from(document.querySelectorAll(".L-api[data-api]"));
|
|
9
|
+
if (!C || !roots.length) return;
|
|
10
|
+
|
|
11
|
+
const $ = (s, r) => (r || document).querySelector(s);
|
|
12
|
+
const $$ = (s, r) => Array.from((r || document).querySelectorAll(s));
|
|
13
|
+
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
14
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
const fmtMs = (ms) => (ms >= 1000 ? (ms / 1000).toFixed(1).replace(".", ",") + " s" : Math.round(ms) + " ms");
|
|
16
|
+
const fmtSize = (n) => (n >= 1024 ? (n / 1024).toFixed(1).replace(".", ",") + " KB" : n + " B");
|
|
17
|
+
const when = (iso) => { try { return new Date(iso).toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); } catch { return ""; } };
|
|
18
|
+
|
|
19
|
+
const EMBED = (() => { try { return JSON.parse(($("#sagadeck-api-rec") || {}).textContent || "{}"); } catch { return {}; } })();
|
|
20
|
+
let STATE = { live: false, envs: [], current: null, recordings: EMBED, reason: "Sem o Studio: mostrando a última resposta gravada." };
|
|
21
|
+
|
|
22
|
+
// variáveis guardadas por slides anteriores (save:), só nesta sessão do navegador
|
|
23
|
+
const deckId = (window.sagadeck && window.sagadeck.data && window.sagadeck.data.id) || location.pathname;
|
|
24
|
+
const VKEY = "sagadeck-api-vars:" + deckId;
|
|
25
|
+
const saved = (() => { try { return JSON.parse(sessionStorage.getItem(VKEY) || "{}"); } catch { return {}; } })();
|
|
26
|
+
// de qual slide veio cada valor guardado, e o último token JWT visto (só os 4 últimos caracteres e a validade)
|
|
27
|
+
const meta = (() => { try { return JSON.parse(sessionStorage.getItem(VKEY + ":meta") || "{}"); } catch { return {}; } })();
|
|
28
|
+
meta.from = meta.from || {};
|
|
29
|
+
const persist = () => { try { sessionStorage.setItem(VKEY, JSON.stringify(saved)); sessionStorage.setItem(VKEY + ":meta", JSON.stringify(meta)); } catch {} };
|
|
30
|
+
const slideNo = (root) => { const s = root.closest(".slide"); return s ? Number(s.dataset.idx) + 1 : null; };
|
|
31
|
+
const envVars = () => ((STATE.envs || []).find((e) => e.name === STATE.current) || {}).vars || {};
|
|
32
|
+
const vars = () => Object.assign({}, envVars(), saved);
|
|
33
|
+
|
|
34
|
+
async function call(path, body) {
|
|
35
|
+
const r = await fetch(path, body ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : { cache: "no-store" });
|
|
36
|
+
const j = await r.json().catch(() => ({}));
|
|
37
|
+
if (!r.ok) { const e = new Error(j.error || "HTTP " + r.status); e.kind = j.kind; throw e; }
|
|
38
|
+
return j;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------- JSON colorido ----------
|
|
42
|
+
function jsonHTML(v) {
|
|
43
|
+
const txt = typeof v === "string" ? v : JSON.stringify(v, null, 2);
|
|
44
|
+
if (typeof v === "string") return esc(txt);
|
|
45
|
+
const re = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
|
|
46
|
+
let out = "", last = 0, m;
|
|
47
|
+
while ((m = re.exec(txt))) {
|
|
48
|
+
out += esc(txt.slice(last, m.index));
|
|
49
|
+
if (m[1]) out += `<span class="${m[2] ? "jk" : "js"}">${esc(m[1])}</span>${m[2] || ""}`;
|
|
50
|
+
else out += `<span class="${m[3] ? "jb" : "jn"}">${esc(m[0])}</span>`;
|
|
51
|
+
last = re.lastIndex;
|
|
52
|
+
}
|
|
53
|
+
return out + esc(txt.slice(last));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------- montagem ----------
|
|
57
|
+
function readReq(root) {
|
|
58
|
+
const cfg = root._cfg;
|
|
59
|
+
const url = $("[data-api-url]", root).value.trim();
|
|
60
|
+
const bodyEl = $("[data-api-body]", root);
|
|
61
|
+
const bodyTxt = bodyEl ? bodyEl.value : "";
|
|
62
|
+
let body;
|
|
63
|
+
if (bodyTxt.trim() !== "" && !cfg.request.form) { // upload: a aba Corpo só mostra os campos do formulário
|
|
64
|
+
try { body = JSON.parse(bodyTxt); }
|
|
65
|
+
catch (e) {
|
|
66
|
+
if (typeof cfg.request.body === "string") body = bodyTxt;
|
|
67
|
+
else throw new Error("O corpo não é um JSON válido: " + e.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const headers = {};
|
|
71
|
+
(($("[data-api-headers]", root) || {}).value || "").split("\n").forEach((l) => { const i = l.indexOf(":"); if (i > 0) headers[l.slice(0, i).trim()] = l.slice(i + 1).trim(); });
|
|
72
|
+
return Object.assign({}, cfg.request, { url, body: cfg.request.form ? undefined : body, headers });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function paintCode(root) {
|
|
76
|
+
const cfg = root._cfg;
|
|
77
|
+
let req;
|
|
78
|
+
try { req = readReq(root); } catch { req = cfg.request; }
|
|
79
|
+
const a = Object.assign({}, cfg, { request: req });
|
|
80
|
+
if (cfg.similarity) {
|
|
81
|
+
const ref = $("[data-api-ref]", root), txt = $("[data-api-texts]", root);
|
|
82
|
+
a.similarity = Object.assign({}, cfg.similarity, { reference: ref ? ref.value : cfg.similarity.reference, texts: txt ? txt.value.split("\n").map((t) => t.trim()).filter(Boolean) : cfg.similarity.texts });
|
|
83
|
+
}
|
|
84
|
+
$$(".api-code", root).forEach((pane) => {
|
|
85
|
+
const out = C.code(a, pane.dataset.pane, Object.assign(vars(), root._file ? { "file.name": root._file.name } : {}));
|
|
86
|
+
const cm = new Set(out.comments);
|
|
87
|
+
pane._marks = out.marks;
|
|
88
|
+
pane.innerHTML = out.code.split("\n").map((l, i) => `<div class="cl${cm.has(i + 1) ? " cm" : ""}" data-ln="${i + 1}"><span class="cn">${i + 1}</span><span class="cc">${esc(l) || " "}</span></div>`).join("");
|
|
89
|
+
});
|
|
90
|
+
$$("[data-field]", root).forEach((tr) => { const v = req.body === undefined ? undefined : C.get(req.body, tr.dataset.field); const td = $(".api-fv", tr); if (td) td.textContent = v === undefined ? "—" : JSON.stringify(v); });
|
|
91
|
+
// o endereço de verdade, embaixo do que tem {{variáveis}}
|
|
92
|
+
const res = $("[data-api-resolved]", root);
|
|
93
|
+
if (res) {
|
|
94
|
+
const r = C.render(req.url, vars());
|
|
95
|
+
res.textContent = r !== req.url && !C.missing(r, {}).length ? r : "";
|
|
96
|
+
}
|
|
97
|
+
if (root._phase) light(root, root._phase);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// acende as linhas do código que correspondem à fase que está rodando (start / poll / done)
|
|
101
|
+
function light(root, phase) {
|
|
102
|
+
root._phase = phase;
|
|
103
|
+
$$(".api-code", root).forEach((pane) => {
|
|
104
|
+
const lines = new Set(((pane._marks || {})[phase]) || []);
|
|
105
|
+
let first = null;
|
|
106
|
+
$$(".cl", pane).forEach((l) => { const on = lines.has(+l.dataset.ln); l.classList.toggle("run", on); if (on && !first) first = l; });
|
|
107
|
+
if (first && !pane.hidden) pane.scrollTop = Math.max(0, first.offsetTop - pane.clientHeight / 4);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function pulse(root) {
|
|
111
|
+
$$(".api-code .cl.run", root).forEach((l) => { l.classList.remove("pulse"); void l.offsetWidth; l.classList.add("pulse"); });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function showTab(root, tab) {
|
|
115
|
+
$$("[data-tab]", root).forEach((b) => b.setAttribute("aria-selected", String(b.dataset.tab === tab)));
|
|
116
|
+
$$("[data-pane]", root).forEach((p) => { p.hidden = p.dataset.pane !== tab; });
|
|
117
|
+
if (root._phase) light(root, root._phase);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function paintEnv(root) {
|
|
121
|
+
const btn = $("[data-api-env]", root);
|
|
122
|
+
const rec = STATE.recordings[root._cfg.key];
|
|
123
|
+
let name, kind, title;
|
|
124
|
+
if (!STATE.live) { name = rec ? "gravado" : "sem Studio"; kind = "off"; title = STATE.reason || ""; }
|
|
125
|
+
else if (!(STATE.envs || []).length) { name = "sem ambiente"; kind = "off"; title = STATE.error || `Crie ${STATE.file || "~/.sagadeck/ambientes.yaml"} com os ambientes (dev, hom…)`; }
|
|
126
|
+
else { name = STATE.current; kind = C.envKind(name); title = "Ambiente: clique para trocar"; }
|
|
127
|
+
btn.dataset.kind = kind;
|
|
128
|
+
btn.title = title;
|
|
129
|
+
$(".api-env-name", btn).textContent = String(name).toUpperCase();
|
|
130
|
+
label(root, !STATE.live && rec ? "Reproduzir gravação" : root._cfg.realtime ? (root._rt ? "Desconectar" : "Conectar") : "Executar");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function label(root, t) { const sp = $("[data-api-run] span", root); if (sp) sp.textContent = t; }
|
|
134
|
+
|
|
135
|
+
function envMenu(root, btn) {
|
|
136
|
+
const old = $(".api-menu", root);
|
|
137
|
+
if (old) { old.remove(); return; }
|
|
138
|
+
if (!STATE.live || (STATE.envs || []).length < 1) { showError(root, btn.title || STATE.reason || "Sem ambientes."); return; }
|
|
139
|
+
const m = document.createElement("div");
|
|
140
|
+
m.className = "api-menu";
|
|
141
|
+
m.innerHTML = STATE.envs.map((e) => `<button type="button" data-env="${esc(e.name)}" data-kind="${C.envKind(e.name)}" aria-current="${e.name === STATE.current}"><span class="api-env-dot"></span>${esc(e.name.toUpperCase())}<small>${esc(Object.values(e.vars || {})[0] || "")}</small></button>`).join("");
|
|
142
|
+
$(".api-bar", root).appendChild(m);
|
|
143
|
+
m.onclick = async (ev) => {
|
|
144
|
+
const b = ev.target.closest("[data-env]");
|
|
145
|
+
if (!b) return;
|
|
146
|
+
m.remove();
|
|
147
|
+
try { const st = await call("api/http/env", { name: b.dataset.env }); STATE = Object.assign({}, STATE, st); } catch (e) { showError(root, e.message); }
|
|
148
|
+
roots.forEach((r) => { paintEnv(r); paintCode(r); });
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------- resposta ----------
|
|
153
|
+
function out(root) { return $(".api-out", root); }
|
|
154
|
+
function status(root, html) { $(".api-status", root).innerHTML = html; }
|
|
155
|
+
function clearOut(root) { out(root).innerHTML = ""; status(root, ""); }
|
|
156
|
+
|
|
157
|
+
function statusBar(r, extra) {
|
|
158
|
+
const ok = r.status < 400;
|
|
159
|
+
return `<span class="api-pill ${ok ? "ok" : "err"}">${esc(r.status)} ${esc(r.statusText || "")}</span>` +
|
|
160
|
+
(r.ms != null ? `<span class="api-meta">${fmtMs(r.ms)}</span>` : "") +
|
|
161
|
+
(r.size != null ? `<span class="api-meta">${fmtSize(r.size)}</span>` : "") + (extra || "");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function stepCards(cfg, body) {
|
|
165
|
+
const list = cfg.steps ? C.get(body, cfg.steps) : null;
|
|
166
|
+
if (!Array.isArray(list) || !list.length) return "";
|
|
167
|
+
return `<div class="api-steps">${list.map((st, i) => {
|
|
168
|
+
const title = cfg.stepTitle ? C.get(st, cfg.stepTitle) : (st && (st.name || st.step || st.service || st.type)) || `Etapa ${i + 1}`;
|
|
169
|
+
const val = cfg.stepText ? C.get(st, cfg.stepText) : st;
|
|
170
|
+
const txt = typeof val === "string" ? esc(val) : `<pre>${jsonHTML(val)}</pre>`;
|
|
171
|
+
return `<div class="api-step" style="--i:${i}"><div class="api-step-n f-label">${String(i + 1).padStart(2, "0")}</div><div class="api-step-t">${esc(title)}</div><div class="api-step-x">${txt}</div></div>`;
|
|
172
|
+
}).join('<div class="api-step-arrow" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>')}</div>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------- áudio: tocar a resposta (TTS) com a onda desenhada ----------
|
|
176
|
+
function playAudio(root, box, audio) {
|
|
177
|
+
const cv = $("canvas", box), ctx = cv.getContext("2d");
|
|
178
|
+
const el = $("audio", box);
|
|
179
|
+
const draw = (data) => {
|
|
180
|
+
const w = (cv.width = cv.clientWidth * 2), h = (cv.height = cv.clientHeight * 2);
|
|
181
|
+
ctx.clearRect(0, 0, w, h);
|
|
182
|
+
const n = 64, bw = w / n;
|
|
183
|
+
for (let i = 0; i < n; i++) {
|
|
184
|
+
const v = data ? data[Math.floor((i / n) * data.length)] / 255 : 0.08;
|
|
185
|
+
const bh = Math.max(4, v * h * 0.9);
|
|
186
|
+
ctx.fillStyle = "#9fc3ff";
|
|
187
|
+
ctx.fillRect(i * bw + 2, (h - bh) / 2, bw - 4, bh);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
draw(null);
|
|
191
|
+
try {
|
|
192
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
193
|
+
const ac = root._ac || (root._ac = new AC());
|
|
194
|
+
const src = ac.createMediaElementSource(el), an = ac.createAnalyser();
|
|
195
|
+
an.fftSize = 256;
|
|
196
|
+
src.connect(an); an.connect(ac.destination);
|
|
197
|
+
const data = new Uint8Array(an.frequencyBinCount);
|
|
198
|
+
const tick = () => { an.getByteFrequencyData(data); draw(data); if (!el.paused) requestAnimationFrame(tick); else draw(null); };
|
|
199
|
+
el.addEventListener("play", () => { ac.resume(); requestAnimationFrame(tick); });
|
|
200
|
+
} catch {}
|
|
201
|
+
el.play().catch(() => {});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function showResult(root, r, badge) {
|
|
205
|
+
const cfg = root._cfg;
|
|
206
|
+
status(root, statusBar(r, badge));
|
|
207
|
+
if (r.body && r.body._audio) {
|
|
208
|
+
let box = $(".api-result", root);
|
|
209
|
+
if (!box) { box = document.createElement("div"); box.className = "api-result"; out(root).appendChild(box); }
|
|
210
|
+
box.innerHTML = `<div class="api-audio"><canvas></canvas><audio controls src="data:${esc(r.body.type)};base64,${r.body.base64}"></audio></div>`;
|
|
211
|
+
playAudio(root, $(".api-audio", box));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const ans = cfg.answer && r.body && typeof r.body === "object" ? C.get(r.body, cfg.answer) : undefined;
|
|
215
|
+
const jsonText = typeof r.body === 'string' ? r.body : JSON.stringify(r.body, null, 2);
|
|
216
|
+
const large = jsonText && jsonText.length > 12000;
|
|
217
|
+
const html = (ans != null ? `<div class="api-answer"><div class="f-label">${esc(cfg.answer.replace(/^\$\.?/, ""))}</div><div class="api-answer-t">${esc(typeof ans === "string" ? ans : JSON.stringify(ans))}</div></div>` : "") +
|
|
218
|
+
stepCards(cfg, r.body) +
|
|
219
|
+
(large ? '<p class="api-hint">Prévia do payload. Abra o explorador para acessar a resposta completa.</p>' : '') +
|
|
220
|
+
`<pre class="api-json f-mono">${large ? esc(jsonText.slice(0,12000)) : jsonHTML(r.body)}</pre>`;
|
|
221
|
+
let box = $(".api-result", root);
|
|
222
|
+
if (!box) { box = document.createElement("div"); box.className = "api-result"; out(root).appendChild(box); }
|
|
223
|
+
box.innerHTML = (r.jwt ? jwtHTML(r.jwt) : "") + '<button type="button" class="api-explore-json">Explorar resposta completa</button>' + html;
|
|
224
|
+
$(".api-explore-json", box).onclick = () => exploreJSON(r.body);
|
|
225
|
+
if (r.jwt && r.jwt.exp) countdown(root, r.jwt.exp);
|
|
226
|
+
if (r.jwt && r.jwt.last4) { meta.token = { last4: r.jwt.last4, exp: r.jwt.exp || null, slide: slideNo(root) }; persist(); }
|
|
227
|
+
LAST = { body: r.body, slide: slideNo(root) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------- painel Variáveis: tudo o que está valendo agora, num lugar só (como o inspect/watch de um depurador) ----------
|
|
231
|
+
// Ambiente: variáveis (editáveis) e protegidas (só o nome e ••••; o olhinho mostra por alguns segundos). Token: nunca o
|
|
232
|
+
// valor (•••• + 4 finais + validade). Guardadas pelos slides (save:), dizendo de qual slide veio; clique para inspecionar.
|
|
233
|
+
// Observar: expressões que ficam à vista ({{nome}} ou $.caminho na última resposta). Rodapé: onde o arquivo está salvo.
|
|
234
|
+
const WKEY = "sagadeck-api-watch:" + deckId;
|
|
235
|
+
const watches = (() => { try { return JSON.parse(localStorage.getItem(WKEY) || "[]"); } catch { return []; } })();
|
|
236
|
+
const saveWatches = () => { try { localStorage.setItem(WKEY, JSON.stringify(watches)); } catch {} };
|
|
237
|
+
const EYE = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2"/></svg>';
|
|
238
|
+
const DEL = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
239
|
+
let LAST = null; // última resposta (para $.caminho em Observar)
|
|
240
|
+
function watchValue(expr) {
|
|
241
|
+
const m = /^\{\{\s*([\w.]+)\s*\}\}$/.exec(expr);
|
|
242
|
+
if (m) { const v = vars()[m[1]]; return v == null ? "(não existe)" : v; }
|
|
243
|
+
if (/^\$/.test(expr)) { if (!LAST) return "(nenhuma resposta ainda)"; const v = C.get(LAST.body, expr); return v == null ? `(não existe na resposta do slide ${LAST.slide})` : v; }
|
|
244
|
+
return "(use {{nome}} ou $.caminho)";
|
|
245
|
+
}
|
|
246
|
+
function varsPanel(root) {
|
|
247
|
+
const old = $(".api-vars-panel", root);
|
|
248
|
+
if (old) { clearInterval(old._tick); old.remove(); return; }
|
|
249
|
+
const p = document.createElement("div");
|
|
250
|
+
p.className = "api-vars-panel";
|
|
251
|
+
const shown = {}; // protegidas reveladas agora: nome -> valor (some sozinho)
|
|
252
|
+
const fmt = (v) => esc(String(v != null && typeof v === "object" ? JSON.stringify(v) : v).slice(0, 90));
|
|
253
|
+
const row = (k, v, extra = "") => `<div class="av-row"><code>{{${esc(k)}}}</code><span class="av-v">${fmt(v)}</span>${extra}</div>`;
|
|
254
|
+
const msg = (t) => { const e = $(".av-msg", p); if (e) e.textContent = t || ""; };
|
|
255
|
+
const act = async (path, body) => {
|
|
256
|
+
try { msg(path.endsWith("set") && body.protected ? "Cifrando e salvando…" : ""); const st = await call(path, body); STATE = Object.assign({}, STATE, st); roots.forEach(paintCode); paint(); }
|
|
257
|
+
catch (e) { msg(e.message); }
|
|
258
|
+
};
|
|
259
|
+
const paint = () => {
|
|
260
|
+
const env = (STATE.envs || []).find((e) => e.name === STATE.current) || {};
|
|
261
|
+
const own = !env.builtin;
|
|
262
|
+
const del = (k) => own ? `<button type="button" class="av-ic" data-var-del="${esc(k)}" title="Apagar">${DEL}</button>` : "";
|
|
263
|
+
const envRows = Object.entries(env.vars || {}).map(([k, v]) => row(k, v, del(k))).join("")
|
|
264
|
+
+ (env.secrets || []).map((k) => row(`secret.${k}`, k in shown ? shown[k] : "•••• protegida",
|
|
265
|
+
`<button type="button" class="av-ic" data-var-eye="${esc(k)}" title="Mostrar por 15 segundos">${EYE}</button>${del(k)}`)).join("");
|
|
266
|
+
const t = meta.token, left = t && t.exp ? Math.max(0, Math.round(t.exp - Date.now() / 1000)) : null;
|
|
267
|
+
const tok = t ? `<div class="av-row"><code>token</code><span class="av-v">••••${esc(t.last4)} ${left == null ? "" : left ? `expira em ${String(Math.floor(left / 60)).padStart(2, "0")}:${String(left % 60).padStart(2, "0")}` : "expirou"}</span>${t.slide ? `<small>slide ${esc(t.slide)}</small>` : ""}</div>`
|
|
268
|
+
: `<div class="av-row av-empty">${env.token ? "pedido sozinho ao executar (token do ambiente)" : "nenhum token ainda"}</div>`;
|
|
269
|
+
const mine = Object.keys(saved);
|
|
270
|
+
const form = own ? `<div class="av-form"><input data-var-name placeholder="nome (ex.: modelo)" aria-label="Nome da variável"><input data-var-value placeholder="valor" aria-label="Valor"><label title="Protegida: não aparece na tela (só pelo olhinho) e é gravada cifrada"><input type="checkbox" data-var-protected> protegida</label><button type="button" data-var-add>Adicionar</button></div>`
|
|
271
|
+
: `<div class="av-row av-empty">${esc(String(STATE.current || "").toUpperCase())} é um ambiente de exemplo: para guardar variáveis, crie um ambiente seu em Ambientes.</div>`;
|
|
272
|
+
p.innerHTML = `<div class="av-sec"><b>Ambiente ${esc(String(STATE.current || "").toUpperCase())}</b>${envRows || '<div class="av-row av-empty">sem variáveis</div>'}${form}</div>`
|
|
273
|
+
+ `<div class="av-sec av-tok"><b>Token</b>${tok}</div>`
|
|
274
|
+
+ `<div class="av-sec"><b>Guardadas pelos slides</b>${mine.length ? mine.map((k) => row(k, saved[k], `${meta.from[k] ? `<small>slide ${esc(meta.from[k])}</small>` : ""}<button type="button" class="av-ic" data-var-inspect="${esc(k)}" title="Inspecionar">{ }</button>`)).join("") : '<div class="av-row av-empty">nenhuma ainda (use save: num slide)</div>'}`
|
|
275
|
+
+ (mine.length ? `<button type="button" class="av-clear" data-vars-clear>Limpar o que os slides guardaram</button>` : "") + `</div>`
|
|
276
|
+
+ `<div class="av-sec av-watch"><b>Observar</b>${watches.map((w, i) => `<div class="av-row"><code>${esc(w)}</code><span class="av-v">${fmt(watchValue(w))}</span><button type="button" class="av-ic" data-watch-del="${i}" title="Parar de observar">${DEL}</button></div>`).join("")}`
|
|
277
|
+
+ `<div class="av-form"><input data-watch-expr placeholder="{{nome}} ou $.caminho da última resposta" aria-label="Expressão para observar"><button type="button" data-watch-add>Observar</button></div></div>`
|
|
278
|
+
+ `<div class="av-foot"><span>Salvo em <code>${esc(STATE.file || "~/.sagadeck/ambientes.yaml")}</code>${STATE.protection === "dpapi" ? " (protegidas cifradas: só o seu usuário do Windows lê)" : ""}</span><button type="button" data-vars-folder>Abrir a pasta</button></div>`
|
|
279
|
+
+ `<div class="av-msg" role="status"></div>`;
|
|
280
|
+
const on = (sel, fn) => $$(sel, p).forEach((b) => { b.onclick = () => fn(b); });
|
|
281
|
+
on("[data-var-add]", () => { const name = $("[data-var-name]", p).value.trim(), value = $("[data-var-value]", p).value; if (!name) return msg("Dê um nome para a variável."); act("api/http/vars/set", { name, value, protected: $("[data-var-protected]", p).checked }); });
|
|
282
|
+
on("[data-var-del]", (b) => act("api/http/vars/delete", { name: b.dataset.varDel }));
|
|
283
|
+
on("[data-var-eye]", async (b) => {
|
|
284
|
+
const k = b.dataset.varEye;
|
|
285
|
+
if (k in shown) { delete shown[k]; return paint(); }
|
|
286
|
+
try { shown[k] = (await call("api/http/vars/reveal", { name: k })).value; paint(); setTimeout(() => { delete shown[k]; if (p.isConnected) paint(); }, 15000); } catch (e) { msg(e.message); }
|
|
287
|
+
});
|
|
288
|
+
on("[data-var-inspect]", (b) => exploreJSON(saved[b.dataset.varInspect]));
|
|
289
|
+
on("[data-vars-clear]", () => { Object.keys(saved).forEach((k) => delete saved[k]); meta.from = {}; delete meta.token; persist(); roots.forEach(paintCode); paint(); });
|
|
290
|
+
on("[data-watch-add]", () => { const e = $("[data-watch-expr]", p).value.trim(); if (e && !watches.includes(e)) { watches.push(e); saveWatches(); } paint(); });
|
|
291
|
+
on("[data-watch-del]", (b) => { watches.splice(Number(b.dataset.watchDel), 1); saveWatches(); paint(); });
|
|
292
|
+
on("[data-vars-folder]", async () => { try { const r = await call("api/http/vars/folder", {}); msg(`Pasta: ${r.dir}`); } catch (e) { msg(e.message); } });
|
|
293
|
+
// digitar nos campos não avança a apresentação
|
|
294
|
+
$$("input", p).forEach((i) => i.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") ($("[data-var-add]", i.closest(".av-form")) || $("[data-watch-add]", i.closest(".av-form")))?.click(); }));
|
|
295
|
+
};
|
|
296
|
+
const paintTokenOnly = (box) => {
|
|
297
|
+
const env = (STATE.envs || []).find((e) => e.name === STATE.current) || {};
|
|
298
|
+
const t = meta.token, left = t && t.exp ? Math.max(0, Math.round(t.exp - Date.now() / 1000)) : null;
|
|
299
|
+
const tok = t ? `<div class="av-row"><code>token</code><span class="av-v">••••${esc(t.last4)} ${left == null ? "" : left ? `expira em ${String(Math.floor(left / 60)).padStart(2, "0")}:${String(left % 60).padStart(2, "0")}` : "expirou"}</span>${t.slide ? `<small>slide ${esc(t.slide)}</small>` : ""}</div>`
|
|
300
|
+
: `<div class="av-row av-empty">${env.token ? "pedido sozinho ao executar (token do ambiente)" : "nenhum token ainda"}</div>`;
|
|
301
|
+
box.innerHTML = `<b>Token</b>${tok}`;
|
|
302
|
+
};
|
|
303
|
+
paint();
|
|
304
|
+
// a contagem do token anda sozinha: só a linha do token muda (redesenhar o painel inteiro a cada segundo trocava
|
|
305
|
+
// os botões debaixo do mouse e o clique se perdia)
|
|
306
|
+
p._tick = setInterval(() => {
|
|
307
|
+
if (!p.isConnected) return clearInterval(p._tick);
|
|
308
|
+
if (!(meta.token && meta.token.exp)) return;
|
|
309
|
+
const box = $(".av-tok", p), fresh = document.createElement("div");
|
|
310
|
+
const keep = box && box.innerHTML;
|
|
311
|
+
paintTokenOnly(fresh);
|
|
312
|
+
if (box && fresh.innerHTML !== keep) box.innerHTML = fresh.innerHTML;
|
|
313
|
+
}, 1000);
|
|
314
|
+
$(".api-bar", root).appendChild(p);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Árvore preguiçosa: payloads grandes não precisam de milhares de nós DOM logo na abertura.
|
|
318
|
+
function exploreJSON(value) {
|
|
319
|
+
const dialog = document.createElement('dialog'); dialog.className = 'api-json-dialog';
|
|
320
|
+
dialog.innerHTML = '<header><b>Resposta completa</b><button type="button" data-download>Baixar JSON</button><button type="button" data-close>Fechar</button></header><label>Campo da resposta <input placeholder="$.responses.0.output" aria-label="Caminho no JSON"></label><div class="api-json-tree"></div>';
|
|
321
|
+
const tree = dialog.querySelector('.api-json-tree');
|
|
322
|
+
function node(key,v) {
|
|
323
|
+
if (v === null || typeof v !== 'object') { const p=document.createElement('pre');p.textContent=key+': '+JSON.stringify(v);return p; }
|
|
324
|
+
const d=document.createElement('details'), summary=document.createElement('summary');summary.textContent=key+' · '+Object.keys(v).length+(Array.isArray(v)?' itens':' campos');d.append(summary);
|
|
325
|
+
d.ontoggle=()=>{if(!d.open || d.dataset.loaded)return;d.dataset.loaded='true';const keys=Object.keys(v);let offset=0;const more=document.createElement('button');more.textContent='Carregar mais 100 itens';more.type='button';const next=()=>{keys.slice(offset,offset+100).forEach(k=>d.insertBefore(node(k,v[k]),more));offset+=100;more.hidden=offset>=keys.length;};more.onclick=next;d.append(more);next();};return d;
|
|
326
|
+
}
|
|
327
|
+
function show(v) {tree.replaceChildren(node('$',v));const details=tree.querySelector('details');if(details)details.open=true;}
|
|
328
|
+
dialog.querySelector('input').oninput=e=>{const path=e.target.value.trim();const found=path?C.get(value,path):value;if(found===undefined)tree.textContent='Campo não encontrado.';else show(found);};
|
|
329
|
+
dialog.querySelector('[data-close]').onclick=()=>dialog.close();
|
|
330
|
+
dialog.querySelector('[data-download]').onclick=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify(value,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='resposta.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
|
331
|
+
dialog.addEventListener('keydown',e=>e.stopPropagation());dialog.addEventListener('close',()=>dialog.remove());document.body.append(dialog);show(value);dialog.showModal();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function jwtHTML(j) {
|
|
335
|
+
if (!j.isJwt) return `<div class="api-answer"><div class="f-label">token gerado</div><div class="api-answer-t">••••${esc(j.last4)} · os próximos slides usam este token</div></div>`;
|
|
336
|
+
return `<div class="api-answer"><div class="f-label">token JWT gerado · os próximos slides usam este</div><div class="api-answer-t">••••${esc(j.last4)} <span class="api-countdown" data-exp="${j.exp || ""}"></span></div></div>
|
|
337
|
+
<div class="api-jwt"><div><div class="f-label">cabeçalho</div><pre>${jsonHTML(j.header)}</pre></div><div><div class="f-label">conteúdo (payload)</div><pre>${jsonHTML(j.payload)}</pre></div></div>`;
|
|
338
|
+
}
|
|
339
|
+
function countdown(root, exp) {
|
|
340
|
+
clearInterval(root._cd);
|
|
341
|
+
const tick = () => {
|
|
342
|
+
const el = $(".api-countdown", root);
|
|
343
|
+
if (!el) return clearInterval(root._cd);
|
|
344
|
+
const left = Math.max(0, Math.round(exp - Date.now() / 1000));
|
|
345
|
+
el.textContent = left ? `expira em ${String(Math.floor(left / 60)).padStart(2, "0")}:${String(left % 60).padStart(2, "0")}` : "expirou";
|
|
346
|
+
};
|
|
347
|
+
tick();
|
|
348
|
+
root._cd = setInterval(tick, 1000);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// arquivo arrastado ou escolhido na hora (vai em base64 para o Studio)
|
|
352
|
+
function pickFile(root, f) {
|
|
353
|
+
if (!f) return;
|
|
354
|
+
const rd = new FileReader();
|
|
355
|
+
rd.onload = () => {
|
|
356
|
+
root._file = { name: f.name, type: f.type || "", base64: String(rd.result).split(",")[1] || "" };
|
|
357
|
+
const n = $(".api-file-name", root); if (n) n.textContent = f.name;
|
|
358
|
+
paintCode(root);
|
|
359
|
+
};
|
|
360
|
+
rd.readAsDataURL(f);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------- microfone (STT): grava, mostra o nível e envia ao parar ----------
|
|
364
|
+
async function toggleMic(root) {
|
|
365
|
+
const btn = $("[data-api-mic]", root), cv = $(".api-level", root);
|
|
366
|
+
if (root._rec) { root._rec.stop(); return; }
|
|
367
|
+
let stream;
|
|
368
|
+
try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
|
|
369
|
+
catch (e) { clearOut(root); showError(root, "Não consegui usar o microfone: " + (e.message || e.name) + ". (Só funciona pelo Studio, em 127.0.0.1, com permissão do navegador.)"); return; }
|
|
370
|
+
const chunks = [];
|
|
371
|
+
const rec = new MediaRecorder(stream);
|
|
372
|
+
root._rec = rec;
|
|
373
|
+
btn.classList.add("rec"); $("span", btn).textContent = "Parar";
|
|
374
|
+
cv.hidden = false;
|
|
375
|
+
let raf;
|
|
376
|
+
try {
|
|
377
|
+
const ac = new (window.AudioContext || window.webkitAudioContext)();
|
|
378
|
+
const an = ac.createAnalyser(); an.fftSize = 256;
|
|
379
|
+
ac.createMediaStreamSource(stream).connect(an);
|
|
380
|
+
const data = new Uint8Array(an.frequencyBinCount), g = cv.getContext("2d");
|
|
381
|
+
const tick = () => {
|
|
382
|
+
an.getByteTimeDomainData(data);
|
|
383
|
+
let peak = 0; for (const v of data) peak = Math.max(peak, Math.abs(v - 128) / 128);
|
|
384
|
+
g.clearRect(0, 0, cv.width, cv.height);
|
|
385
|
+
const bars = 16;
|
|
386
|
+
for (let i = 0; i < bars; i++) { g.fillStyle = i / bars < peak * 1.6 ? "#ff9aa2" : "#2a3446"; g.fillRect(i * 10, 6, 7, 24); }
|
|
387
|
+
raf = requestAnimationFrame(tick);
|
|
388
|
+
};
|
|
389
|
+
tick();
|
|
390
|
+
rec.addEventListener("stop", () => { cancelAnimationFrame(raf); ac.close(); });
|
|
391
|
+
} catch {}
|
|
392
|
+
rec.ondataavailable = (e) => chunks.push(e.data);
|
|
393
|
+
rec.onstop = () => {
|
|
394
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
395
|
+
root._rec = null;
|
|
396
|
+
btn.classList.remove("rec"); $("span", btn).textContent = "Gravar"; cv.hidden = true;
|
|
397
|
+
const blob = new Blob(chunks, { type: rec.mimeType || "audio/webm" });
|
|
398
|
+
const rd = new FileReader();
|
|
399
|
+
rd.onload = () => {
|
|
400
|
+
const ext = /ogg/.test(blob.type) ? "ogg" : /mp4/.test(blob.type) ? "m4a" : "webm";
|
|
401
|
+
root._file = { name: "gravacao." + ext, type: blob.type.split(";")[0], base64: String(rd.result).split(",")[1] || "" };
|
|
402
|
+
const n = $(".api-file-name", root); if (n) n.textContent = root._file.name;
|
|
403
|
+
paintCode(root);
|
|
404
|
+
run(root); // parou de falar: já envia
|
|
405
|
+
};
|
|
406
|
+
rd.readAsDataURL(blob);
|
|
407
|
+
};
|
|
408
|
+
rec.start();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function showError(root, msg, kind) {
|
|
412
|
+
const rec = STATE.recordings[root._cfg.key];
|
|
413
|
+
const canReplay = rec && (kind === "offline" || kind === "tls" || kind === "network" || !STATE.live);
|
|
414
|
+
const box = document.createElement("div");
|
|
415
|
+
box.className = "api-error";
|
|
416
|
+
box.innerHTML = `<b>${kind === "offline" ? "Sem conexão" : "Não deu"}</b> ${esc(msg)}${canReplay ? ` <button type="button" class="api-replay">Mostrar a última gravação (${esc(when(rec.at))})</button>` : ""}`;
|
|
417
|
+
out(root).appendChild(box);
|
|
418
|
+
const b = $(".api-replay", box);
|
|
419
|
+
if (b) b.onclick = () => replay(root, rec);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function timeline(root) {
|
|
423
|
+
let t = $(".api-timeline", root);
|
|
424
|
+
if (!t) { t = document.createElement("div"); t.className = "api-timeline"; out(root).prepend(t); }
|
|
425
|
+
return t;
|
|
426
|
+
}
|
|
427
|
+
function row(root, label, info, cls) {
|
|
428
|
+
const t = timeline(root);
|
|
429
|
+
const r = document.createElement("div");
|
|
430
|
+
r.className = "api-tl " + (cls || "");
|
|
431
|
+
r.innerHTML = `<span class="api-tl-s">${esc(label)}</span><span class="api-tl-i">${esc(info || "")}</span>`;
|
|
432
|
+
t.appendChild(r);
|
|
433
|
+
t.scrollTop = t.scrollHeight;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------- execução ----------
|
|
437
|
+
function payload(root, req) {
|
|
438
|
+
const cfg = root._cfg;
|
|
439
|
+
return { request: Object.assign({}, req, cfg.token ? { captureToken: cfg.token } : {}), file: root._file || undefined, fileRef: root._file ? undefined : cfg.file || undefined };
|
|
440
|
+
}
|
|
441
|
+
async function runSync(root, req) {
|
|
442
|
+
light(root, "start");
|
|
443
|
+
const r = await call("api/http/send", payload(root, req));
|
|
444
|
+
light(root, "done");
|
|
445
|
+
showResult(root, r);
|
|
446
|
+
return r.ok ? { mode: "sync", env: STATE.current, result: r } : null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function runPolling(root, req) {
|
|
450
|
+
const cfg = root._cfg, P = cfg.polling, t0 = performance.now();
|
|
451
|
+
const since = () => fmtMs(performance.now() - t0);
|
|
452
|
+
light(root, "start");
|
|
453
|
+
const start = await call("api/http/send", payload(root, req));
|
|
454
|
+
if (!start.ok) { showResult(root, start); return null; }
|
|
455
|
+
const id = C.get(start.body, P.id);
|
|
456
|
+
if (id == null) throw new Error(`A resposta do início não tem ${P.id} (o código da execução).`);
|
|
457
|
+
row(root, "início", `${start.status} · execução ${id} · ${since()}`, "start");
|
|
458
|
+
status(root, statusBar(start, `<span class="api-meta">execução ${esc(id)}</span>`));
|
|
459
|
+
const check = C.render(P.check, Object.assign(vars(), { id }));
|
|
460
|
+
const polls = [];
|
|
461
|
+
for (;;) {
|
|
462
|
+
if ((performance.now() - t0) / 1000 > P.timeout) throw new Error(`Passou de ${P.timeout} s sem terminar. Aumente polling.timeout se for normal.`);
|
|
463
|
+
light(root, "poll"); pulse(root);
|
|
464
|
+
const r = await call("api/http/send", { request: check });
|
|
465
|
+
if (!r.ok) { showResult(root, r); return null; }
|
|
466
|
+
const st = String(C.get(r.body, P.status));
|
|
467
|
+
const t = Math.round(performance.now() - t0);
|
|
468
|
+
polls.push({ status: st, t });
|
|
469
|
+
const done = P.done.includes(st), failed = P.failed.includes(st);
|
|
470
|
+
row(root, st, since(), done ? "done" : failed ? "failed" : "");
|
|
471
|
+
if (done) {
|
|
472
|
+
light(root, "done");
|
|
473
|
+
showResult(root, r, `<span class="api-meta">${polls.length} consulta${polls.length > 1 ? "s" : ""} · ${since()}</span>`);
|
|
474
|
+
return { mode: "polling", env: STATE.current, start, polls, final: r };
|
|
475
|
+
}
|
|
476
|
+
if (failed) { showResult(root, r); throw new Error(`A execução terminou com ${st}.`); }
|
|
477
|
+
await sleep(P.interval * 1000);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function runStream(root, req) {
|
|
482
|
+
const cfg = root._cfg, t0 = performance.now();
|
|
483
|
+
light(root, "start");
|
|
484
|
+
const r = await fetch("api/http/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload(root, req)) });
|
|
485
|
+
if (!r.ok) { const j = await r.json().catch(() => ({})); const e = new Error(j.error || "HTTP " + r.status); e.kind = j.kind; throw e; }
|
|
486
|
+
const up = +r.headers.get("X-Api-Status") || 200;
|
|
487
|
+
const box = document.createElement("div");
|
|
488
|
+
box.className = "api-stream";
|
|
489
|
+
out(root).appendChild(box);
|
|
490
|
+
light(root, "poll");
|
|
491
|
+
const reader = r.body.getReader(), dec = new TextDecoder();
|
|
492
|
+
let buf = "", text = "", raw = "", n = 0;
|
|
493
|
+
for (;;) {
|
|
494
|
+
const { value, done } = await reader.read();
|
|
495
|
+
if (done) break;
|
|
496
|
+
const s = dec.decode(value, { stream: true });
|
|
497
|
+
raw += s; buf += s;
|
|
498
|
+
let i;
|
|
499
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
500
|
+
const line = buf.slice(0, i).trim();
|
|
501
|
+
buf = buf.slice(i + 1);
|
|
502
|
+
if (!line.startsWith("data:")) continue;
|
|
503
|
+
const d = line.slice(5).trim();
|
|
504
|
+
if (!d || d === "[DONE]") continue;
|
|
505
|
+
let piece;
|
|
506
|
+
try { piece = C.get(JSON.parse(d), cfg.stream.text); } catch { piece = d; }
|
|
507
|
+
if (piece != null) { text += piece; n++; box.textContent = text; pulse(root); }
|
|
508
|
+
}
|
|
509
|
+
status(root, statusBar({ status: up, ms: performance.now() - t0 }, `<span class="api-meta">${n} pedaços</span>`));
|
|
510
|
+
}
|
|
511
|
+
light(root, "done");
|
|
512
|
+
if (up >= 400) { box.remove(); showResult(root, { status: up, body: raw, ms: performance.now() - t0 }); return null; }
|
|
513
|
+
if (!text && raw) box.textContent = raw;
|
|
514
|
+
const ms = Math.round(performance.now() - t0);
|
|
515
|
+
status(root, statusBar({ status: up, ms }, `<span class="api-meta">${n} pedaços</span>`));
|
|
516
|
+
return { mode: "stream", env: STATE.current, stream: { status: up, text: text || raw, ms, pieces: n } };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---------- embeddings: vetor e similaridade por cosseno ----------
|
|
520
|
+
const pct = (x) => Math.max(0, Math.min(100, x * 100)).toFixed(1) + "%";
|
|
521
|
+
const num = (x) => x.toFixed(2).replace(".", ",");
|
|
522
|
+
function vecHTML(preview, dims) {
|
|
523
|
+
const max = Math.max(1e-9, ...preview.map((v) => Math.abs(v)));
|
|
524
|
+
return `<div class="api-vec">${preview.map((v, k) => `<i style="--k:${k};background:hsl(${v < 0 ? 212 : 24} 85% ${Math.round(92 - (Math.abs(v) / max) * 46)}%)" title="${v}"></i>`).join("")}</div><div class="api-sim-l">vetor com ${Number(dims).toLocaleString("pt-BR")} números · os ${preview.length} primeiros (azul negativo, laranja positivo)</div>`;
|
|
525
|
+
}
|
|
526
|
+
function simBox(root, sim) {
|
|
527
|
+
let box = $(".api-sim", root);
|
|
528
|
+
if (!box) { box = document.createElement("div"); box.className = "api-sim"; out(root).appendChild(box); }
|
|
529
|
+
box.innerHTML = `<div class="api-sim-ref"><div class="f-label">referência</div><div class="api-sim-t">${esc(sim.reference)}</div>${sim.preview ? vecHTML(sim.preview, sim.dims) : ""}</div>` +
|
|
530
|
+
sim.items.map((it) => `<div class="api-sim-row" data-score="${it.score}"><div class="api-sim-t">${esc(it.text)}</div><div class="api-sim-bar"><i style="--w:0%"></i></div><b>${it.score == null ? "…" : num(it.score)}</b></div>`).join("");
|
|
531
|
+
const best = Math.max(...sim.items.map((it) => (it.score == null ? -Infinity : it.score)));
|
|
532
|
+
requestAnimationFrame(() => $$(".api-sim-row", box).forEach((row, i) => {
|
|
533
|
+
const sc = sim.items[i].score;
|
|
534
|
+
if (sc != null) { $("i", row).style.setProperty("--w", pct(sc)); row.classList.toggle("win", sim.items.length > 1 && sc === best && sim.done); }
|
|
535
|
+
}));
|
|
536
|
+
}
|
|
537
|
+
async function runSimilarity(root, req) {
|
|
538
|
+
const cfg = root._cfg;
|
|
539
|
+
const ref = ($("[data-api-ref]", root) || {}).value || cfg.similarity.reference;
|
|
540
|
+
const texts = (($("[data-api-texts]", root) || {}).value || cfg.similarity.texts.join("\n")).split("\n").map((t) => t.trim()).filter(Boolean);
|
|
541
|
+
if (!ref.trim() || !texts.length) throw new Error("Escreva a frase de referência e pelo menos uma para comparar (aba Frases).");
|
|
542
|
+
const embed = async (t) => {
|
|
543
|
+
const r = await call("api/http/send", payload(root, C.render(req, { text: t })));
|
|
544
|
+
if (!r.ok) { showResult(root, r); throw new Error(`O serviço respondeu ${r.status}.`); }
|
|
545
|
+
const v = C.get(r.body, cfg.similarity.vector);
|
|
546
|
+
if (!Array.isArray(v) || !v.length) throw new Error(`A resposta não tem o vetor em ${cfg.similarity.vector}.`);
|
|
547
|
+
return { v, r };
|
|
548
|
+
};
|
|
549
|
+
const t0 = performance.now();
|
|
550
|
+
light(root, "start");
|
|
551
|
+
const first = await embed(ref);
|
|
552
|
+
const sim = { reference: ref, dims: first.v.length, preview: first.v.slice(0, 48).map((x) => +Number(x).toFixed(4)), items: texts.map((t) => ({ text: t, score: null })) };
|
|
553
|
+
simBox(root, sim);
|
|
554
|
+
light(root, "poll");
|
|
555
|
+
for (const it of sim.items) { pulse(root); it.score = C.cosine(first.v, (await embed(it.text)).v); simBox(root, sim); }
|
|
556
|
+
sim.done = true;
|
|
557
|
+
sim.items.sort((a, b) => b.score - a.score);
|
|
558
|
+
simBox(root, sim);
|
|
559
|
+
light(root, "done");
|
|
560
|
+
status(root, statusBar(first.r, `<span class="api-meta">${texts.length + 1} embeddings · ${fmtMs(performance.now() - t0)}</span>`));
|
|
561
|
+
return { mode: "similarity", env: STATE.current, sim };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function keep(root, rec) {
|
|
565
|
+
const cfg = root._cfg;
|
|
566
|
+
const body = rec.final ? rec.final.body : rec.result ? rec.result.body : null;
|
|
567
|
+
const got = [];
|
|
568
|
+
Object.entries(cfg.save || {}).forEach(([name, path]) => { const v = C.get(body, path); if (v != null) { saved[name] = v; meta.from[name] = slideNo(root); got.push(name); } });
|
|
569
|
+
if (got.length) {
|
|
570
|
+
persist();
|
|
571
|
+
const box = document.createElement("div");
|
|
572
|
+
box.className = "api-saved";
|
|
573
|
+
box.innerHTML = got.map((n) => `<span>guardado <b>{{${esc(n)}}}</b> = ${esc(String(saved[n]).slice(0, 60))}</span>`).join("");
|
|
574
|
+
out(root).appendChild(box);
|
|
575
|
+
roots.forEach(paintCode);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ---------- conversa em tempo real (WebSocket, pela ponte do Studio) ----------
|
|
580
|
+
const b64ToBytes = (b64) => { const s = atob(b64 || ""); const u = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u[i] = s.charCodeAt(i); return u; };
|
|
581
|
+
const bytesToB64 = (u) => { let s = ""; for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode.apply(null, u.subarray(i, i + 0x8000)); return btoa(s); };
|
|
582
|
+
// no registro, o áudio vira "(N KB de áudio)" — o que importa é ver o tipo e a estrutura
|
|
583
|
+
const shortAudio = (o) => JSON.stringify(o, (k, v) => (typeof v === "string" && v.length > 200 && /^[A-Za-z0-9+/=]+$/.test(v) ? `…(${(v.length * 0.75 / 1024).toFixed(1).replace(".", ",")} KB de áudio)` : v));
|
|
584
|
+
function logRt(root, dir, msg) {
|
|
585
|
+
const box = $(".api-log", root);
|
|
586
|
+
if (!box) return;
|
|
587
|
+
const obj = typeof msg === "string" ? (() => { try { return JSON.parse(msg); } catch { return msg; } })() : msg;
|
|
588
|
+
const type = obj && typeof obj === "object" ? C.get(obj, root._cfg.realtime.receive.type) : null;
|
|
589
|
+
const row = document.createElement("div");
|
|
590
|
+
row.className = dir === "up" ? "up" : "dn";
|
|
591
|
+
row.innerHTML = `<b>${dir === "up" ? '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg>' : '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M19 12l-7 7-7-7"/></svg>'}</b><em>${esc(type || "")}</em><span>${esc(typeof obj === "string" ? obj : shortAudio(obj))}</span>`;
|
|
592
|
+
// pedaços de áudio seguidos viram uma linha só, com contador
|
|
593
|
+
const last = box.lastElementChild;
|
|
594
|
+
if (last && last.dataset.type === String(type) && /audio/.test(String(type)) && last.className === row.className) { last.dataset.n = +(last.dataset.n || 1) + 1; last.querySelector("em").textContent = `${type} ×${last.dataset.n}`; return; }
|
|
595
|
+
row.dataset.type = String(type);
|
|
596
|
+
box.appendChild(row);
|
|
597
|
+
while (box.children.length > 300) box.firstChild.remove();
|
|
598
|
+
box.scrollTop = box.scrollHeight;
|
|
599
|
+
}
|
|
600
|
+
function bubble(root, who, text) {
|
|
601
|
+
let box = $(".api-rt", root);
|
|
602
|
+
if (!box) { box = document.createElement("div"); box.className = "api-rt"; out(root).appendChild(box); }
|
|
603
|
+
const b = document.createElement("div");
|
|
604
|
+
b.className = "api-bub " + who;
|
|
605
|
+
b.textContent = text || "";
|
|
606
|
+
box.appendChild(b);
|
|
607
|
+
out(root).scrollTop = out(root).scrollHeight;
|
|
608
|
+
(root._rt ? root._rt.log : []).push({ who, text: text || "" });
|
|
609
|
+
return b;
|
|
610
|
+
}
|
|
611
|
+
function playPcm(root, b64) {
|
|
612
|
+
const st = root._rt, rate = root._cfg.realtime.audio.rate;
|
|
613
|
+
const bytes = b64ToBytes(b64);
|
|
614
|
+
const n = bytes.length >> 1;
|
|
615
|
+
if (!n) return;
|
|
616
|
+
const view = new DataView(bytes.buffer), f = new Float32Array(n);
|
|
617
|
+
for (let i = 0; i < n; i++) f[i] = view.getInt16(i * 2, true) / 32768;
|
|
618
|
+
st.ac = st.ac || new (window.AudioContext || window.webkitAudioContext)();
|
|
619
|
+
const buf = st.ac.createBuffer(1, n, rate);
|
|
620
|
+
buf.copyToChannel(f, 0);
|
|
621
|
+
const src = st.ac.createBufferSource();
|
|
622
|
+
src.buffer = buf;
|
|
623
|
+
src.connect(st.ac.destination);
|
|
624
|
+
const at = Math.max(st.ac.currentTime + 0.05, st.next || 0);
|
|
625
|
+
src.start(at);
|
|
626
|
+
st.next = at + buf.duration;
|
|
627
|
+
st.played = (st.played || 0) + 1;
|
|
628
|
+
}
|
|
629
|
+
function onRt(root, ev) {
|
|
630
|
+
const st = root._rt;
|
|
631
|
+
if (!st) return;
|
|
632
|
+
if (ev.type === "close") { logRt(root, "dn", `(conexão fechada: ${ev.code}${ev.why ? " " + ev.why : ""})`); rtClosed(root); return; }
|
|
633
|
+
if (ev.bin) { logRt(root, "dn", `(binário, ${Math.round(ev.bin.length * 0.75)} bytes)`); return; }
|
|
634
|
+
logRt(root, "dn", ev.text);
|
|
635
|
+
let m;
|
|
636
|
+
try { m = JSON.parse(ev.text); } catch { return; }
|
|
637
|
+
const R = root._cfg.realtime, T = R.receive.type;
|
|
638
|
+
if (C.isType(m, R.receive.audio, T)) playPcm(root, C.get(m, R.receive.audio.data));
|
|
639
|
+
else if (C.isType(m, R.receive.text, T)) {
|
|
640
|
+
if (!st.bot) { st.bot = bubble(root, "bot", ""); st.bot.classList.add("live"); st.botText = ""; st.botEntry = st.log[st.log.length - 1]; }
|
|
641
|
+
st.botText += String(C.get(m, R.receive.text.data) ?? "");
|
|
642
|
+
st.bot.textContent = st.botText;
|
|
643
|
+
st.botEntry.text = st.botText;
|
|
644
|
+
out(root).scrollTop = out(root).scrollHeight;
|
|
645
|
+
} else if (C.isType(m, R.receive.user, T)) bubble(root, "user", String(C.get(m, R.receive.user.data) ?? ""));
|
|
646
|
+
else if (R.receive.done.includes(String(C.get(m, T)))) { if (st.bot) st.bot.classList.remove("live"); st.bot = null; }
|
|
647
|
+
else if (C.isType(m, R.receive.error, T)) showError(root, String(C.get(m, R.receive.error.data) || "erro do serviço"));
|
|
648
|
+
}
|
|
649
|
+
async function rtSend(root, msgs) {
|
|
650
|
+
const st = root._rt;
|
|
651
|
+
if (!st) return;
|
|
652
|
+
[].concat(msgs).forEach((m) => { if (!/input_audio_buffer\.append/.test(JSON.stringify(m).slice(0, 80))) logRt(root, "up", m); else logRt(root, "up", { type: C.get(m, root._cfg.realtime.receive.type) || "áudio" }); });
|
|
653
|
+
await call("api/http/rt/send", { sid: st.sid, messages: [].concat(msgs) });
|
|
654
|
+
}
|
|
655
|
+
function rtControls(root, on) {
|
|
656
|
+
$$("[data-rt-mic],[data-rt-text],[data-rt-send]", root).forEach((e) => { e.disabled = !on; });
|
|
657
|
+
}
|
|
658
|
+
async function rtOpen(root) {
|
|
659
|
+
const cfg = root._cfg, R = cfg.realtime;
|
|
660
|
+
let open;
|
|
661
|
+
try { open = JSON.parse(($("[data-api-body]", root) || {}).value || "[]"); } catch (e) { return showError(root, "As mensagens de 'Ao conectar' não são um JSON válido: " + e.message); }
|
|
662
|
+
const url = C.render($("[data-api-url]", root).value.trim(), vars());
|
|
663
|
+
const miss = C.missing(url, {}).filter((m) => !/^secret\./.test(m));
|
|
664
|
+
if (miss.length) return showError(root, `Falta ${miss.map((m) => "{{" + m + "}}").join(", ")} (vars do ambiente).`);
|
|
665
|
+
clearOut(root);
|
|
666
|
+
label(root, "Conectando…");
|
|
667
|
+
const r = await call("api/http/rt/open", { realtime: { url, auth: R.auth }, open: C.render([].concat(open), vars()) });
|
|
668
|
+
root._rt = { sid: r.sid, log: [], t0: Date.now() };
|
|
669
|
+
[].concat(open).forEach((m) => logRt(root, "up", m));
|
|
670
|
+
const es = new EventSource("api/http/rt/events?sid=" + encodeURIComponent(r.sid));
|
|
671
|
+
root._rt.es = es;
|
|
672
|
+
es.onmessage = (e) => { try { onRt(root, JSON.parse(e.data)); } catch {} };
|
|
673
|
+
es.onerror = () => { if (root._rt && root._rt.es === es && es.readyState === 2) rtClosed(root); };
|
|
674
|
+
label(root, "Desconectar");
|
|
675
|
+
root.classList.add("rt-on");
|
|
676
|
+
status(root, `<span class="api-pill ok">conectado</span><span class="api-meta">${esc(String(r.env || "").toUpperCase())} · ${esc(r.url.replace(/[?].*/, ""))}</span>`);
|
|
677
|
+
rtControls(root, true);
|
|
678
|
+
}
|
|
679
|
+
function rtClosed(root) {
|
|
680
|
+
const st = root._rt;
|
|
681
|
+
if (!st) return;
|
|
682
|
+
if (st.mic) stopMic(root, false);
|
|
683
|
+
if (st.es) st.es.close();
|
|
684
|
+
root._rt = null;
|
|
685
|
+
root.classList.remove("rt-on");
|
|
686
|
+
rtControls(root, false);
|
|
687
|
+
label(root, "Conectar");
|
|
688
|
+
status(root, `<span class="api-meta">desconectado · ${fmtMs(Date.now() - st.t0)} de conversa</span>`);
|
|
689
|
+
const talk = st.log.filter((b) => b.text);
|
|
690
|
+
if (talk.length) {
|
|
691
|
+
const rec = { mode: "realtime", env: STATE.current, talk };
|
|
692
|
+
STATE.recordings[root._cfg.key] = Object.assign({ at: new Date().toISOString() }, rec);
|
|
693
|
+
call("api/http/record", { key: root._cfg.key, record: rec }).catch(() => {});
|
|
694
|
+
}
|
|
695
|
+
window.sagadeckApi.rtLast = { played: st.played || 0, talk };
|
|
696
|
+
}
|
|
697
|
+
async function rtToggle(root) {
|
|
698
|
+
if (root._rt) {
|
|
699
|
+
const st = root._rt;
|
|
700
|
+
call("api/http/rt/close", { sid: st.sid }).catch(() => {});
|
|
701
|
+
rtClosed(root);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (!STATE.live) { const rec = STATE.recordings[root._cfg.key]; if (rec) return replay(root, rec, true); return showError(root, STATE.reason || "Sem o Studio para conectar."); }
|
|
705
|
+
try { await rtOpen(root); } catch (e) { label(root, "Conectar"); showError(root, e.message, e.kind); }
|
|
706
|
+
}
|
|
707
|
+
// microfone → PCM16 mono na taxa do serviço, em pedaços de ~200 ms
|
|
708
|
+
async function startMic(root) {
|
|
709
|
+
const st = root._rt, R = root._cfg.realtime;
|
|
710
|
+
const btn = $("[data-rt-mic]", root), cv = $(".api-level", root);
|
|
711
|
+
let stream;
|
|
712
|
+
try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
|
|
713
|
+
catch (e) { return showError(root, "Não consegui usar o microfone: " + (e.message || e.name)); }
|
|
714
|
+
const ac = new (window.AudioContext || window.webkitAudioContext)();
|
|
715
|
+
const src = ac.createMediaStreamSource(stream);
|
|
716
|
+
const proc = ac.createScriptProcessor(4096, 1, 1);
|
|
717
|
+
const ratio = ac.sampleRate / R.audio.rate;
|
|
718
|
+
let pend = [], pendN = 0;
|
|
719
|
+
const g = cv.getContext("2d");
|
|
720
|
+
proc.onaudioprocess = (e) => {
|
|
721
|
+
const x = e.inputBuffer.getChannelData(0);
|
|
722
|
+
let peak = 0;
|
|
723
|
+
const n = Math.floor(x.length / ratio), out16 = new Int16Array(n);
|
|
724
|
+
for (let i = 0; i < n; i++) {
|
|
725
|
+
let s = 0, c = 0;
|
|
726
|
+
for (let j = Math.floor(i * ratio); j < Math.floor((i + 1) * ratio) && j < x.length; j++) { s += x[j]; c++; }
|
|
727
|
+
const v = Math.max(-1, Math.min(1, c ? s / c : 0));
|
|
728
|
+
peak = Math.max(peak, Math.abs(v));
|
|
729
|
+
out16[i] = v < 0 ? v * 32768 : v * 32767;
|
|
730
|
+
}
|
|
731
|
+
g.clearRect(0, 0, cv.width, cv.height);
|
|
732
|
+
for (let i = 0; i < 16; i++) { g.fillStyle = i / 16 < peak * 1.6 ? "#ff9aa2" : "#2a3446"; g.fillRect(i * 10, 6, 7, 24); }
|
|
733
|
+
pend.push(out16); pendN += n;
|
|
734
|
+
if (pendN >= R.audio.rate * 0.2) {
|
|
735
|
+
const all = new Int16Array(pendN); let o = 0;
|
|
736
|
+
for (const p of pend) { all.set(p, o); o += p.length; }
|
|
737
|
+
pend = []; pendN = 0;
|
|
738
|
+
st.sentAudio = (st.sentAudio || 0) + all.length;
|
|
739
|
+
rtSend(root, [C.render(R.audio.send, Object.assign(vars(), { audio: bytesToB64(new Uint8Array(all.buffer)) }))]).catch(() => {});
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
src.connect(proc); proc.connect(ac.destination);
|
|
743
|
+
st.mic = { stream, ac, proc, flush: () => { if (pendN) { const all = new Int16Array(pendN); let o = 0; for (const p of pend) { all.set(p, o); o += p.length; } pend = []; pendN = 0; return all; } return null; } };
|
|
744
|
+
btn.classList.add("rec"); $("span", btn).textContent = "Parar"; cv.hidden = false;
|
|
745
|
+
}
|
|
746
|
+
function stopMic(root, commit = true) {
|
|
747
|
+
const st = root._rt, R = root._cfg.realtime;
|
|
748
|
+
if (!st || !st.mic) return;
|
|
749
|
+
const { stream, ac, proc, flush } = st.mic;
|
|
750
|
+
st.mic = null;
|
|
751
|
+
const rest = flush();
|
|
752
|
+
proc.disconnect(); stream.getTracks().forEach((t) => t.stop()); ac.close();
|
|
753
|
+
const btn = $("[data-rt-mic]", root); btn.classList.remove("rec"); $("span", btn).textContent = "Falar"; $(".api-level", root).hidden = true;
|
|
754
|
+
if (!commit) return;
|
|
755
|
+
const msgs = [];
|
|
756
|
+
if (rest && rest.length) msgs.push(C.render(R.audio.send, Object.assign(vars(), { audio: bytesToB64(new Uint8Array(rest.buffer)) })));
|
|
757
|
+
msgs.push(...C.render(R.audio.commit, vars()));
|
|
758
|
+
rtSend(root, msgs).catch((e) => showError(root, e.message));
|
|
759
|
+
}
|
|
760
|
+
function sendText(root) {
|
|
761
|
+
const inp = $("[data-rt-text]", root);
|
|
762
|
+
const text = inp.value.trim();
|
|
763
|
+
if (!text || !root._rt) return;
|
|
764
|
+
inp.value = "";
|
|
765
|
+
bubble(root, "user", text);
|
|
766
|
+
rtSend(root, C.render(root._cfg.realtime.text, Object.assign(vars(), { text }))).catch((e) => showError(root, e.message));
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function run(root) {
|
|
770
|
+
const btn = $("[data-api-run]", root);
|
|
771
|
+
btn.blur();
|
|
772
|
+
if (root._cfg.realtime) return rtToggle(root);
|
|
773
|
+
if (root._running) return;
|
|
774
|
+
const cfg = root._cfg;
|
|
775
|
+
const menu = $(".api-menu", root); if (menu) menu.remove();
|
|
776
|
+
if (!STATE.live) {
|
|
777
|
+
const rec = STATE.recordings[cfg.key];
|
|
778
|
+
clearOut(root);
|
|
779
|
+
if (rec) return replay(root, rec, true);
|
|
780
|
+
return showError(root, STATE.reason || "Sem o Studio para executar.");
|
|
781
|
+
}
|
|
782
|
+
let req;
|
|
783
|
+
try { req = C.render(readReq(root), vars()); } catch (e) { clearOut(root); return showError(root, e.message); }
|
|
784
|
+
const miss = C.missing(req, {}).filter((m) => !/^(file|secret)\./.test(m) && !(cfg.similarity && m === "text"));
|
|
785
|
+
if (miss.length) { clearOut(root); return showError(root, `Falta ${miss.map((m) => "{{" + m + "}}").join(", ")}: defina em vars do ambiente, ou execute antes o slide que guarda esse valor (save:).`); }
|
|
786
|
+
root._running = true;
|
|
787
|
+
btn.disabled = true; label(root, "Executando…");
|
|
788
|
+
root.classList.add("running");
|
|
789
|
+
clearOut(root);
|
|
790
|
+
try {
|
|
791
|
+
const rec = cfg.similarity ? await runSimilarity(root, req) : cfg.mode === "polling" ? await runPolling(root, req) : cfg.mode === "stream" ? await runStream(root, req) : await runSync(root, req);
|
|
792
|
+
if (rec) {
|
|
793
|
+
keep(root, rec);
|
|
794
|
+
const big = rec.result && rec.result.body && rec.result.body._audio && rec.result.body.base64.length > 1500000;
|
|
795
|
+
const saved = big ? Object.assign({}, rec, { result: Object.assign({}, rec.result, { body: { nota: "áudio grande demais para gravar" } }) }) : rec;
|
|
796
|
+
STATE.recordings[cfg.key] = Object.assign({ at: new Date().toISOString() }, saved);
|
|
797
|
+
call("api/http/record", { key: cfg.key, record: saved }).catch(() => {});
|
|
798
|
+
}
|
|
799
|
+
} catch (e) {
|
|
800
|
+
showError(root, e.message, e.kind);
|
|
801
|
+
} finally {
|
|
802
|
+
root._running = false;
|
|
803
|
+
btn.disabled = false;
|
|
804
|
+
root.classList.remove("running");
|
|
805
|
+
paintEnv(root);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// a última resposta boa, no mesmo ritmo em que aconteceu (encurtado); instant = sem animação
|
|
810
|
+
async function replay(root, rec, animate) {
|
|
811
|
+
const badge = `<span class="api-rec">gravado ${esc(when(rec.at))}${rec.env ? " · " + esc(String(rec.env).toUpperCase()) : ""}</span>`;
|
|
812
|
+
clearOut(root);
|
|
813
|
+
const wait = (ms) => (animate ? sleep(Math.min(ms, 900)) : Promise.resolve());
|
|
814
|
+
if (rec.mode === "realtime" && rec.talk) {
|
|
815
|
+
clearOut(root);
|
|
816
|
+
for (const b of rec.talk) { if (animate) await sleep(350); const el = document.createElement("div"); el.className = "api-bub " + b.who; el.textContent = b.text; let box = $(".api-rt", root); if (!box) { box = document.createElement("div"); box.className = "api-rt"; out(root).appendChild(box); } box.appendChild(el); }
|
|
817
|
+
status(root, `<span class="api-meta">conversa gravada</span>${badge}`);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (rec.mode === "similarity" && rec.sim) {
|
|
821
|
+
light(root, "done");
|
|
822
|
+
simBox(root, Object.assign({}, rec.sim, { done: true }));
|
|
823
|
+
status(root, `<span class="api-meta">${rec.sim.items.length + 1} embeddings</span>${badge}`);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (rec.mode === "polling" && rec.final) {
|
|
827
|
+
light(root, "start");
|
|
828
|
+
row(root, "início", `${rec.start ? rec.start.status : ""} · execução ${C.get(rec.start && rec.start.body, root._cfg.polling.id) ?? ""}`, "start");
|
|
829
|
+
let prev = 0;
|
|
830
|
+
for (const p of rec.polls || []) {
|
|
831
|
+
await wait(p.t - prev); prev = p.t;
|
|
832
|
+
light(root, "poll"); if (animate) pulse(root);
|
|
833
|
+
row(root, p.status, fmtMs(p.t), root._cfg.polling.done.includes(p.status) ? "done" : "");
|
|
834
|
+
}
|
|
835
|
+
light(root, "done");
|
|
836
|
+
showResult(root, rec.final, badge);
|
|
837
|
+
} else if (rec.mode === "stream" && rec.stream) {
|
|
838
|
+
light(root, "poll");
|
|
839
|
+
const box = document.createElement("div");
|
|
840
|
+
box.className = "api-stream";
|
|
841
|
+
out(root).appendChild(box);
|
|
842
|
+
if (animate) { for (let i = 0; i <= rec.stream.text.length; i += 3) { box.textContent = rec.stream.text.slice(0, i); await sleep(12); } }
|
|
843
|
+
box.textContent = rec.stream.text;
|
|
844
|
+
light(root, "done");
|
|
845
|
+
status(root, statusBar({ status: rec.stream.status, ms: rec.stream.ms }, badge));
|
|
846
|
+
} else if (rec.result) {
|
|
847
|
+
light(root, "done");
|
|
848
|
+
showResult(root, rec.result, badge);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ---------- controle flutuante (Document Picture-in-Picture) ----------
|
|
853
|
+
// Uma janelinha sempre por cima (o portal pode ficar em tela cheia): ambiente, Executar, status e o
|
|
854
|
+
// resumo da resposta do slide atual, espelhados daqui; ← → passam os slides.
|
|
855
|
+
const PIP_CSS = `body{margin:0;background:#0d1320;color:#e8edf5;font:15px system-ui,sans-serif}
|
|
856
|
+
.pip{display:flex;flex-direction:column;height:100vh;box-sizing:border-box;padding:12px;gap:10px}
|
|
857
|
+
.pip-top{display:flex;align-items:center;gap:8px;min-height:28px}
|
|
858
|
+
.pip-top b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:15px}
|
|
859
|
+
.pip-env{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;border:1px solid #ffffff2a;font:700 12px system-ui;letter-spacing:.08em}
|
|
860
|
+
.pip-env i{width:9px;height:9px;border-radius:50%;background:#8a8f98}
|
|
861
|
+
.pip-env[data-kind=dev] i{background:#2fb96b}.pip-env[data-kind=hom] i{background:#f0a020}.pip-env[data-kind=prod] i{background:#e5484d}
|
|
862
|
+
.pip-run{height:44px;border:0;border-radius:10px;background:#ffd21f;color:#1a1a1a;font:800 17px system-ui;cursor:pointer}
|
|
863
|
+
.pip-run:disabled{opacity:.6}
|
|
864
|
+
.pip-status{display:flex;flex-wrap:wrap;align-items:center;gap:8px;min-height:22px;font-size:13px}
|
|
865
|
+
.pip-status .api-pill{font-size:13px;padding:3px 9px}.pip-status .api-meta{color:#8d9ab1}.pip-status .api-rec{margin-left:0;font-size:11px}
|
|
866
|
+
.pip-body{flex:1;min-height:0;overflow:auto;border-radius:10px;background:#101725}
|
|
867
|
+
.pip-out{zoom:.5;padding:16px}
|
|
868
|
+
.pip-nav{display:flex;align-items:center;gap:8px}
|
|
869
|
+
.pip-nav button{width:40px;height:32px;border:1px solid #ffffff2a;border-radius:8px;background:none;color:#e8edf5;font:700 16px system-ui;cursor:pointer}
|
|
870
|
+
.pip-nav span{flex:1;text-align:center;color:#8d9ab1;font-size:13px}
|
|
871
|
+
.pip-empty{padding:16px;color:#8d9ab1}`;
|
|
872
|
+
let pipWin = null;
|
|
873
|
+
const currentRoot = () => { const i = window.sagadeck ? window.sagadeck.cur : 0; const sl = document.querySelectorAll(".slide")[i]; return sl ? [...sl.querySelectorAll(".L-api[data-api]")].find(r=>!r.closest(".api-collection-panel[hidden]")) || null : null; };
|
|
874
|
+
function renderPip() {
|
|
875
|
+
if (!pipWin || pipWin.closed) return;
|
|
876
|
+
const d = pipWin.document, root = currentRoot();
|
|
877
|
+
const i = window.sagadeck ? window.sagadeck.cur : 0, n = window.sagadeck ? window.sagadeck.n : 1;
|
|
878
|
+
const env = root ? $("[data-api-env]", root) : null;
|
|
879
|
+
const html = root
|
|
880
|
+
? `<div class="pip-top"><span class="pip-env" data-kind="${esc(env.dataset.kind || "")}"><i></i>${esc($(".api-env-name", env).textContent)}</span><b>${esc(root._cfg.title || "Requisição")}</b></div>
|
|
881
|
+
<button class="pip-run" ${root._running ? "disabled" : ""}>${root._running ? "Executando…" : esc($("[data-api-run] span", root).textContent)}</button>
|
|
882
|
+
<div class="pip-status">${$(".api-status", root).innerHTML}</div>
|
|
883
|
+
<div class="pip-body"><div class="pip-out">${$(".api-out", root).innerHTML}</div></div>`
|
|
884
|
+
: `<div class="pip-top"><b>Slide ${i + 1}</b></div><div class="pip-body"><div class="pip-empty">Este slide não tem requisição.</div></div>`;
|
|
885
|
+
const key = html + i;
|
|
886
|
+
if (d.body._key !== key) {
|
|
887
|
+
d.body._key = key;
|
|
888
|
+
d.body.innerHTML = `<div class="pip">${html}<div class="pip-nav"><button data-go="-1" title="Slide anterior"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5M12 19l-7-7 7-7"/></svg></button><span>${i + 1} / ${n}</span><button data-go="1" title="Próximo slide"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M12 5l7 7-7 7"/></svg></button></div></div>`;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
async function openPip(root) {
|
|
892
|
+
if (!("documentPictureInPicture" in window)) { clearOut(root); showError(root, "Este navegador não tem o controle flutuante (use o Chrome ou o Edge atualizados)."); return; }
|
|
893
|
+
if (window.top !== window) { // dentro do Studio (quadro): o Chrome só abre a janelinha em janela própria
|
|
894
|
+
window.open(location.href.split("#")[0] + "#" + ((window.sagadeck ? window.sagadeck.cur : 0) + 1), "_blank");
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (pipWin && !pipWin.closed) { pipWin.focus(); return; }
|
|
898
|
+
pipWin = await window.documentPictureInPicture.requestWindow({ width: 420, height: 560 });
|
|
899
|
+
const css = pipWin.document.createElement("style");
|
|
900
|
+
css.textContent = Array.from(document.querySelectorAll("style")).map((s) => s.textContent).join("\n") + PIP_CSS;
|
|
901
|
+
pipWin.document.head.appendChild(css);
|
|
902
|
+
pipWin.document.title = "Controle · " + (document.title || "SagaDeck");
|
|
903
|
+
pipWin.document.addEventListener("click", (e) => {
|
|
904
|
+
const b = e.target.closest("[data-go]");
|
|
905
|
+
if (b && window.sagadeck) window.sagadeck.goto(Math.max(0, Math.min(window.sagadeck.n - 1, window.sagadeck.cur + Number(b.dataset.go))), 0);
|
|
906
|
+
if (e.target.closest(".pip-run")) { const r = currentRoot(); if (r) run(r); }
|
|
907
|
+
});
|
|
908
|
+
pipWin.document.addEventListener("keydown", (e) => {
|
|
909
|
+
if (!window.sagadeck) return;
|
|
910
|
+
if (e.key === "ArrowRight" || e.key === "PageDown") window.sagadeck.goto(Math.min(window.sagadeck.n - 1, window.sagadeck.cur + 1), 0);
|
|
911
|
+
if (e.key === "ArrowLeft" || e.key === "PageUp") window.sagadeck.goto(Math.max(0, window.sagadeck.cur - 1), 0);
|
|
912
|
+
});
|
|
913
|
+
const iv = setInterval(() => { if (!pipWin || pipWin.closed) { clearInterval(iv); pipWin = null; return; } renderPip(); }, 300);
|
|
914
|
+
renderPip();
|
|
915
|
+
window.sagadeckApi.pip = pipWin;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function mount(root) {
|
|
919
|
+
try { root._cfg = JSON.parse(root.dataset.api); } catch { return; }
|
|
920
|
+
$$("[data-tab]", root).forEach((b) => { b.onclick = () => showTab(root, b.dataset.tab); });
|
|
921
|
+
$("[data-api-run]", root).onclick = () => run(root);
|
|
922
|
+
$("[data-api-env]", root).onclick = (e) => envMenu(root, e.currentTarget);
|
|
923
|
+
const varsBtn = $("[data-api-vars]", root);
|
|
924
|
+
if (varsBtn) varsBtn.onclick = () => varsPanel(root);
|
|
925
|
+
if (root._cfg.realtime) {
|
|
926
|
+
$("[data-rt-mic]", root).onclick = () => (root._rt && root._rt.mic ? stopMic(root) : startMic(root));
|
|
927
|
+
$("[data-rt-send]", root).onclick = () => sendText(root);
|
|
928
|
+
$("[data-rt-text]", root).addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); sendText(root); } });
|
|
929
|
+
}
|
|
930
|
+
const pipBtn = $("[data-api-pip]", root);
|
|
931
|
+
if (pipBtn) pipBtn.onclick = () => openPip(root).catch((e) => { clearOut(root); showError(root, "Não abriu o controle flutuante: " + e.message); });
|
|
932
|
+
["[data-api-url]", "[data-api-body]", "[data-api-headers]", "[data-api-ref]", "[data-api-texts]"].forEach((sel) => { const el = $(sel, root); if (el) el.addEventListener("input", () => paintCode(root)); });
|
|
933
|
+
// Enter/espaço num botão do slide não avançam a apresentação
|
|
934
|
+
const zone = $("[data-api-file]", root);
|
|
935
|
+
if (zone) {
|
|
936
|
+
const inp = $("[data-api-input]", zone);
|
|
937
|
+
$("[data-api-pick]", zone).onclick = () => inp.click();
|
|
938
|
+
const mic = $("[data-api-mic]", zone);
|
|
939
|
+
if (mic) mic.onclick = () => toggleMic(root);
|
|
940
|
+
inp.onchange = () => pickFile(root, inp.files[0]);
|
|
941
|
+
const req = $(".api-req", root);
|
|
942
|
+
req.addEventListener("dragover", (e) => { e.preventDefault(); zone.classList.add("drag"); });
|
|
943
|
+
req.addEventListener("dragleave", () => zone.classList.remove("drag"));
|
|
944
|
+
req.addEventListener("drop", (e) => { e.preventDefault(); zone.classList.remove("drag"); pickFile(root, e.dataTransfer.files[0]); });
|
|
945
|
+
}
|
|
946
|
+
root.addEventListener("keydown", (e) => { if ((e.key === "Enter" || e.key === " ") && e.target.closest("button,a,select")) e.stopPropagation(); });
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function start() {
|
|
950
|
+
roots.forEach(mount);
|
|
951
|
+
if (location.protocol !== "file:") {
|
|
952
|
+
try {
|
|
953
|
+
const st = await call("api/http/state");
|
|
954
|
+
STATE = Object.assign({}, st, { recordings: Object.assign({}, EMBED, st.recordings || {}) });
|
|
955
|
+
} catch {}
|
|
956
|
+
}
|
|
957
|
+
roots.forEach((root) => {
|
|
958
|
+
paintEnv(root);
|
|
959
|
+
paintCode(root);
|
|
960
|
+
const rec = STATE.recordings[root._cfg.key];
|
|
961
|
+
if (!STATE.live && rec) replay(root, rec, false); // HTML exportado / PDF / servidor: mostra o resultado gravado
|
|
962
|
+
});
|
|
963
|
+
window.sagadeckApi = { get state() { return STATE; }, vars, run: (i) => run(roots[i || 0]) };
|
|
964
|
+
}
|
|
965
|
+
start();
|
|
966
|
+
})();
|