sagadeck 1.4.0 → 1.6.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/README.md +59 -67
- package/SKILL.md +18 -11
- package/bin/sagadeck.js +12 -11
- package/docs/ANDAMENTO.md +355 -0
- package/docs/REFERENCIA.md +4 -4
- package/package.json +2 -3
- package/src/ai/bench.js +132 -0
- package/src/ai/context.js +4 -2
- package/src/ai/critique.js +114 -0
- package/src/ai/deck-ai.js +142 -32
- package/src/ai/document-visuals.js +7 -3
- package/src/ai/ia-config.js +101 -0
- package/src/ai/llm.js +62 -26
- package/src/ai/progress.js +23 -0
- package/src/ai/quality.js +30 -2
- package/src/api-client.js +2 -1
- package/src/build.js +3 -0
- package/src/decision-lab.js +2 -1
- package/src/deck-file.js +2 -1
- package/src/elements.js +10 -3
- package/src/figures/charts.js +7 -4
- package/src/figures/diagrams.js +15 -1
- package/src/figures/ufmap.js +2 -1
- package/src/fs-retry.js +19 -0
- package/src/layouts.js +1 -1
- package/src/library.js +75 -19
- package/src/locale.js +18 -0
- package/src/mcp/server.js +2 -2
- package/src/motion.js +5 -3
- package/src/preferences.js +5 -1
- package/src/research/research.js +6 -5
- package/src/runtime/calc.js +3 -3
- package/src/runtime/decision-lab.js +1 -1
- package/src/runtime/motion.js +5 -1
- package/src/runtime/runtime.js +2 -1
- package/src/studio/ai-chat-route.js +100 -4
- package/src/studio/banner.js +18 -0
- package/src/studio/code-version.js +35 -0
- package/src/studio/generate.js +53 -0
- package/src/studio/ia-routes.js +53 -0
- package/src/studio/project.js +8 -7
- package/src/studio/public/ai-settings.js +76 -0
- package/src/studio/public/app.js +11 -239
- package/src/studio/public/chat.js +71 -1
- package/src/studio/public/header-footer.js +140 -0
- package/src/studio/public/index.html +6 -0
- package/src/studio/public/library.html +3 -1
- package/src/studio/public/library.js +7 -5
- package/src/studio/public/preferences-ui.js +86 -0
- package/src/studio/public/provenance.js +13 -0
- package/src/studio/public/style.css +17 -0
- package/src/studio/public/ui-icons.js +1 -1
- package/src/studio/public/update-notice.js +25 -0
- package/src/studio/public-files.js +11 -1
- package/src/studio/server.js +26 -47
- package/src/studio/share-routes.js +2 -1
- package/src/studio/slide-copy.js +3 -1
- package/src/ai/relay.js +0 -45
|
@@ -451,9 +451,79 @@ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, open
|
|
|
451
451
|
dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
|
|
452
452
|
return msgDiv;
|
|
453
453
|
}
|
|
454
|
+
// Leitura crítica do material anexado (botão do chat): o servidor lê o documento como um revisor da área e devolve
|
|
455
|
+
// inconsistências, achados escondidos, perguntas prováveis e slides que representam mal o material, cada item com
|
|
456
|
+
// trecho conferido. Nada muda nos slides; vira uma mensagem da conversa com opções para aplicar.
|
|
457
|
+
async function runCritique() {
|
|
458
|
+
if (chatJob) return;
|
|
459
|
+
appendChatMessage("user", "Leitura crítica do material");
|
|
460
|
+
const job = chatJob = { file: state.file, id: crypto.randomUUID(), controller: new AbortController() };
|
|
461
|
+
job.stop = () => job.controller.abort();
|
|
462
|
+
const work = createProgressBubble("Lendo o material com olho crítico…");
|
|
463
|
+
try {
|
|
464
|
+
const data = await streamAI("api/ai/critique", { spec: state.deck, requestId: job.id }, (ev) => work.update(ev), job.controller.signal);
|
|
465
|
+
state.chatHistory.push({ role: "user", text: "Leitura crítica do material" });
|
|
466
|
+
state.chatHistory.push({ role: "assistant", text: data.reply + (data.options?.length ? `\n(opções: ${data.options.join(" | ")})` : ""), talk: true });
|
|
467
|
+
await saveChatHistory();
|
|
468
|
+
work.done();
|
|
469
|
+
// com os pontos estruturados, a bolha traz o resumo e cada ponto vira um cartão (o texto inteiro fica no histórico)
|
|
470
|
+
const msg = appendChatMessage("ai", data.critique?.itens?.length ? data.critique.summary : data.reply, data.actions || []);
|
|
471
|
+
msg.classList.add("bs");
|
|
472
|
+
if (data.critique?.itens?.length) renderCritiqueItems(msg, data.critique);
|
|
473
|
+
const tag = document.createElement("div");
|
|
474
|
+
tag.className = "bs-tag";
|
|
475
|
+
tag.innerHTML = '<i class="ic" data-ic="scan-search"></i> Leitura crítica — nada mudou nos slides'; hydrateIcons(tag);
|
|
476
|
+
msg.querySelector(".ai-content").prepend(tag);
|
|
477
|
+
renderChatOptions(msg, data.options);
|
|
478
|
+
updateBrainstormApply();
|
|
479
|
+
} catch (e) {
|
|
480
|
+
work.done();
|
|
481
|
+
appendChatMessage("ai", `A leitura crítica não saiu: ${e.message}`);
|
|
482
|
+
} finally { chatJob = null; }
|
|
483
|
+
}
|
|
484
|
+
// Cada ponto da leitura crítica como cartão: o slide a que ele se liga (clique leva até lá), Aplicar (pede à IA só
|
|
485
|
+
// aquele ponto, mirando aquele slide; a autoria decide se vai ao slide ou às notes) e Ignorar (gravado: o ponto deixa
|
|
486
|
+
// de acompanhar os pedidos).
|
|
487
|
+
const CRIT_LABEL = { destaque: "Achado", inconsistencia: "Inconsistência", slide: "Slide a rever", pergunta: "Pergunta provável", limitacao: "Limitação", forte: "Ponto forte" };
|
|
488
|
+
function goToSlide(n) {
|
|
489
|
+
if (!(n >= 1 && n <= state.deck.slides.length)) return;
|
|
490
|
+
state.currentSlideIndex = n - 1;
|
|
491
|
+
renderThumbnails(); renderCurrentSlide();
|
|
492
|
+
}
|
|
493
|
+
async function markCritique(id, status) {
|
|
494
|
+
try { await fetch("api/ai/critique/item", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, status }) }); } catch { /* fica só na tela */ }
|
|
495
|
+
}
|
|
496
|
+
function renderCritiqueItems(msg, critique) {
|
|
497
|
+
const list = document.createElement("div");
|
|
498
|
+
list.className = "crit-list";
|
|
499
|
+
for (const it of critique.itens) {
|
|
500
|
+
const card = document.createElement("div");
|
|
501
|
+
card.className = `crit-item crit-${it.tipo}`;
|
|
502
|
+
card.dataset.id = it.id;
|
|
503
|
+
card.innerHTML = `<div class="crit-head"><span class="crit-kind"></span>${it.slide ? `<button type="button" class="crit-slide" title="Ir ao slide ${it.slide}">Slide ${it.slide}</button>` : ""}</div><b class="crit-title"></b><div class="crit-text"></div>${it.trecho ? '<blockquote class="crit-quote"></blockquote>' : ""}<div class="crit-actions"><button type="button" class="bs-option crit-apply"><i class="ic" data-ic="check"></i> Aplicar${it.slide ? ` no slide ${it.slide}` : ""}</button><button type="button" class="bs-option crit-ignore"><i class="ic" data-ic="x"></i> Ignorar</button></div>`;
|
|
504
|
+
card.querySelector(".crit-kind").textContent = CRIT_LABEL[it.tipo] || it.tipo;
|
|
505
|
+
card.querySelector(".crit-title").textContent = it.titulo;
|
|
506
|
+
card.querySelector(".crit-text").textContent = `${it.texto}${it.onde ? ` (${it.onde})` : ""}`;
|
|
507
|
+
if (it.trecho) card.querySelector(".crit-quote").textContent = `"${it.trecho}"`;
|
|
508
|
+
const done = (label) => { card.classList.add("crit-done"); card.querySelector(".crit-actions").innerHTML = `<span class="crit-status">${label}</span>`; };
|
|
509
|
+
card.querySelector(".crit-slide")?.addEventListener("click", () => goToSlide(it.slide));
|
|
510
|
+
card.querySelector(".crit-apply").onclick = () => {
|
|
511
|
+
if (chatJob) { showToast("Espere a IA terminar o pedido atual.", 3000); return; }
|
|
512
|
+
goToSlide(it.slide);
|
|
513
|
+
done(it.slide ? `Aplicado no slide ${it.slide}` : "Aplicado");
|
|
514
|
+
markCritique(it.id, "aplicado");
|
|
515
|
+
dom.chatInput.value = `Aplique este ponto da leitura crítica${it.slide ? ` no slide ${it.slide}` : ", no slide em que ele se encaixa"}: ${it.titulo}. ${it.texto}${it.trecho ? ` (trecho do material: "${it.trecho}")` : ""}. Siga a autoria da apresentação: achado do material pode ir para o slide; crítica, inconsistência e pergunta vão para as notes ("Prepare-se: …"), salvo se a apresentação for livre.`;
|
|
516
|
+
handleChatSubmit();
|
|
517
|
+
};
|
|
518
|
+
card.querySelector(".crit-ignore").onclick = () => { done("Ignorado"); markCritique(it.id, "ignorado"); };
|
|
519
|
+
list.append(card);
|
|
520
|
+
}
|
|
521
|
+
hydrateIcons(list);
|
|
522
|
+
msg.querySelector(".ai-content").append(list);
|
|
523
|
+
}
|
|
454
524
|
// Bolha de progresso do chat e de outros fluxos (transformação usa a mesma).
|
|
455
525
|
const createProgressBubble = window.SagaChatProgress({ dom, appendChatMessage, hydrateIcons, getChatJob });
|
|
456
|
-
const api = { getChatJob, handleChatSubmit, loadChatHistory, saveChatHistory, appendChatMessage,
|
|
526
|
+
const api = { getChatJob, handleChatSubmit, runCritique, loadChatHistory, saveChatHistory, appendChatMessage,
|
|
457
527
|
createProgressBubble, commandEvent, updateBrainstormApply, renderVariants, applyBrainstorm,
|
|
458
528
|
renderChatOptions, clearChatAttachments, renderChatAttachments, addChatImage, addChatDoc,
|
|
459
529
|
addChatFile, autoGrowChat, commandCard };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Cabeçalho e rodapé do deck: modelos prontos, campos com variáveis ({autor}, {data:…}, {n}) e prévia do slide atual.
|
|
2
|
+
// Saiu do app.js pela trava de tamanho dos monolitos; o comportamento é o mesmo.
|
|
3
|
+
// Fábrica no molde de slide-select.js: recebe o estado e os ajudantes, devolve a API.
|
|
4
|
+
window.SagaHeaderFooter = function ({ state, fitRendered, syncDeckToServer, renderCurrentSlide, renderThumbnails, showToast }) {
|
|
5
|
+
const HF_PRESETS = [
|
|
6
|
+
{ id: "padrao", name: "Padrão", desc: "Título e número", footer: null, header: null },
|
|
7
|
+
{ id: "autor", name: "Autor e evento", desc: "Autor · evento e 3 / 20", footer: { left: "{autor} · {evento}", right: "{n} / {total}" }, header: null },
|
|
8
|
+
{ id: "evento", name: "Evento e data", desc: "Evento, data e número", footer: { left: "{evento}", center: "{data:DD MMM AAAA}", right: "{pagina}" }, header: null },
|
|
9
|
+
{ id: "corp", name: "Corporativo", desc: "Área e Confidencial em cima; título, data e página embaixo",
|
|
10
|
+
header: { left: "{depto}", right: "Confidencial" }, footer: { left: "{titulo}", center: "{data:MM/AAAA}", right: "{n} / {total}" } },
|
|
11
|
+
{ id: "numero", name: "Só o número", desc: "Número discreto no canto", footer: { right: "{n}" }, header: null },
|
|
12
|
+
{ id: "nenhum", name: "Nenhum", desc: "Slides limpos", footer: false, header: null },
|
|
13
|
+
];
|
|
14
|
+
const HF_TOKENS = [["{titulo}", "Título"], ["{autor}", "Autor"], ["{evento}", "Evento"], ["{depto}", "Departamento"], ["{data:DD/MM/AAAA}", "Data"],
|
|
15
|
+
["{data:DD MMM AAAA}", "Data por extenso"], ["{pagina}", "Página (01)"], ["{n}", "Página (1)"], ["{total}", "Total"]];
|
|
16
|
+
const HF_SLOTS = ["header.left", "header.center", "header.right", "footer.left", "footer.center", "footer.right"];
|
|
17
|
+
const hfModal = () => document.getElementById("modal-hf");
|
|
18
|
+
let hfLastInput = null;
|
|
19
|
+
let hfTimer = null;
|
|
20
|
+
|
|
21
|
+
// footer/header do deck -> valores dos 6 campos (o padrão sem footer = título + número)
|
|
22
|
+
function hfSlotsFrom(v, kind, deck) {
|
|
23
|
+
if (v === false) return { left: "", center: "", right: "" };
|
|
24
|
+
if (v == null) return kind === "footer" ? { left: "{titulo}", center: "", right: "{pagina}" } : { left: "", center: "", right: "" };
|
|
25
|
+
if (typeof v === "string") return { left: v, center: "", right: "{pagina}" };
|
|
26
|
+
return { left: v.left || "", center: v.center || "", right: v.right || "" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hfFill(deckLike) {
|
|
30
|
+
const m = hfModal();
|
|
31
|
+
for (const k of ["author", "event", "department", "date"]) m.querySelector(`[data-deck="${k}"]`).value = deckLike[k] || "";
|
|
32
|
+
for (const kind of ["header", "footer"]) {
|
|
33
|
+
const v = hfSlotsFrom(deckLike[kind], kind, deckLike);
|
|
34
|
+
for (const pos of ["left", "center", "right"]) m.querySelector(`[data-slot="${kind}.${pos}"]`).value = v[pos];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// o que os campos do modal significam como deck (footer/header "limpos": null = padrão, false = nenhum)
|
|
39
|
+
function hfRead() {
|
|
40
|
+
const m = hfModal();
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const k of ["author", "event", "department", "date"]) out[k] = m.querySelector(`[data-deck="${k}"]`).value.trim();
|
|
43
|
+
for (const kind of ["header", "footer"]) {
|
|
44
|
+
const v = Object.fromEntries(["left", "center", "right"].map((p) => [p, m.querySelector(`[data-slot="${kind}.${p}"]`).value.trim()]));
|
|
45
|
+
const empty = !v.left && !v.center && !v.right;
|
|
46
|
+
if (kind === "footer" && v.left === "{titulo}" && !v.center && v.right === "{pagina}") out.footer = null;
|
|
47
|
+
else if (empty) out[kind] = kind === "footer" ? false : null;
|
|
48
|
+
else out[kind] = Object.fromEntries(Object.entries(v).filter(([, x]) => x));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hfApplyTo(deck, vals) {
|
|
54
|
+
for (const [k, v] of Object.entries(vals)) {
|
|
55
|
+
if (v == null || v === "") delete deck[k];
|
|
56
|
+
else deck[k] = v;
|
|
57
|
+
}
|
|
58
|
+
return deck;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function openHeaderFooter() {
|
|
62
|
+
const m = hfModal();
|
|
63
|
+
const presets = m.querySelector(".hf-presets");
|
|
64
|
+
if (!presets.childElementCount) {
|
|
65
|
+
for (const p of HF_PRESETS) {
|
|
66
|
+
const b = document.createElement("button");
|
|
67
|
+
b.type = "button";
|
|
68
|
+
b.className = "hf-preset";
|
|
69
|
+
b.dataset.preset = p.id;
|
|
70
|
+
b.innerHTML = `<b></b><span></span>`;
|
|
71
|
+
b.querySelector("b").textContent = p.name;
|
|
72
|
+
b.querySelector("span").textContent = p.desc;
|
|
73
|
+
b.onclick = () => {
|
|
74
|
+
const cur = hfRead();
|
|
75
|
+
hfFill({ ...cur, footer: p.footer, header: p.header });
|
|
76
|
+
presets.querySelectorAll(".hf-preset").forEach((x) => x.classList.toggle("active", x === b));
|
|
77
|
+
hfPreview();
|
|
78
|
+
};
|
|
79
|
+
presets.append(b);
|
|
80
|
+
}
|
|
81
|
+
const chips = m.querySelector(".hf-tokens");
|
|
82
|
+
for (const [tok, label] of HF_TOKENS) {
|
|
83
|
+
const c = document.createElement("button");
|
|
84
|
+
c.type = "button";
|
|
85
|
+
c.className = "hf-token";
|
|
86
|
+
c.dataset.token = tok;
|
|
87
|
+
c.textContent = label;
|
|
88
|
+
c.title = tok;
|
|
89
|
+
c.onmousedown = (e) => e.preventDefault(); // não rouba o foco do campo
|
|
90
|
+
c.onclick = () => {
|
|
91
|
+
const inp = hfLastInput || m.querySelector('[data-slot="footer.left"]');
|
|
92
|
+
const a = inp.selectionStart ?? inp.value.length, z = inp.selectionEnd ?? a;
|
|
93
|
+
inp.value = inp.value.slice(0, a) + tok + inp.value.slice(z);
|
|
94
|
+
inp.focus();
|
|
95
|
+
inp.setSelectionRange(a + tok.length, a + tok.length);
|
|
96
|
+
hfPreview();
|
|
97
|
+
};
|
|
98
|
+
chips.append(c);
|
|
99
|
+
}
|
|
100
|
+
m.querySelectorAll("input").forEach((inp) => {
|
|
101
|
+
inp.addEventListener("focus", () => { if (inp.dataset.slot) hfLastInput = inp; });
|
|
102
|
+
inp.addEventListener("input", () => { clearTimeout(hfTimer); hfTimer = setTimeout(hfPreview, 250); });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
hfFill(state.deck);
|
|
106
|
+
m.classList.remove("hidden");
|
|
107
|
+
hfPreview();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function closeHeaderFooter() {
|
|
111
|
+
hfModal().classList.add("hidden");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function hfPreview() {
|
|
115
|
+
const stage = hfModal().querySelector(".hf-stage");
|
|
116
|
+
const spec = hfApplyTo(JSON.parse(JSON.stringify(state.deck)), hfRead());
|
|
117
|
+
// na prévia, um slide que mostra rodapé (a capa não mostra)
|
|
118
|
+
let i = state.currentSlideIndex;
|
|
119
|
+
const NO_BARS = ["cover", "section", "end", "image", "canvas", "full", "headline"];
|
|
120
|
+
if (NO_BARS.includes(spec.slides[i]?.layout)) i = Math.max(0, spec.slides.findIndex((s) => !NO_BARS.includes(s.layout)));
|
|
121
|
+
try {
|
|
122
|
+
const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
|
|
123
|
+
body: JSON.stringify({ slide: spec.slides[i], index: i, spec }) })).json();
|
|
124
|
+
stage.innerHTML = `<div class="thumb-render">${r.html}</div>`;
|
|
125
|
+
requestAnimationFrame(() => fitRendered(stage));
|
|
126
|
+
stage.style.setProperty("--thumb-scale", String(stage.clientWidth / 1920));
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applyHeaderFooter() {
|
|
131
|
+
hfApplyTo(state.deck, hfRead());
|
|
132
|
+
closeHeaderFooter();
|
|
133
|
+
syncDeckToServer();
|
|
134
|
+
renderCurrentSlide();
|
|
135
|
+
renderThumbnails();
|
|
136
|
+
showToast("Cabeçalho e rodapé aplicados em todos os slides", 2200);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { openHeaderFooter, closeHeaderFooter, applyHeaderFooter };
|
|
140
|
+
};
|
|
@@ -500,6 +500,7 @@
|
|
|
500
500
|
<div id="chat-attachments" class="chat-attachments" hidden></div>
|
|
501
501
|
<div class="chat-compose">
|
|
502
502
|
<textarea id="chat-input" class="chat-field" rows="1" placeholder="Pergunte, peça uma mudança, anexe um arquivo ou cole um link…" autocomplete="off"></textarea>
|
|
503
|
+
<button type="button" id="chat-critique" class="icon-btn" title="Leitura crítica do material anexado: inconsistências, achados escondidos, perguntas prováveis da banca (nada muda nos slides)"><i class="ic" data-ic="scan-search"></i></button>
|
|
503
504
|
<button type="button" id="chat-attach" class="icon-btn" title="Anexar imagem ou documento (pdf, docx, xlsx, pptx, txt)"><i class="ic" data-ic="image-plus"></i></button>
|
|
504
505
|
<button type="submit" id="chat-send" class="icon-btn icon-btn-primary" title="Enviar (Enter)"><i class="ic" data-ic="send"></i></button>
|
|
505
506
|
</div>
|
|
@@ -840,6 +841,8 @@
|
|
|
840
841
|
|
|
841
842
|
<script src="fit.js"></script>
|
|
842
843
|
<script src="ui-icons.js"></script>
|
|
844
|
+
<script src="update-notice.js"></script>
|
|
845
|
+
<script src="ai-settings.js"></script>
|
|
843
846
|
<script src="calc-fields.js"></script>
|
|
844
847
|
<script src="art-preview.js"></script>
|
|
845
848
|
<script src="api-collections.js"></script>
|
|
@@ -867,6 +870,9 @@
|
|
|
867
870
|
<script src="workspace.js"></script>
|
|
868
871
|
<script src="studio-awareness.js"></script>
|
|
869
872
|
<script src="chat.js"></script>
|
|
873
|
+
<script src="provenance.js"></script>
|
|
874
|
+
<script src="header-footer.js"></script>
|
|
875
|
+
<script src="preferences-ui.js"></script>
|
|
870
876
|
<script src="app.js"></script>
|
|
871
877
|
</body>
|
|
872
878
|
</html>
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
<div class="lib-body">
|
|
32
32
|
<aside class="lib-side">
|
|
33
33
|
<nav id="side" aria-label="Biblioteca"></nav>
|
|
34
|
-
<
|
|
34
|
+
<button type="button" class="lib-btn ghost ai-settings" id="btn-ai" hidden title="Provedores, chaves e modelos de IA"><i class="ic" data-ic="sliders-horizontal"></i><span class="lbl">Configurar IA</span></button>
|
|
35
35
|
</aside>
|
|
36
36
|
<main class="lib-main" id="main" tabindex="-1" aria-live="polite"><div class="initial-loading"><span class="loading-orbit"></span>Preparando seu estúdio…</div></main>
|
|
37
37
|
</div>
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
<div class="ldlg-back" id="dlg"><div class="ldlg" role="dialog" aria-modal="true" aria-labelledby="dialog-heading"></div></div>
|
|
51
51
|
<div class="ltoast" id="toast" role="status"></div>
|
|
52
52
|
<script src="ui-icons.js"></script>
|
|
53
|
+
<script src="update-notice.js"></script>
|
|
54
|
+
<script src="ai-settings.js"></script>
|
|
53
55
|
<script src="library.js"></script>
|
|
54
56
|
</body>
|
|
55
57
|
</html>
|
|
@@ -58,8 +58,9 @@
|
|
|
58
58
|
if (view !== "recentes" && view !== "todas" && view !== "lixeira" && !topicOf(view)) view = "recentes";
|
|
59
59
|
render();
|
|
60
60
|
}
|
|
61
|
-
// botão IA:
|
|
62
|
-
api("api/
|
|
61
|
+
// botão IA: provedor, chave e modelos desta máquina (ai-settings.js); no multiusuário quem configura é quem administra
|
|
62
|
+
api("api/ia").then(({ editable }) => { $("#btn-ai").hidden = !editable; }).catch(() => {});
|
|
63
|
+
$("#btn-ai").onclick = () => window.SagaAISettings.open();
|
|
63
64
|
const openEditor = (id, present) => { location.href = `editor?deck=${encodeURIComponent(id)}${present ? "&present=1" : ""}`; };
|
|
64
65
|
|
|
65
66
|
// ---------------------------------------------------------------- barra lateral
|
|
@@ -111,7 +112,7 @@
|
|
|
111
112
|
const match = (d) => !q || d.title.toLowerCase().includes(q);
|
|
112
113
|
const main = $("#main");
|
|
113
114
|
if (view === "lixeira") {
|
|
114
|
-
main.innerHTML = `<div class="head"><div><h1>Lixeira</h1><div class="sub">Fica aqui por 30 dias; depois é apagado de vez.</div></div>${data.trash.length ? `<button class="lib-btn ghost danger" data-empty-trash>${ic("trash-2")}Esvaziar lixeira</button>` : ""}</div>` +
|
|
115
|
+
main.innerHTML = `<div class="head"><div><h1>Lixeira</h1><div class="sub">Fica aqui por ${data.trashDays === 1 ? "um dia" : `${data.trashDays || 30} dias`}; depois é apagado de vez (o prazo se muda em Preferências).</div></div>${data.trash.length ? `<button class="lib-btn ghost danger" data-empty-trash>${ic("trash-2")}Esvaziar lixeira</button>` : ""}</div>` +
|
|
115
116
|
(data.trash.length ? data.trash.map((t) => `<div class="trash-row"><div class="t"><b>${esc(t.title)}</b><div class="sub">Era de ${esc(t.topic || NO_TOPIC.name)} · ${t.slides || 0} slides · excluída ${ago(t.deleted)}</div></div>
|
|
116
117
|
<button class="lib-btn ghost" data-restore="${esc(t.id)}">${ic("rotate-ccw")}Restaurar</button><button class="lib-btn ghost danger" data-purge="${esc(t.id)}">Excluir de vez</button></div>`).join("")
|
|
117
118
|
: `<div class="empty"><div class="big">${ic("trash-2")}</div><h2>Lixeira vazia</h2></div>`);
|
|
@@ -211,7 +212,7 @@
|
|
|
211
212
|
if (a === "open" || a === "present") return openEditor(id, a === "present");
|
|
212
213
|
if (a === "rename") return nameDialog("Renomear apresentação", d.title, (title) => act(() => api("api/library/decks/rename", { id, title }), "Renomeada"));
|
|
213
214
|
if (a === "dup") return act(() => api("api/library/decks/duplicate", { id }), "Cópia criada");
|
|
214
|
-
if (a === "trash") return act(() => api("api/library/decks/trash", { id }),
|
|
215
|
+
if (a === "trash") return act(() => api("api/library/decks/trash", { id }), `Na lixeira — dá para restaurar por ${data.trashDays === 1 ? "um dia" : `${data.trashDays || 30} dias`}`);
|
|
215
216
|
});
|
|
216
217
|
m.querySelectorAll("[data-move]").forEach((b) => b.onclick = () => { closeMenus(); act(() => api("api/library/decks/move", { id, topic: b.dataset.move }), `Movida para ${topicOf(b.dataset.move).name}`); });
|
|
217
218
|
m.querySelectorAll("[data-dl]").forEach((b) => b.onclick = () => {
|
|
@@ -435,7 +436,8 @@
|
|
|
435
436
|
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
436
437
|
if (!line.trim()) continue;
|
|
437
438
|
const ev = JSON.parse(line);
|
|
438
|
-
|
|
439
|
+
// a etapa (ou as etapas que correm juntas) e o tempo: a geração com documento leva minutos
|
|
440
|
+
if (ev.type === "progress" && ev.text) status.textContent = `${ev.text}${ev.elapsed ? ` · ${Math.round(ev.elapsed / 1000)}s` : ""}`;
|
|
439
441
|
if (ev.type === "result") result = ev.data;
|
|
440
442
|
if (ev.type === "error") throw new Error(ev.error || "falhou");
|
|
441
443
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Preferências (tela única, com busca): as desta máquina ficam em ~/.sagadeck/preferencias.json (servidor); as do
|
|
2
|
+
// editor, neste navegador. Cada linha diz o que faz; gravar é automático.
|
|
3
|
+
// Saiu do app.js pela trava de tamanho dos monolitos; o comportamento é o mesmo.
|
|
4
|
+
window.SagaPrefs = function ({ dom, store, escHtml, escAttr, fold, setAppTheme, renderCurrentSlide, renderThumbnails }) {
|
|
5
|
+
const PREFS_UI = [
|
|
6
|
+
{ id: "texto", title: "Texto e código", items: [
|
|
7
|
+
{ k: "texto.minCodePt", type: "num", unit: "pt", min: 6, max: 24, label: "Tamanho mínimo do código ao encolher", hint: "Código que não cabe encolhe até aqui (10 pt ≈ 20 px no slide). Se nem assim couber, ele rola na apresentação e o fiscal avisa." },
|
|
8
|
+
{ k: "texto.minTextPt", type: "num", unit: "pt", min: 6, max: 30, label: "Tamanho mínimo do texto ao encolher", hint: "Títulos e textos que se ajustam para caber não ficam menores que isto." },
|
|
9
|
+
{ k: "texto.wrapCode", type: "bool", label: "Quebrar linhas longas de código", hint: "Em vez de encolher o bloco todo por causa de uma linha comprida." },
|
|
10
|
+
] },
|
|
11
|
+
{ id: "ia", title: "Inteligência artificial", items: [
|
|
12
|
+
{ k: "ia.pesquisa", type: "bool", label: "Pesquisar na web quando precisar", hint: "Ranking, lançamentos, preços, um artigo científico: a IA pesquisa, lê as fontes confiáveis e cita cada uma. Desligado (ou sem internet), ela usa só o que sabe e avisa." },
|
|
13
|
+
{ k: "ia.perguntar", type: "bool", label: "Perguntar quando o pedido não disser para que serve o material", hint: "Ex.: um workshop sem dizer se o pessoal vai guardar o material. Desligado, a IA decide sozinha." },
|
|
14
|
+
{ k: "ia.imagens", type: "bool", label: "Gerar imagens quando o pedido pedir", hint: "Desligado: só ícones, gráficos e diagramas do sagadeck (sem custo de imagem)." },
|
|
15
|
+
{ k: "ia.autor", type: "text", label: "Seu nome", hint: "Vai na capa e no rodapé das apresentações novas (no lugar de \"Seu Nome\")." },
|
|
16
|
+
{ k: "ia.idioma", type: "select", options: [["auto", "O do pedido"], ["português do Brasil", "Português (Brasil)"], ["inglês", "Inglês"], ["espanhol", "Espanhol"]], label: "Idioma do conteúdo gerado" },
|
|
17
|
+
] },
|
|
18
|
+
{ id: "biblioteca", title: "Biblioteca", items: [
|
|
19
|
+
{ k: "biblioteca.lixeiraDias", type: "select", options: [["1", "1 dia"], ["7", "7 dias"], ["30", "30 dias"], ["90", "90 dias"]], label: "Quanto tempo a lixeira guarda", hint: "Apresentação excluída fica restaurável por esse tempo; depois é apagada de vez." },
|
|
20
|
+
] },
|
|
21
|
+
{ id: "exportacao", title: "Exportação", items: [
|
|
22
|
+
{ k: "exportacao.pdfClaro", type: "bool", label: "PDF na versão clara do tema", hint: "Tema escuro com par claro (ex.: Manual noite) sai claro no PDF, melhor para imprimir." },
|
|
23
|
+
] },
|
|
24
|
+
{ id: "editor", title: "Editor (este navegador)", local: true, items: [
|
|
25
|
+
{ k: "editor.theme", type: "select", options: [["system", "Automática (do sistema)"], ["light", "Clara"], ["dark", "Escura"]], label: "Tema da interface", hint: "Só a interface do Studio; o slide mantém o tema dele.",
|
|
26
|
+
get: () => store.get("appTheme", "system"), set: (v) => setAppTheme(v) },
|
|
27
|
+
{ k: "editor.guides", type: "bool", label: "Mostrar as guias da área segura", hint: "O retângulo pontilhado onde o conteúdo cabe sem cortar.",
|
|
28
|
+
get: () => dom.chkGuides.checked, set: (v) => { dom.chkGuides.checked = v; dom.chkGuides.dispatchEvent(new Event("change", { bubbles: true })); } },
|
|
29
|
+
{ k: "editor.inspect", type: "bool", label: "Marcar no slide os avisos do fiscal", hint: "Caixas em volta do texto fora da margem, código que não coube, sobreposição.",
|
|
30
|
+
get: () => dom.chkInspectOverlay.checked, set: (v) => { dom.chkInspectOverlay.checked = v; dom.chkInspectOverlay.dispatchEvent(new Event("change", { bubbles: true })); } },
|
|
31
|
+
] },
|
|
32
|
+
];
|
|
33
|
+
let prefsData = null, prefsTimer = null;
|
|
34
|
+
const prefGet = (key) => { const [sec, k] = key.split("."); return prefsData?.[sec]?.[k]; };
|
|
35
|
+
async function openPrefs() {
|
|
36
|
+
const modal = document.getElementById("modal-prefs");
|
|
37
|
+
modal.classList.remove("hidden");
|
|
38
|
+
try {
|
|
39
|
+
const r = await fetch("api/preferences"); const j = await r.json();
|
|
40
|
+
prefsData = j.prefs; document.getElementById("prefs-file").textContent = j.file || "";
|
|
41
|
+
} catch { prefsData = {}; }
|
|
42
|
+
renderPrefs();
|
|
43
|
+
const q = document.getElementById("prefs-search"); q.value = ""; q.focus();
|
|
44
|
+
}
|
|
45
|
+
function renderPrefs() {
|
|
46
|
+
const nav = document.getElementById("prefs-nav"), list = document.getElementById("prefs-list");
|
|
47
|
+
nav.innerHTML = PREFS_UI.map((sec) => `<button type="button" data-prefs-sec="${sec.id}">${escHtml(sec.title)}</button>`).join("");
|
|
48
|
+
list.innerHTML = PREFS_UI.map((sec) => `<section class="prefs-sec" data-prefs-sec="${sec.id}"><h4>${escHtml(sec.title)}</h4>${sec.items.map((it) => {
|
|
49
|
+
const v = it.get ? it.get() : prefGet(it.k);
|
|
50
|
+
const ctl = it.type === "bool" ? `<input type="checkbox" data-pref="${it.k}"${v ? " checked" : ""} aria-label="${escAttr(it.label)}">`
|
|
51
|
+
: it.type === "select" ? `<select class="form-control" data-pref="${it.k}" aria-label="${escAttr(it.label)}">${it.options.map(([ov, ol]) => `<option value="${ov}"${String(v) === ov ? " selected" : ""}>${escHtml(ol)}</option>`).join("")}</select>`
|
|
52
|
+
: it.type === "num" ? `<span class="pref-num"><input type="number" class="form-control" data-pref="${it.k}" value="${escAttr(v ?? "")}" min="${it.min}" max="${it.max}" step="1" aria-label="${escAttr(it.label)}"><span>${it.unit || ""}</span></span>`
|
|
53
|
+
: `<input type="text" class="form-control" data-pref="${it.k}" value="${escAttr(v ?? "")}" aria-label="${escAttr(it.label)}">`;
|
|
54
|
+
return `<div class="pref-row" data-search="${escAttr(fold(`${sec.title} ${it.label} ${it.hint || ""}`))}"><div class="pref-label"><b>${escHtml(it.label)}</b>${it.hint ? `<small>${escHtml(it.hint)}</small>` : ""}</div><div class="pref-ctl">${ctl}</div></div>`;
|
|
55
|
+
}).join("")}</section>`).join("");
|
|
56
|
+
nav.querySelectorAll("[data-prefs-sec]").forEach((b) => b.onclick = () => list.querySelector(`section[data-prefs-sec="${b.dataset.prefsSec}"]`)?.scrollIntoView({ block: "start" }));
|
|
57
|
+
list.querySelectorAll("[data-pref]").forEach((el) => el.addEventListener("change", () => changePref(el)));
|
|
58
|
+
}
|
|
59
|
+
function changePref(el) {
|
|
60
|
+
const key = el.dataset.pref, it = PREFS_UI.flatMap((s) => s.items).find((x) => x.k === key);
|
|
61
|
+
const value = el.type === "checkbox" ? el.checked : it.type === "num" ? Number(el.value) : el.value;
|
|
62
|
+
const status = document.getElementById("prefs-status");
|
|
63
|
+
if (it.set) { it.set(value); status.textContent = "Salvo neste navegador"; return; }
|
|
64
|
+
const [sec, k] = key.split(".");
|
|
65
|
+
(prefsData[sec] ||= {})[k] = value;
|
|
66
|
+
clearTimeout(prefsTimer);
|
|
67
|
+
status.textContent = "Salvando…";
|
|
68
|
+
prefsTimer = setTimeout(async () => {
|
|
69
|
+
try {
|
|
70
|
+
const r = await fetch("api/preferences", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prefs: { [sec]: prefsData[sec] } }) });
|
|
71
|
+
const j = await r.json(); if (!r.ok) throw new Error(j.error || r.status);
|
|
72
|
+
prefsData = j.prefs;
|
|
73
|
+
const shown = document.querySelector(`#prefs-list [data-pref="${key}"]`); if (shown && shown.type === "number") shown.value = prefGet(key); // voltou para a faixa aceita
|
|
74
|
+
status.textContent = "Salvo";
|
|
75
|
+
renderCurrentSlide(); renderThumbnails(); // os mínimos do ajuste para caber mudam o desenho
|
|
76
|
+
} catch (e) { status.textContent = "Não salvou: " + e.message; }
|
|
77
|
+
}, 250);
|
|
78
|
+
}
|
|
79
|
+
function filterPrefs() {
|
|
80
|
+
const q = fold(document.getElementById("prefs-search").value.trim());
|
|
81
|
+
document.querySelectorAll("#prefs-list .pref-row").forEach((r) => { r.hidden = !!q && !r.dataset.search.includes(q); });
|
|
82
|
+
document.querySelectorAll("#prefs-list .prefs-sec").forEach((sec) => { sec.hidden = !sec.querySelector(".pref-row:not([hidden])"); });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { openPrefs, filterPrefs };
|
|
86
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Selo de origem do conteúdo na miniatura (deck feito de um material anexado). "material" é o normal e não leva selo;
|
|
2
|
+
// o que o sagadeck incluiu (provenance: proprio) ou calculou do material (derivado) aparece, para a pessoa saber o
|
|
3
|
+
// que não é do paper. Saiu do app.js pela trava de tamanho dos monolitos.
|
|
4
|
+
window.SagaProvenance = function (slide, hydrateIcons) {
|
|
5
|
+
if (slide?.provenance !== "proprio" && slide?.provenance !== "derivado") return null;
|
|
6
|
+
const own = slide.provenance === "proprio";
|
|
7
|
+
const pv = document.createElement("span");
|
|
8
|
+
pv.className = `thumb-prov prov-${slide.provenance}`;
|
|
9
|
+
pv.innerHTML = `<i class="ic" data-ic="${own ? "sparkles" : "chart-column"}"></i><span>${own ? "sagadeck" : "derivado"}</span>`;
|
|
10
|
+
hydrateIcons(pv);
|
|
11
|
+
pv.title = `${own ? "Incluído pelo sagadeck: não está no material" : "Feito pelo sagadeck a partir dos números do material"}${slide.provenanceNote ? ` — ${slide.provenanceNote}` : ""}`;
|
|
12
|
+
return pv;
|
|
13
|
+
};
|
|
@@ -812,6 +812,18 @@ textarea.form-control.napkin-input { min-height: 220px; resize: vertical; font-f
|
|
|
812
812
|
.bs-option:disabled { opacity: .5; }
|
|
813
813
|
.ai-msg.bs .ai-avatar { background: #8b5cf6; }
|
|
814
814
|
.bs-tag { font-size: 11px; color: var(--text-3); margin-bottom: 4px; }
|
|
815
|
+
/* leitura crítica: um cartão por ponto, ligado ao slide, com Aplicar e Ignorar */
|
|
816
|
+
.crit-list { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
|
|
817
|
+
.crit-item { border: 1px solid var(--border, rgba(127,127,127,.3)); border-radius: 10px; padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
818
|
+
.crit-item.crit-done { opacity: .6; }
|
|
819
|
+
.crit-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
|
820
|
+
.crit-kind { font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-3); }
|
|
821
|
+
.crit-slide { font-size: 11px; border: 0; background: var(--selected); color: var(--brand); border-radius: 999px; padding: 1px 8px; cursor: pointer; }
|
|
822
|
+
.crit-text { font-size: 13px; }
|
|
823
|
+
.crit-quote { margin: 2px 0 0; padding-left: 8px; border-left: 2px solid var(--brand); font-size: 12px; color: var(--text-3); }
|
|
824
|
+
.crit-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
|
|
825
|
+
.crit-actions .ic { width: 12px; height: 12px; }
|
|
826
|
+
.crit-status { font-size: 12px; color: var(--text-3); }
|
|
815
827
|
#btn-brainstorm-apply { margin-left: auto; }
|
|
816
828
|
|
|
817
829
|
/* versões de um slide para escolher */
|
|
@@ -1227,6 +1239,11 @@ textarea.form-control.napkin-input { min-height: 220px; resize: vertical; font-f
|
|
|
1227
1239
|
.thumb-review.rv-changed { background: #bc4b09; }
|
|
1228
1240
|
.thumb-review.rv-pending { background: #8764b8; }
|
|
1229
1241
|
.thumb-review.rv-check { background: #0063b1; }
|
|
1242
|
+
/* origem do conteúdo: o que o sagadeck incluiu (sagadeck) ou calculou do material (derivado) */
|
|
1243
|
+
.thumb-prov { position: absolute; right: 8px; bottom: 10px; z-index: 2; display: inline-flex; align-items: center; gap: 3px; height: 17px; padding: 0 6px; border-radius: 9px; font-size: 10.5px; font-weight: 600; color: #fff; box-shadow: var(--shadow-1); }
|
|
1244
|
+
.thumb-prov .ic, .thumb-prov .ic svg { width: 11px; height: 11px; }
|
|
1245
|
+
.thumb-prov.prov-proprio { background: #8764b8; }
|
|
1246
|
+
.thumb-prov.prov-derivado { background: #4f6bed; }
|
|
1230
1247
|
.review-bar { position: absolute; left: 12px; right: 12px; top: 40px; z-index: 6; display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 4px 6px 4px 12px; border-radius: 8px; background: var(--chrome); border: 1px solid var(--ui-line); border-left: 4px solid #bc4b09; box-shadow: var(--shadow-2); font-size: 12px; }
|
|
1231
1248
|
.review-bar.rv-new { border-left-color: #107c10; }
|
|
1232
1249
|
.review-bar.rv-pending { border-left-color: #8764b8; }
|