sagadeck 1.2.0 → 1.3.1
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 +11 -2
- package/bin/sagadeck.js +36 -3
- package/docs/ANDAMENTO.md +37 -0
- package/docs/REFERENCIA.md +4 -3
- package/package.json +1 -1
- package/src/ai/deck-ai.js +54 -12
- package/src/ai/document-visuals.js +230 -24
- package/src/ai/quality.js +1 -1
- package/src/ai/transform.js +51 -2
- package/src/diff.js +44 -0
- package/src/elements.js +37 -2
- package/src/export/pptx.js +8 -6
- package/src/figures/charts.js +11 -3
- package/src/figures/diagrams.js +37 -9
- package/src/fiscal/autofix.js +1 -1
- package/src/fiscal/enxugar.js +22 -0
- package/src/fiscal/normalize.js +19 -2
- package/src/import/crop.js +10 -3
- package/src/layouts.js +5 -3
- package/src/library.js +74 -2
- package/src/motion.js +1 -1
- package/src/package.js +16 -1
- package/src/research/brave.js +12 -0
- package/src/research/chat-research.js +29 -0
- package/src/research/links.js +37 -0
- package/src/research/research.js +42 -2
- package/src/research/wikipedia.js +15 -0
- package/src/runtime/base.css +6 -6
- package/src/runtime/motion.js +1 -1
- package/src/runtime/runtime.js +15 -2
- package/src/studio/ai-chat-route.js +158 -0
- package/src/studio/exporting.js +2 -2
- package/src/studio/library-routes.js +158 -0
- package/src/studio/public/app.js +44 -508
- package/src/studio/public/chat.js +461 -0
- package/src/studio/public/index.html +17 -12
- package/src/studio/public/library.js +13 -2
- package/src/studio/public/slide-clipboard.js +20 -7
- package/src/studio/public/slide-select.js +73 -0
- package/src/studio/public/studio-next.css +28 -1
- package/src/studio/public/style.css +7 -1
- package/src/studio/public-files.js +1 -1
- package/src/studio/server.js +14 -285
- package/src/studio/slide-copy-routes.js +7 -4
- package/src/studio/snapshot.js +3 -0
- package/src/themes.js +32 -2
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Seleção de slides na lista (multisseleção com Ctrl/Shift) e teclas da lista.
|
|
2
|
+
// Saiu do app.js pela trava de tamanho dos monolitos; o comportamento é o mesmo.
|
|
3
|
+
window.SagaSlideSelect = function ({ state, dom, markActiveThumb, renderCurrentSlide, copySelectedSlides, pasteCopiedSlide, deleteCurrentSlide }) {
|
|
4
|
+
// slides selecionados na lista (para excluir/copiar em bloco): ordenados, válidos e nunca vazios
|
|
5
|
+
function selectedSlides() {
|
|
6
|
+
const n = state.deck?.slides.length || 0;
|
|
7
|
+
const sel = (Array.isArray(state.selectedSlides) ? state.selectedSlides : []).filter((i) => Number.isInteger(i) && i >= 0 && i < n);
|
|
8
|
+
if (!sel.length && n) sel.push(Math.min(state.currentSlideIndex, n - 1));
|
|
9
|
+
return [...new Set(sel)].sort((a, b) => a - b);
|
|
10
|
+
}
|
|
11
|
+
// click: só este; Ctrl+click: alterna; Shift+click: do atual até aqui. O atual (foco) vai junto.
|
|
12
|
+
function selectSlide(idx, { add, range } = {}) {
|
|
13
|
+
const n = state.deck.slides.length;
|
|
14
|
+
if (idx < 0 || idx >= n) return;
|
|
15
|
+
let sel = selectedSlides();
|
|
16
|
+
if (add) {
|
|
17
|
+
sel = sel.includes(idx) ? sel.filter((i) => i !== idx) : [...sel, idx];
|
|
18
|
+
if (!sel.length) sel = [idx];
|
|
19
|
+
} else if (range) {
|
|
20
|
+
const a = sel.length ? sel[sel.length - 1] : state.currentSlideIndex;
|
|
21
|
+
const [lo, hi] = a < idx ? [a, idx] : [idx, a];
|
|
22
|
+
sel = [...new Set([...sel, ...Array.from({ length: hi - lo + 1 }, (_, k) => lo + k)])];
|
|
23
|
+
} else sel = [idx];
|
|
24
|
+
if (idx !== state.currentSlideIndex) state.editorStep = "all"; // outro slide: volta a mostrar tudo
|
|
25
|
+
state.currentSlideIndex = idx;
|
|
26
|
+
state.selectedSlides = [...new Set(sel)].sort((a, b) => a - b);
|
|
27
|
+
markActiveThumb();
|
|
28
|
+
renderCurrentSlide();
|
|
29
|
+
}
|
|
30
|
+
// Delete/Backspace com o foco na lista de slides (à esquerda) exclui o slide selecionado. Só ali: digitando
|
|
31
|
+
// num campo ou mexendo num elemento do slide, as teclas continuam fazendo o que já faziam.
|
|
32
|
+
function bindSlideListKeys() {
|
|
33
|
+
document.getElementById("btn-copy-slide").onclick = () => copySelectedSlides();
|
|
34
|
+
document.getElementById("btn-paste-slide").onclick = pasteCopiedSlide;
|
|
35
|
+
if (dom.thumbnailsList._keys) return;
|
|
36
|
+
dom.thumbnailsList._keys = true;
|
|
37
|
+
dom.thumbnailsList.tabIndex = 0;
|
|
38
|
+
dom.thumbnailsList.setAttribute("aria-label", "Slides (Ctrl+click seleciona vários; Delete exclui)");
|
|
39
|
+
const scrollCard = (to) => {
|
|
40
|
+
// rola só a lista (scrollIntoView rolaria também a tela em volta)
|
|
41
|
+
const card = dom.thumbnailsList.querySelector(`.thumb-card[data-idx="${to}"]`), list = dom.thumbnailsList;
|
|
42
|
+
if (card) {
|
|
43
|
+
const c = card.getBoundingClientRect(), l = list.getBoundingClientRect();
|
|
44
|
+
if (c.top < l.top) list.scrollTop -= l.top - c.top + 8;
|
|
45
|
+
else if (c.bottom > l.bottom) list.scrollTop += c.bottom - l.bottom + 8;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
dom.thumbnailsList.addEventListener("keydown", (e) => {
|
|
49
|
+
if (e.altKey || e.target.closest("input, textarea, select, [contenteditable]")) return;
|
|
50
|
+
// Ctrl+C/V copiam e colam os selecionados (funcionam aqui na lista)
|
|
51
|
+
if ((e.ctrlKey || e.metaKey) && ["c","v"].includes(e.key.toLowerCase())) { e.preventDefault(); e.key.toLowerCase()==="c" ? copySelectedSlides() : pasteCopiedSlide(); return; }
|
|
52
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { e.preventDefault(); state.selectedSlides = state.deck.slides.map((_, i) => i); markActiveThumb(); return; }
|
|
53
|
+
const go = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -1, ArrowRight: 1 }[e.key];
|
|
54
|
+
// ↑/↓ (e Home/End) trocam de slide, como no PowerPoint; nunca rolam a visualização.
|
|
55
|
+
// Com Ctrl ou Shift segurados, estendem a seleção em vez de trocar.
|
|
56
|
+
if (go || e.key === "Home" || e.key === "End") {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
const last = state.deck.slides.length - 1;
|
|
59
|
+
const to = e.key === "Home" ? 0 : e.key === "End" ? last : Math.max(0, Math.min(last, state.currentSlideIndex + go));
|
|
60
|
+
if (to !== state.currentSlideIndex) selectSlide(to, { add: e.ctrlKey || e.metaKey || e.shiftKey });
|
|
61
|
+
scrollCard(to);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
deleteCurrentSlide();
|
|
67
|
+
dom.thumbnailsList.focus({ preventScroll: true });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
return { selectedSlides, selectSlide, bindSlideListKeys };
|
|
73
|
+
};
|
|
@@ -72,6 +72,14 @@
|
|
|
72
72
|
.studio-next .rbtn-lg { height: 56px; min-width: 65px; padding: 7px 10px; gap: 5px; font-size: 11px; }
|
|
73
73
|
.studio-next .rbtn-lg > .ic { width: 21px; height: 21px; }
|
|
74
74
|
.studio-next .rgroup-fields { gap: 5px; }
|
|
75
|
+
/* aviso de identidade/direção à vista (máquina nova): o grupo continua cabendo na faixa */
|
|
76
|
+
.studio-next .rgroup-body:has(> .rnote:not([hidden])) { gap: 1px; }
|
|
77
|
+
/* Identidade: 2 fileiras + aviso cabem na faixa (controles compactos só neste grupo; */
|
|
78
|
+
/* tamanhos em px fixos para a conta não depender da fonte instalada) */
|
|
79
|
+
#identity-select, #direction-select { height: 24px; font-size: 11px; }
|
|
80
|
+
#btn-identity-setup, #btn-direction-save { height: 24px; width: 26px; }
|
|
81
|
+
/* nada se esmaga em silêncio neste grupo: o que não couber aparece na rolagem e o teste acusa */
|
|
82
|
+
.studio-next .rgroup:has(#identity-select) > .rgroup-body > * { flex-shrink: 0; }
|
|
75
83
|
.studio-next .rfield > span { font-size: 11px; }
|
|
76
84
|
.studio-next .workspace { grid-template-columns: 200px minmax(0, 1fr) auto; }
|
|
77
85
|
.studio-next .slide-rail { background: var(--chrome); }
|
|
@@ -135,6 +143,26 @@
|
|
|
135
143
|
.studio-next .doc-title { max-width: 280px; }
|
|
136
144
|
.scene-tools > span { display: none; }
|
|
137
145
|
}
|
|
146
|
+
@media (max-width: 1440px) {
|
|
147
|
+
/* notebook: 3 temas + 3 paletas à vista mesmo com o aviso de identidade (controles e vãos compactos) */
|
|
148
|
+
.studio-next .ribbon { padding: 6px 8px; }
|
|
149
|
+
.studio-next .ribbon .rgroup { padding: 0 6px; }
|
|
150
|
+
.studio-next .ribbon .rselect { min-width: 104px; }
|
|
151
|
+
.studio-next .ribbon .rfield { gap: 6px; }
|
|
152
|
+
.studio-next .ribbon .rfield > span { min-width: 0; }
|
|
153
|
+
.studio-next .ribbon .rrow { gap: 1px; }
|
|
154
|
+
.studio-next .ribbon .rbtn-icon { width: 26px; }
|
|
155
|
+
}
|
|
156
|
+
@media (max-width: 1024px) {
|
|
157
|
+
/* janela estreita/tablet em pé: a faixa continua cabendo sem rolagem (menos respiro, rótulos e vãos compactos) */
|
|
158
|
+
.studio-next .ribbon .rgroup { padding: 0 4px; }
|
|
159
|
+
.studio-next .ribbon .rselect { min-width: 96px; }
|
|
160
|
+
.studio-next .ribbon .rfield { gap: 4px; }
|
|
161
|
+
.studio-next .ribbon .rfield > span { min-width: 0; }
|
|
162
|
+
.studio-next .ribbon .rrow { gap: 1px; }
|
|
163
|
+
.studio-next .ribbon .rbtn-icon { width: 26px; }
|
|
164
|
+
.studio-next .rgroup-body:has(> .rnote:not([hidden])) { gap: 0; }
|
|
165
|
+
}
|
|
138
166
|
@media (max-width: 900px) {
|
|
139
167
|
.studio-next .titlebar { height: 54px; }
|
|
140
168
|
.studio-next .titlebar .doc-title { max-width: none; min-width: 40px; flex: 1; }
|
|
@@ -143,7 +171,6 @@
|
|
|
143
171
|
.studio-next .ribbon-tab { padding: 0 10px; }
|
|
144
172
|
.studio-next .ribbon { padding: 5px; height: 70px; }
|
|
145
173
|
.studio-next .workspace { grid-template-columns: minmax(0, 1fr); }
|
|
146
|
-
.studio-next .rgroup { padding: 0 7px; }
|
|
147
174
|
.studio-next .rgroup-body { gap: 3px; }
|
|
148
175
|
.motion-control { padding: 0 6px; gap: 2px; }
|
|
149
176
|
.motion-control .ic { display: none; }
|
|
@@ -224,7 +224,7 @@ kbd { font-family: var(--font-sans); font-size: 11px; color: var(--text-3); bord
|
|
|
224
224
|
.rbtn .caret { width: 12px; height: 12px; }
|
|
225
225
|
.rfield { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-2); }
|
|
226
226
|
.rfield > span { min-width: 44px; }
|
|
227
|
-
.rnote { font-size: 11px; color: var(--text-2); max-width: 230px; line-height: 1.
|
|
227
|
+
.rnote { font-size: 11px; color: var(--text-2); max-width: 230px; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
228
228
|
.rnote.warn { color: #b45309; }
|
|
229
229
|
.rselect, .form-control {
|
|
230
230
|
height: 28px; padding: 0 8px; border: 1px solid var(--ui-line-strong); border-radius: 4px; background: var(--chrome);
|
|
@@ -294,6 +294,9 @@ kbd { font-family: var(--font-sans); font-size: 11px; color: var(--text-3); bord
|
|
|
294
294
|
.thumb-card:hover .thumb-screen { outline-color: var(--ui-line-strong); }
|
|
295
295
|
.thumb-card.active .thumb-screen { outline-color: var(--brand); }
|
|
296
296
|
.thumb-card.active .thumb-num { color: var(--brand); font-weight: 700; }
|
|
297
|
+
/* multisseleção (Ctrl+click, Shift+click, Ctrl+setas): tracejado nos marcados sem foco */
|
|
298
|
+
.thumb-card.selected:not(.active) .thumb-screen { outline-style: dashed; outline-color: var(--brand); }
|
|
299
|
+
.thumb-card.selected:not(.active) .thumb-num { color: var(--brand); }
|
|
297
300
|
.thumb-render { position: absolute; left: 0; top: 0; width: 1920px; height: var(--sh, 1080px); transform-origin: 0 0; transform: scale(var(--thumb-scale, .09)); pointer-events: none; }
|
|
298
301
|
.thumb-render .slide { opacity: 1 !important; visibility: visible !important; }
|
|
299
302
|
.thumb-render mark { background-size: 100% 92% !important; transition: none !important; }
|
|
@@ -445,6 +448,9 @@ textarea.form-control { height: auto; min-height: 64px; padding: 6px 8px; resize
|
|
|
445
448
|
.modal-dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 8px 20px; }
|
|
446
449
|
.modal-dialog-header h3 { font-size: 18px; font-weight: 600; }
|
|
447
450
|
.modal-dialog-body { padding: 8px 20px 16px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
|
|
451
|
+
.modal-dialog-body details summary { cursor: pointer; font-size: 13px; color: var(--text-2); }
|
|
452
|
+
.modal-dialog-body details[open] summary { margin-bottom: 10px; }
|
|
453
|
+
.modal-dialog-body details .form-row { margin-top: 10px; }
|
|
448
454
|
.modal-dialog-body .form-control { width: 100%; }
|
|
449
455
|
.modal-dialog-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; }
|
|
450
456
|
.form-row { display: flex; align-items: flex-end; gap: 14px; flex-wrap: wrap; }
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const PUBLIC_SCRIPTS = new Set(["/workspace.js", "/chat-progress.js", "/slide-clipboard.js", "/app.js", "/ui-icons.js", "/slide-form.js", "/lab-fields.js", "/calc-fields.js", "/art-preview.js", "/library.js", "/screenshot-editor.js", "/visual-editor.js", "/inspector.js", "/explorer.js", "/viewers.js", "/merge-decks.js", "/history.js", "/review-ui.js", "/share-ui.js", "/developer-fields.js", "/api-collections.js", "/studio-awareness.js"]);
|
|
1
|
+
export const PUBLIC_SCRIPTS = new Set(["/workspace.js", "/chat-progress.js", "/slide-clipboard.js", "/slide-select.js", "/app.js", "/ui-icons.js", "/slide-form.js", "/lab-fields.js", "/calc-fields.js", "/art-preview.js", "/library.js", "/screenshot-editor.js", "/visual-editor.js", "/inspector.js", "/explorer.js", "/viewers.js", "/merge-decks.js", "/history.js", "/review-ui.js", "/share-ui.js", "/developer-fields.js", "/api-collections.js", "/studio-awareness.js", "/chat.js"]);
|
package/src/studio/server.js
CHANGED
|
@@ -45,6 +45,8 @@ import { sendExport, sendHtml, lightVariant } from "./exporting.js";
|
|
|
45
45
|
import { styleRoutes, styleAction } from "./style-routes.js";
|
|
46
46
|
import { shareRoutes } from "./share-routes.js";
|
|
47
47
|
import { apiRoutes } from "./api-routes.js";
|
|
48
|
+
import { aiChatRoutes } from "./ai-chat-route.js";
|
|
49
|
+
import { libraryRoutes } from "./library-routes.js";
|
|
48
50
|
export { lightVariant };
|
|
49
51
|
import { VERSION, buildInfo } from "./instance.js";
|
|
50
52
|
import { extractDocText, fetchUrlText, CONTEXT_STORE_CHARS, CONTEXT_MAX_DOCS, pastedUrls } from "../ai/context.js";
|
|
@@ -102,6 +104,8 @@ export function createStudioServer(deckPath = null, opts = {}) {
|
|
|
102
104
|
function newWorkspace(user) {
|
|
103
105
|
const W = { user, library: openLibrary(user ? path.join(libraryRoot, "usuarios", safeName(user, "usuario")) : libraryRoot),
|
|
104
106
|
file: null, spec: null, lastPreview: { ok: true, error: null, warnings: [] } };
|
|
107
|
+
// gerações que morreram com o processo viram órfãs: vão para a lixeira (visíveis e restauráveis), nunca somem
|
|
108
|
+
try { const n = W.library.recoverStaging().length; if (n) console.warn(`[Studio] ${n} geração(ões) interrompida(s) na biblioteca de ${user || "local"}: movida(s) para a lixeira.`); } catch {}
|
|
105
109
|
Object.assign(W, sampleSpec());
|
|
106
110
|
return W;
|
|
107
111
|
}
|
|
@@ -312,12 +316,12 @@ export function createStudioServer(deckPath = null, opts = {}) {
|
|
|
312
316
|
while (W.contextDocs.size > CONTEXT_MAX_DOCS) W.contextDocs.delete(W.contextDocs.keys().next().value);
|
|
313
317
|
return { id, name, chars: text.length, detail };
|
|
314
318
|
}
|
|
315
|
-
// Gerar com IA (editor ou biblioteca):
|
|
316
|
-
//
|
|
319
|
+
// Gerar com IA (editor ou biblioteca): a IA trabalha numa pasta oculta do tópico (invisível na biblioteca)
|
|
320
|
+
// e a apresentação só aparece quando pronta, publicada com o título final. Falhou ou a IA perguntou algo:
|
|
321
|
+
// a pasta vai para a lixeira (recuperável). Minutos, estilo, anexos.
|
|
317
322
|
async function generateIntoLibrary(W, topic, b, emit) {
|
|
318
323
|
const L = W.library;
|
|
319
|
-
const
|
|
320
|
-
const file = L.resolveId(id), dir = path.dirname(file);
|
|
324
|
+
const { dir, file } = L.createStagingDeck(topic);
|
|
321
325
|
try {
|
|
322
326
|
const prefs = loadPreferences().ia;
|
|
323
327
|
const gen = await generateDeck(String(b.briefing || ""), {
|
|
@@ -339,15 +343,15 @@ export function createStudioServer(deckPath = null, opts = {}) {
|
|
|
339
343
|
research: prefs.pesquisa === false ? false : "auto",
|
|
340
344
|
researchDir: dir,
|
|
341
345
|
});
|
|
342
|
-
if (gen.question) { L.
|
|
346
|
+
if (gen.question) { L.discardStaging(topic, dir); return { question: gen.question }; } // a IA quer saber para que serve o material
|
|
343
347
|
// estilo padrão da biblioteca (Brand Kit), se a pessoa não escolheu um tema para esta
|
|
344
348
|
if (!b.theme) gen.spec = L.withDefaultStyle(gen.spec, dir);
|
|
345
349
|
fs.writeFileSync(file, toYaml(gen.spec), "utf8");
|
|
346
350
|
saveGenReport(dir, gen);
|
|
347
|
-
const finalId = L.
|
|
351
|
+
const finalId = L.publishStagedDeck(topic, dir, gen.spec.title || "Nova apresentação");
|
|
348
352
|
return { id: finalId, file: L.resolveId(finalId), images: gen.images, research: gen.research, quality: gen.quality, variety: gen.variety };
|
|
349
353
|
} catch (e) {
|
|
350
|
-
L.
|
|
354
|
+
try { L.discardStaging(topic, dir); } catch {}
|
|
351
355
|
throw e;
|
|
352
356
|
}
|
|
353
357
|
}
|
|
@@ -1205,138 +1209,7 @@ Responda só com JSON: {"colunas": [{"nome": "…", "tipo": "tempo|categoria|num
|
|
|
1205
1209
|
return;
|
|
1206
1210
|
}
|
|
1207
1211
|
if (await slideCopyRoutes({pathname,req,res,W,readJSON,staleTab,slideClipboard,isBundledTemplate,materializePreview})) return;
|
|
1208
|
-
if (pathname
|
|
1209
|
-
const body = await readJSON(req);
|
|
1210
|
-
const job = W.aiChats?.get(body.requestId);
|
|
1211
|
-
job?.abort();
|
|
1212
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1213
|
-
res.end(JSON.stringify({ ok: true, stopped: !!job }));
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
if (pathname === "/api/ai/chat" && req.method === "POST") {
|
|
1217
|
-
const body = await readJSON(req);
|
|
1218
|
-
if (staleTab(body, W, res)) return;
|
|
1219
|
-
const owner = W;
|
|
1220
|
-
owner.aiChats ||= new Map();
|
|
1221
|
-
const requestId = body.requestId || crypto.randomUUID();
|
|
1222
|
-
const controller = new AbortController();
|
|
1223
|
-
owner.aiChats.set(requestId, controller);
|
|
1224
|
-
const signal = controller.signal;
|
|
1225
|
-
const onClose = () => { if (!res.writableEnded && !['melhorar','recriar'].some(m => transforms.has(`${taskWorkspace.file}|${m}`))) controller.abort(); };
|
|
1226
|
-
res.on('close', onClose);
|
|
1227
|
-
// Capture o arquivo da tarefa: abrir outro deck não redireciona a escrita.
|
|
1228
|
-
const taskWorkspace = { ...owner, spec: structuredClone(owner.spec) };
|
|
1229
|
-
const prompt = body.message || "";
|
|
1230
|
-
const spec = body.spec || taskWorkspace.spec;
|
|
1231
|
-
const issues = body.issues || [];
|
|
1232
|
-
// a IA vê (e testa) os slides api no ambiente atual, que pode ser o embutido "ensaio"
|
|
1233
|
-
if (!apiBlocked(req) && (spec.slides || []).some((sl) => sl && sl.layout === "api")) await ensureEnsaio();
|
|
1234
|
-
|
|
1235
|
-
try { await respond(res, body.stream, async (send) => cancellable(async () => {
|
|
1236
|
-
const emit = ev => { signal.throwIfAborted(); send(ev); };
|
|
1237
|
-
let result;
|
|
1238
|
-
const history = Array.isArray(body.history) ? body.history : [];
|
|
1239
|
-
// o cache pode ter guardado uma queda de segundos do relay: confere de novo antes de desistir
|
|
1240
|
-
if (!(await llmAvailable()) && !(await llmAvailable({ force: true }))) {
|
|
1241
|
-
// Sem modelo, ninguém decide nada: nem "o que é X?" nem "resuma" viram edição por palavra-chave
|
|
1242
|
-
// (as regras antigas trocavam o layout e enfiavam texto genérico no slide). O deck fica como está.
|
|
1243
|
-
return {
|
|
1244
|
-
reply: `A IA está desligada (nenhum LLM respondendo em ${llmConfig().url}), então não mexi em nada. Rode "modelrelay serve" ou defina SAGADECK_LLM_URL e mande de novo.`,
|
|
1245
|
-
spec, actions: [], targetSlide: body.targetSlide, talk: true, mode: "off",
|
|
1246
|
-
};
|
|
1247
|
-
}
|
|
1248
|
-
try {
|
|
1249
|
-
const target = typeof body.targetSlide === "number" ? body.targetSlide : null;
|
|
1250
|
-
const visuals = await lookAt(withBase(taskWorkspace, spec), target, prompt, emit);
|
|
1251
|
-
// anexos: imagens vão como visão; documentos (id do /api/ai/context) vão como texto
|
|
1252
|
-
const materials = await prepareDocumentMaterials(takeMaterials(taskWorkspace, (Array.isArray(body.attachments) ? body.attachments : [])
|
|
1253
|
-
.filter((a) => a && typeof a === "object" && a.type === "doc").map((a) => a.id)), path.dirname(taskWorkspace.file), {signal,onProgress:emit});
|
|
1254
|
-
for (const [i, url] of (Array.isArray(body.attachments) ? body.attachments : []).entries()) {
|
|
1255
|
-
if (typeof url === "string" && url.startsWith("data:image/")) visuals.push({ label: `imagem colada pelo usuário ${i + 1}`, dataUrl: url });
|
|
1256
|
-
}
|
|
1257
|
-
// arquivos do projeto (contexto/): material para a IA, junto com os anexos da mensagem
|
|
1258
|
-
const P = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? Project.projectOf(taskWorkspace.file) : null;
|
|
1259
|
-
if (P) for (const doc of await Project.contextMaterials(P)) if (!materials.some((m) => m.name === doc.name || m.name === doc.name.split("/").pop())) materials.push(doc);
|
|
1260
|
-
// links colados na mensagem: o servidor lê sozinho e conta nas ações
|
|
1261
|
-
const linkActions = [];
|
|
1262
|
-
for (const doc of await readPastedLinks(taskWorkspace, prompt, linkActions)) materials.push(doc);
|
|
1263
|
-
result = await editDeck({
|
|
1264
|
-
spec: withBase(taskWorkspace, spec), signal,
|
|
1265
|
-
instruction: prompt,
|
|
1266
|
-
targetSlide: target,
|
|
1267
|
-
issues,
|
|
1268
|
-
images: true, // a IA decide (regra no prompt: só quando pedirem ou aceitarem)
|
|
1269
|
-
imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)),
|
|
1270
|
-
history,
|
|
1271
|
-
onProgress: emit,
|
|
1272
|
-
visuals,
|
|
1273
|
-
materials,
|
|
1274
|
-
renderNotes: Array.isArray(body.renderNotes) ? body.renderNotes.slice(0, 8) : [],
|
|
1275
|
-
apiContext: apiContextFor(req, taskWorkspace),
|
|
1276
|
-
drawCheck: diagramCheck,
|
|
1277
|
-
runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
|
|
1278
|
-
reviewCheck: (deck, indices) => reviewExperience(deck, indices, { snapshot: slideSnapshots, onProgress: emit, signal, briefing:prompt }),
|
|
1279
|
-
styles: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? { list: taskWorkspace.library.listStyles(), current: taskWorkspace.spec?.style?.name || null } : null,
|
|
1280
|
-
});
|
|
1281
|
-
signal.throwIfAborted();
|
|
1282
|
-
if (linkActions.length) result.actions = [...linkActions, ...(result.actions || [])];
|
|
1283
|
-
// a IA decidiu transformar a apresentação inteira (transform:): o trabalho em etapas, com o andamento aqui
|
|
1284
|
-
if (result.transform) {
|
|
1285
|
-
const transformed = await runTransform(taskWorkspace, withBase(taskWorkspace, spec), result, emit, prompt, visuals.filter(v => v.label.startsWith("imagem colada pelo usuário")), signal);
|
|
1286
|
-
return owner.file === taskWorkspace.file ? transformed : { ...transformed, spec: owner.spec, talk: true, reply: transformed.reply + '\nO resultado ficou no arquivo original; você está em outra apresentação.' };
|
|
1287
|
-
}
|
|
1288
|
-
// a IA decidiu mexer no estilo (estilo:): aplicar, salvar, tirar ou padrão das novas
|
|
1289
|
-
if (result.style) return styleAction({ W: taskWorkspace, spec: withBase(taskWorkspace, spec), result, persist, isBundledTemplate });
|
|
1290
|
-
// Slides api: a IA pediu para testar (test: [n]) → o Studio executa, devolve o relatório e ela
|
|
1291
|
-
// corrige, até 3 rodadas. Quem decide testar e o que corrigir é a IA; aqui só executa.
|
|
1292
|
-
const convo = [...history, { role: "user", text: prompt }]
|
|
1293
|
-
for (let round = 1; result.test?.length && round <= 3; round++) {
|
|
1294
|
-
if (apiBlocked(req)) { result.actions.push("Testes de slides api só no Studio local."); break; }
|
|
1295
|
-
const reports = [];
|
|
1296
|
-
for (const i of result.test) {
|
|
1297
|
-
const slide = result.spec.slides[i];
|
|
1298
|
-
emit({ phase: "test", text: `Testando o slide ${i + 1} (${apiEnv.currentName() || "sem ambiente"})…` });
|
|
1299
|
-
const r = await apiEnv.runSlide(slide, { vars: taskWorkspace.apiVars || {}, deckDir: taskWorkspace.file ? path.dirname(taskWorkspace.file) : null });
|
|
1300
|
-
signal.throwIfAborted();
|
|
1301
|
-
if (r.saved) taskWorkspace.apiVars = { ...(taskWorkspace.apiVars || {}), ...r.saved };
|
|
1302
|
-
if (r.record) {
|
|
1303
|
-
const key = globalThis.SagadeckApiCore.key(slide);
|
|
1304
|
-
if (taskWorkspace.file && !isBundledTemplate(taskWorkspace.file)) writeRecording(taskWorkspace.file, key, r.record);
|
|
1305
|
-
else (taskWorkspace.apiRecordings = taskWorkspace.apiRecordings || {})[key] = { ...r.record, at: new Date().toISOString() };
|
|
1306
|
-
}
|
|
1307
|
-
reports.push({ slide: i + 1, ...r.report });
|
|
1308
|
-
result.actions.push(`Teste do slide ${i + 1}: ${r.report.ok ? "funcionou" : "falhou, " + String(r.report.erro || (r.report.status ? `HTTP ${r.report.status}` : "falhou")).slice(0, 140)}`);
|
|
1309
|
-
}
|
|
1310
|
-
convo.push({ role: "assistant", text: result.reply });
|
|
1311
|
-
const instruction = `Resultado do teste (rodada ${round} de 3), executado no ambiente ${apiEnv.currentName()}:\n\`\`\`json\n${JSON.stringify(reports, null, 2).slice(0, 12000)}\n\`\`\`\nSe algo falhou ou tem "NÃO EXISTE", corrija os slides com base na resposta real e peça test de novo. Se tudo funcionou, confirme em uma frase, sem yaml.`;
|
|
1312
|
-
emit({ phase: "test", text: reports.every((x) => x.ok) ? "Os testes passaram; conferindo…" : "Corrigindo com base no resultado…" });
|
|
1313
|
-
const next = await editDeck({
|
|
1314
|
-
spec: withBase(taskWorkspace, result.spec), signal, instruction, targetSlide: target, images: false,
|
|
1315
|
-
imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, result.spec)), history: convo, onProgress: emit, apiContext: apiContextFor(req, taskWorkspace),
|
|
1316
|
-
drawCheck: diagramCheck, runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
|
|
1317
|
-
});
|
|
1318
|
-
convo.push({ role: "user", text: instruction });
|
|
1319
|
-
result = { ...next, actions: [...result.actions, ...(next.actions || [])], spec: next.spec };
|
|
1320
|
-
}
|
|
1321
|
-
result.mode = "llm";
|
|
1322
|
-
} catch (e) {
|
|
1323
|
-
if (signal.aborted) return { reply: "Parado a pedido. Nenhuma resposta pendente foi aplicada.", spec, actions: [], talk: true, mode: "cancelled" };
|
|
1324
|
-
// Falhou no meio: não "chuta" com as regras (poderiam fazer outra coisa); deck fica como estava.
|
|
1325
|
-
console.error("[Studio] IA falhou:", e.message);
|
|
1326
|
-
return chatErrorResult(e,spec,body.targetSlide);
|
|
1327
|
-
}
|
|
1328
|
-
// o que a pessoa salvou enquanto a IA pensava não some: junção a três (base = o que foi para a IA)
|
|
1329
|
-
signal.throwIfAborted();
|
|
1330
|
-
const current = taskWorkspace.file && fs.existsSync(taskWorkspace.file) ? loadSpec(taskWorkspace.file) : owner.spec;
|
|
1331
|
-
const merged = globalThis.SagadeckMerge.mergeDecks(spec, current || spec, result.spec);
|
|
1332
|
-
taskWorkspace.spec = merged.deck;
|
|
1333
|
-
carryVisualEdits(spec, taskWorkspace.spec); // a IA mudou o texto de um objeto ajustado: o ajuste acompanha
|
|
1334
|
-
persist(taskWorkspace);
|
|
1335
|
-
if (owner.file === taskWorkspace.file) owner.spec = taskWorkspace.spec;
|
|
1336
|
-
return { ...result, spec: taskWorkspace.spec, conflicts: merged.conflicts, kept: merged.kept };
|
|
1337
|
-
}, signal)); } finally { owner.aiChats.delete(requestId); res.off("close", onClose); }
|
|
1338
|
-
return;
|
|
1339
|
-
}
|
|
1212
|
+
if (await aiChatRoutes({ req, res, pathname, url, W, readJSON, staleTab, llmAvailable, llmConfig, lookAt, withBase, apiBlocked, ensureEnsaio, readPastedLinks, imageOptions, apiContextFor, commandRunner, diagramCheck, slideSnapshots, persist, isBundledTemplate, runTransform, transforms, apiEnv })) return;
|
|
1340
1213
|
|
|
1341
1214
|
// ── Biblioteca (tópicos e apresentações da área de trabalho) ──
|
|
1342
1215
|
// ---------- slide "api": executa pedidos HTTP (ver src/api-client.js) ----------
|
|
@@ -1345,151 +1218,7 @@ Responda só com JSON: {"colunas": [{"nome": "…", "tipo": "tempo|categoria|num
|
|
|
1345
1218
|
// slide API: ambientes, variáveis, envio, tempo real, gravação (src/studio/api-routes.js)
|
|
1346
1219
|
if (pathname.startsWith("/api/http/")) return apiRoutes({ req, res, pathname, url, W, apiEnv, rtSessions, apiBlocked, ensureEnsaio, readJSON, isBundledTemplate });
|
|
1347
1220
|
|
|
1348
|
-
if (pathname.startsWith("/api/library")) {
|
|
1349
|
-
const L = W.library;
|
|
1350
|
-
const ok = (obj = {}) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...obj })); };
|
|
1351
|
-
const fail = (e, code = 400) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message || String(e) })); };
|
|
1352
|
-
try {
|
|
1353
|
-
if (pathname === "/api/library" && req.method === "GET") {
|
|
1354
|
-
const openId = W.file && W.file.startsWith(L.root + path.sep) ? L.idOf(W.file) : null;
|
|
1355
|
-
return ok({ ...L.list(), user: W.user, multiuser: !!opts.multiuser, open: openId });
|
|
1356
|
-
}
|
|
1357
|
-
if (pathname === "/api/library/gallery-cover" && req.method === "GET") {
|
|
1358
|
-
const key = galleryKey(url.searchParams.get("key")), entry = galleryEntry(key);
|
|
1359
|
-
const spec = entry.dir ? { ...entry.spec, _dir: entry.dir } : entry.spec;
|
|
1360
|
-
const cacheDir = path.join(L.root, ".cache", "vitrine");
|
|
1361
|
-
const cached = path.join(cacheDir, `${crypto.createHash("sha1").update(`${key}|${JSON.stringify(entry.spec)}`).digest("hex")}.jpg`);
|
|
1362
|
-
if (!fs.existsSync(cached)) {
|
|
1363
|
-
const [shot] = await slideSnapshots(spec, 0, { mode: "final", width: 480 });
|
|
1364
|
-
fs.mkdirSync(cacheDir, { recursive: true });
|
|
1365
|
-
fs.writeFileSync(cached, Buffer.from(shot.dataUrl.split(",")[1], "base64"));
|
|
1366
|
-
}
|
|
1367
|
-
res.writeHead(200, { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=86400" });
|
|
1368
|
-
res.end(fs.readFileSync(cached));
|
|
1369
|
-
return;
|
|
1370
|
-
}
|
|
1371
|
-
if (pathname === "/api/library/cover" && req.method === "GET") {
|
|
1372
|
-
const file = L.resolveId(url.searchParams.get("id"));
|
|
1373
|
-
const st = fs.statSync(file);
|
|
1374
|
-
const cacheDir = path.join(L.root, ".cache", "capas");
|
|
1375
|
-
const cached = path.join(cacheDir, `${crypto.createHash("sha1").update(`${L.idOf(file)}|${st.mtimeMs}`).digest("hex")}.jpg`);
|
|
1376
|
-
if (!fs.existsSync(cached)) {
|
|
1377
|
-
const spec = loadSpec(file);
|
|
1378
|
-
const [shot] = await slideSnapshots(spec, 0, { mode: "final", width: 480 });
|
|
1379
|
-
fs.mkdirSync(cacheDir, { recursive: true });
|
|
1380
|
-
fs.writeFileSync(cached, Buffer.from(shot.dataUrl.split(",")[1], "base64"));
|
|
1381
|
-
}
|
|
1382
|
-
res.writeHead(200, { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=31536000" });
|
|
1383
|
-
res.end(fs.readFileSync(cached));
|
|
1384
|
-
return;
|
|
1385
|
-
}
|
|
1386
|
-
if (pathname === "/api/library/download" && req.method === "GET") {
|
|
1387
|
-
const file = L.resolveId(url.searchParams.get("id"));
|
|
1388
|
-
const kind = url.searchParams.get("kind") || "sagadeck";
|
|
1389
|
-
if (!["sagadeck", "pptx", "pdf", "roteiro", "tudo", "estudo", "estudo-html"].includes(kind)) throw new Error("formato inválido");
|
|
1390
|
-
await sendExport(res, kind, loadSpec(file), path.basename(file).replace(/\.ya?ml$/i, ""), { audience:url.searchParams.get('audience')||undefined, notes: url.searchParams.get("notas") !== "0" });
|
|
1391
|
-
return;
|
|
1392
|
-
}
|
|
1393
|
-
if (pathname === "/api/library/import" && req.method === "POST") {
|
|
1394
|
-
const name = url.searchParams.get("name") || "Importada";
|
|
1395
|
-
// PowerPoint: importação fiel (src/import); .sagadeck/.zip: pacote do sagadeck
|
|
1396
|
-
if (/\.pptx$/i.test(name)) { const r = await L.importOffice(await readBody(req), url.searchParams.get("topic") || "", name); return ok({ id: r.id, slides: r.slides, snapshots: r.snapshots, snapBy: r.snapBy }); }
|
|
1397
|
-
const id = await L.importPackage(await readBody(req), url.searchParams.get("topic") || "", name);
|
|
1398
|
-
return ok({ id });
|
|
1399
|
-
}
|
|
1400
|
-
if (req.method !== "POST") return fail(new Error("método não suportado"), 405);
|
|
1401
|
-
const b = await readJSON(req);
|
|
1402
|
-
switch (pathname) {
|
|
1403
|
-
case "/api/library/experience": {
|
|
1404
|
-
const spec = createExperienceDeck(b.experience, { title: b.title });
|
|
1405
|
-
buildHTML(spec); // Valida antes de criar o arquivo na biblioteca.
|
|
1406
|
-
return ok({ id: L.createDeck(b.topic || "", spec) });
|
|
1407
|
-
}
|
|
1408
|
-
case "/api/library/topics": return ok({ id: L.createTopic(b.name, b.color) });
|
|
1409
|
-
case "/api/library/topics/update": return ok({ id: L.updateTopic(b.id, b) });
|
|
1410
|
-
case "/api/library/topics/delete": L.deleteTopic(b.id); return ok();
|
|
1411
|
-
case "/api/library/decks": {
|
|
1412
|
-
// nova: em branco (uma capa) — "com IA" usa /api/library/decks/ai
|
|
1413
|
-
const title = String(b.title || "Nova apresentação").trim();
|
|
1414
|
-
const id = L.createDeck(b.topic || "", { title, theme: b.theme || "bauhaus", duration: 10,
|
|
1415
|
-
slides: [{ layout: "cover", title, subtitle: "Subtítulo", author: "" }] });
|
|
1416
|
-
// estilo padrão da biblioteca (Brand Kit): sem tema escolhido, a nova já nasce nele
|
|
1417
|
-
if (!b.theme && L.defaultStyle()) { const f = L.resolveId(id); writeDeckFile(f, L.withDefaultStyle(YAML.parse(fs.readFileSync(f, "utf8")), path.dirname(f))); }
|
|
1418
|
-
return ok({ id });
|
|
1419
|
-
}
|
|
1420
|
-
case "/api/library/decks/model": {
|
|
1421
|
-
// modelos de fábrica (src/studio/demo-decks.js): viram uma apresentação nova na biblioteca
|
|
1422
|
-
const id = L.createDeck(b.topic || "Modelos", demoDeck(b.kind));
|
|
1423
|
-
copyModelAssets(L, id, b.kind);
|
|
1424
|
-
return ok({ id });
|
|
1425
|
-
}
|
|
1426
|
-
case "/api/library/decks/model-preview": {
|
|
1427
|
-
// abre o item da vitrine sem criar arquivo; a cópia só nasce na primeira mudança
|
|
1428
|
-
const key = galleryKey(b.key || b.kind), entry = galleryEntry(key);
|
|
1429
|
-
W.spec = entry.dir ? { ...entry.spec, _dir: entry.dir } : entry.spec;
|
|
1430
|
-
W.file = null;
|
|
1431
|
-
W.preview = { key, topic: String(b.topic ?? "Modelos") }; // "" = sem tópico
|
|
1432
|
-
W.chatHistory = [];
|
|
1433
|
-
return ok({ spec: W.spec, preview: { key, title: W.spec.title, topic: W.preview.topic } });
|
|
1434
|
-
}
|
|
1435
|
-
case "/api/library/decks/model-use": {
|
|
1436
|
-
// "Usar como base": cria a cópia agora, mesmo sem mudança
|
|
1437
|
-
const made = materializePreview(W);
|
|
1438
|
-
if (!made) throw new Error("Não há um modelo em prévia aberto.");
|
|
1439
|
-
return ok(made);
|
|
1440
|
-
}
|
|
1441
|
-
case "/api/library/decks/example-cenario": {
|
|
1442
|
-
// demonstração do Texto no cenário: o YAML e as imagens de exemplo (fundo e recorte transparente)
|
|
1443
|
-
const dir0 = TEMPLATE_DIRS.map((d) => path.join(d, "cenario")).find((d) => fs.existsSync(d));
|
|
1444
|
-
if (!dir0) throw new Error("o exemplo não veio no pacote (templates/cenario)");
|
|
1445
|
-
const tpl = fs.readdirSync(dir0).find((f) => f.endsWith(".yaml"));
|
|
1446
|
-
const id = L.createDeck(b.topic || "", YAML.parse(fs.readFileSync(path.join(dir0, tpl), "utf8")));
|
|
1447
|
-
fs.cpSync(path.join(dir0, "imagens"), path.join(path.dirname(L.resolveId(id)), "imagens"), { recursive: true, force: false });
|
|
1448
|
-
return ok({ id });
|
|
1449
|
-
}
|
|
1450
|
-
case "/api/library/decks/example": {
|
|
1451
|
-
// exemplo de slides api (aula de APIs de IA): roda no ambiente embutido "ensaio", sem configurar nada
|
|
1452
|
-
const tpl = templateFile("ensaio-api.yaml");
|
|
1453
|
-
if (!tpl) throw new Error("o exemplo não veio no pacote (templates/ensaio-api.yaml)");
|
|
1454
|
-
const id = L.createDeck(b.topic || "", YAML.parse(fs.readFileSync(tpl, "utf8")));
|
|
1455
|
-
const dir = path.dirname(L.resolveId(id));
|
|
1456
|
-
for (const [name, text] of Object.entries(DEMO_FILES)) fs.writeFileSync(path.join(dir, name), text);
|
|
1457
|
-
return ok({ id });
|
|
1458
|
-
}
|
|
1459
|
-
case "/api/library/decks/move": return ok({ id: L.moveDeck(b.id, b.topic || "") });
|
|
1460
|
-
case "/api/library/decks/rename": {
|
|
1461
|
-
const id = L.renameDeck(b.id, String(b.title || "").trim() || "Sem título");
|
|
1462
|
-
if (W.file === L.resolveId(b.id)) { W.file = L.resolveId(id); W.spec = loadSpec(W.file); }
|
|
1463
|
-
return ok({ id });
|
|
1464
|
-
}
|
|
1465
|
-
case "/api/library/decks/duplicate": return ok({ id: L.duplicateDeck(b.id) });
|
|
1466
|
-
case "/api/library/decks/trash": return ok({ slot: L.trashDeck(b.id) });
|
|
1467
|
-
case "/api/library/decks/restore": return ok({ id: L.restoreDeck(b.slot) });
|
|
1468
|
-
case "/api/library/decks/purge": L.purgeDeck(b.slot); return ok();
|
|
1469
|
-
case "/api/library/trash/empty": return ok({ purged: L.emptyTrash() });
|
|
1470
|
-
case "/api/library/open": {
|
|
1471
|
-
W.preview = null;
|
|
1472
|
-
const file = L.resolveId(b.id);
|
|
1473
|
-
W.spec = loadSpec(file);
|
|
1474
|
-
W.file = file;
|
|
1475
|
-
return ok({ spec: W.spec, file: W.file, id: b.id });
|
|
1476
|
-
}
|
|
1477
|
-
case "/api/library/decks/ai": {
|
|
1478
|
-
// gera com IA direto numa pasta nova da biblioteca (as imagens ficam dentro dela)
|
|
1479
|
-
if (!(await llmAvailable({ force: true }))) return fail(new Error(`Nenhum LLM respondendo em ${llmConfig().url}.`), 503);
|
|
1480
|
-
await respond(res, b.stream, async (emit) => {
|
|
1481
|
-
const r = await generateIntoLibrary(W, b.topic || "", b, emit);
|
|
1482
|
-
if (r.question) return { ok: true, question: r.question }; // o Studio mostra a pergunta e gera de novo com a resposta
|
|
1483
|
-
return { ok: true, id: r.id, images: r.images, research: r.research, quality: r.quality, variety: r.variety };
|
|
1484
|
-
});
|
|
1485
|
-
return;
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1488
|
-
return fail(new Error("rota da biblioteca desconhecida"), 404);
|
|
1489
|
-
} catch (e) {
|
|
1490
|
-
return fail(e);
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1221
|
+
if (pathname.startsWith("/api/library")) return libraryRoutes({ req, res, pathname, url, W, opts, readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS });
|
|
1493
1222
|
|
|
1494
1223
|
// Baixar o deck aberto: .sagadeck (YAML + imagens, CSS, widgets), PowerPoint, PDF ou roteiro
|
|
1495
1224
|
const exportKind = (pathname.match(/^\/api\/export\/(sagadeck|pptx|pdf|roteiro|tudo|estudo|estudo-html)$/) || [])[1];
|
|
@@ -1497,7 +1226,7 @@ Responda só com JSON: {"colunas": [{"nome": "…", "tipo": "tempo|categoria|num
|
|
|
1497
1226
|
const spec = withBase(W, W.spec);
|
|
1498
1227
|
const name = W.file && !isBundledTemplate(W.file) ? path.basename(W.file).replace(/\.ya?ml$/i, "") : slugify(spec.title);
|
|
1499
1228
|
// ?ver=1: o material de estudo abre na aba (o que o aluno recebe), em vez de baixar
|
|
1500
|
-
await sendExport(res, exportKind, spec, name, { audience:url.searchParams.get('audience')||undefined, notes: url.searchParams.get("notas") !== "0", inline: exportKind === "estudo-html" && url.searchParams.get("ver") === "1" });
|
|
1229
|
+
await sendExport(res, exportKind, spec, name, { audience:url.searchParams.get('audience')||undefined, notes: url.searchParams.get("notas") !== "0", inline: exportKind === "estudo-html" && url.searchParams.get("ver") === "1", macFonts: url.searchParams.get("macFonts") === "1" });
|
|
1501
1230
|
return;
|
|
1502
1231
|
}
|
|
1503
1232
|
|
|
@@ -8,9 +8,10 @@ export async function slideCopyRoutes({pathname,req,res,W,readJSON,staleTab,slid
|
|
|
8
8
|
if (pathname === "/api/slides/copy" && req.method === "POST") {
|
|
9
9
|
const body = await readJSON(req);
|
|
10
10
|
if (staleTab(body, W, res)) return true;
|
|
11
|
-
|
|
11
|
+
const slides = Array.isArray(body.slides) && body.slides.length ? body.slides : [body.slide];
|
|
12
|
+
if (!W.file || !slides[0]) throw Error('Abra uma apresentação salva para copiar.');
|
|
12
13
|
const token = crypto.randomUUID();
|
|
13
|
-
slideClipboard.set(W.user || '', { token, file: W.file,
|
|
14
|
+
slideClipboard.set(W.user || '', { token, file: W.file, slides: structuredClone(slides) });
|
|
14
15
|
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ token })); return true;
|
|
15
16
|
}
|
|
16
17
|
if (pathname === "/api/slides/paste" && req.method === "POST") {
|
|
@@ -19,8 +20,10 @@ export async function slideCopyRoutes({pathname,req,res,W,readJSON,staleTab,slid
|
|
|
19
20
|
const clip = slideClipboard.get(W.user || '');
|
|
20
21
|
if (!clip || clip.token !== body.token) throw Error('Copie um slide novamente antes de colar.');
|
|
21
22
|
if (!W.file || isBundledTemplate(W.file)) materializePreview(W);
|
|
22
|
-
const
|
|
23
|
-
|
|
23
|
+
const from = Array.isArray(clip.slides) && clip.slides.length ? clip.slides : [clip.slide];
|
|
24
|
+
const slides = [];
|
|
25
|
+
for (const one of from) slides.push(await copySlideAssets(one, clip.file, W.file));
|
|
26
|
+
res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ slides, slide: slides[0] })); return true;
|
|
24
27
|
}
|
|
25
28
|
return false;
|
|
26
29
|
}
|
package/src/studio/snapshot.js
CHANGED
|
@@ -39,6 +39,9 @@ export async function slideSnapshots(spec, index, { mode = "final", maxFrames =
|
|
|
39
39
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
40
40
|
await page.goto(`${pathToFileURL(file).href}#${index + 1}`, { waitUntil: "load" });
|
|
41
41
|
await page.addStyleTag({ content: '#hud,#laser,.draw-fab,.draw-toolbar,#draw-canvas{display:none!important}' });
|
|
42
|
+
// Fotos para conferência (e exportação estática): animações de gráfico concluídas, senão o instantâneo
|
|
43
|
+
// pega linhas no meio do traço e a revisão acusa "linhas faltando" que o usuário nunca veria.
|
|
44
|
+
await page.addStyleTag({ content: '.chart .draw,.chart .fade-in,.chart .pop,.chart .gx,.chart .gy,.chart .arc{animation:none!important;opacity:1!important;transform:none!important}.chart .draw{stroke-dashoffset:0!important}' });
|
|
42
45
|
await page.waitForFunction(() => window.sagadeck && typeof window.sagadeck.goto === "function", null, { timeout: 10000 });
|
|
43
46
|
await page.evaluate(() => window.SagaScienceReady);
|
|
44
47
|
await page.evaluate(() => window.SagaDiagramsReady);
|
package/src/themes.js
CHANGED
|
@@ -599,6 +599,36 @@ export function themeCSS(theme) {
|
|
|
599
599
|
}
|
|
600
600
|
|
|
601
601
|
// Mapa usado pelo exportador de PPTX: alias CSS da família -> face do PowerPoint.
|
|
602
|
-
export function pptxFontMap(theme) {
|
|
603
|
-
|
|
602
|
+
export function pptxFontMap(theme, { fallback } = {}) {
|
|
603
|
+
const map = Object.fromEntries(Object.entries(theme.faces).map(([k, f]) => [k, { regular: f.pptx, bold: f.pptxBold || { ...f.pptx, bold: true } }]));
|
|
604
|
+
if (fallback === "mac") {
|
|
605
|
+
for (const m of Object.values(map)) {
|
|
606
|
+
m.regular = { ...m.regular, face: macFallbackFace(m.regular.face) };
|
|
607
|
+
m.bold = { ...m.bold, face: macFallbackFace(m.bold.face) };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return map;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Fontes presentes no Windows e no macOS mesmo sem o Office instalado: o PPTX
|
|
614
|
+
// com --mac-fonts só usa estas. O HTML e o PDF não mudam (continuam idênticos).
|
|
615
|
+
export const PPTX_MAC_SAFE = new Set([
|
|
616
|
+
"Arial", "Arial Narrow", "Arial Black", "Verdana", "Courier New",
|
|
617
|
+
"Georgia", "Comic Sans MS", "Times New Roman",
|
|
618
|
+
]);
|
|
619
|
+
|
|
620
|
+
// Windows/Office (e as OFL vendorizadas no HTML) -> equivalente dos dois sistemas.
|
|
621
|
+
// A cara é preservada: condensada vira condensada, mono vira mono, mão vira mão.
|
|
622
|
+
export function macFallbackFace(face) {
|
|
623
|
+
if (!face || PPTX_MAC_SAFE.has(face)) return face;
|
|
624
|
+
if (/^Bahnschrift/i.test(face)) return "Arial Narrow";
|
|
625
|
+
if (/^Segoe UI/i.test(face)) return "Arial";
|
|
626
|
+
if (/^Cascadia Mono/i.test(face)) return "Courier New";
|
|
627
|
+
if (/^Century Gothic/i.test(face)) return "Verdana";
|
|
628
|
+
if (/^Franklin Gothic Heavy/i.test(face)) return "Arial Black";
|
|
629
|
+
if (/^Franklin Gothic/i.test(face)) return "Arial";
|
|
630
|
+
if (/^(Ink Free|Segoe Print|Caveat|Patrick Hand|Comic Neue)$/i.test(face)) return "Comic Sans MS";
|
|
631
|
+
if (/^(Press Start 2P|VT323)$/i.test(face)) return "Courier New";
|
|
632
|
+
if (/^(Plus Jakarta Sans|Fredoka|Inter|Futura)$/i.test(face)) return "Arial";
|
|
633
|
+
return "Arial";
|
|
604
634
|
}
|