sagadeck 1.2.0 → 1.3.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.
Files changed (41) hide show
  1. package/README.md +11 -2
  2. package/bin/sagadeck.js +36 -3
  3. package/docs/ANDAMENTO.md +37 -0
  4. package/docs/REFERENCIA.md +4 -3
  5. package/package.json +1 -1
  6. package/src/ai/deck-ai.js +44 -11
  7. package/src/ai/document-visuals.js +90 -15
  8. package/src/ai/quality.js +1 -1
  9. package/src/ai/transform.js +51 -2
  10. package/src/diff.js +44 -0
  11. package/src/export/pptx.js +8 -6
  12. package/src/figures/charts.js +4 -2
  13. package/src/figures/diagrams.js +37 -9
  14. package/src/fiscal/autofix.js +1 -1
  15. package/src/fiscal/enxugar.js +22 -0
  16. package/src/library.js +74 -2
  17. package/src/motion.js +1 -1
  18. package/src/package.js +16 -1
  19. package/src/research/brave.js +12 -0
  20. package/src/research/chat-research.js +29 -0
  21. package/src/research/links.js +37 -0
  22. package/src/research/research.js +42 -2
  23. package/src/research/wikipedia.js +15 -0
  24. package/src/runtime/base.css +2 -2
  25. package/src/runtime/motion.js +1 -1
  26. package/src/runtime/runtime.js +15 -2
  27. package/src/studio/ai-chat-route.js +158 -0
  28. package/src/studio/exporting.js +2 -2
  29. package/src/studio/library-routes.js +158 -0
  30. package/src/studio/public/app.js +44 -508
  31. package/src/studio/public/chat.js +461 -0
  32. package/src/studio/public/index.html +17 -12
  33. package/src/studio/public/library.js +13 -2
  34. package/src/studio/public/slide-clipboard.js +20 -7
  35. package/src/studio/public/slide-select.js +73 -0
  36. package/src/studio/public/studio-next.css +28 -1
  37. package/src/studio/public/style.css +7 -1
  38. package/src/studio/public-files.js +1 -1
  39. package/src/studio/server.js +14 -285
  40. package/src/studio/slide-copy-routes.js +7 -4
  41. package/src/themes.js +32 -2
@@ -9,7 +9,7 @@ for(const frame of document.querySelectorAll('iframe[data-motion]')){
9
9
  const begin=e=>{if(!slide?.classList.contains('current')||done||e.target.closest('input,textarea,select,[contenteditable=true]'))return;if(e.type==='keydown'&&![' ','Enter','ArrowRight','PageDown'].includes(e.key))return;e.preventDefault();e.stopImmediatePropagation();if(started){finish();return;}started=true;send('start');};
10
10
  document.addEventListener('keydown',begin,true);slide.addEventListener('click',begin,true);
11
11
  }
12
- window.addEventListener('message',e=>{if(e.source!==frame.contentWindow)return;if(e.data?.sagaMotion==='done')finish();if(e.data?.sagaMotion==='start-request'&&slide?.classList.contains('current')&&!done){if(started)finish();else{started=true;send('start');}}});
12
+ window.addEventListener('message',e=>{if(e.source!==frame.contentWindow)return;if(e.data?.sagaMotion==='done')finish();if(e.data?.sagaMotion==='ready')activate();if(e.data?.sagaMotion==='start-request'&&slide?.classList.contains('current')&&!done){if(started)finish();else{started=true;send('start');}}});
13
13
  const activate=()=>{const active=slide?.classList.contains('current');send('active',active);if(document.documentElement.classList.contains('export')||document.documentElement.dataset.motion==='none'||matchMedia('(prefers-reduced-motion: reduce)').matches)send('poster');if(!active){started=false;done=false;frame.style.visibility='';send('reset');}};
14
14
  frame.addEventListener('load',activate);if(slide)new MutationObserver(activate).observe(slide,{attributes:true,attributeFilter:['class']});
15
15
  }
@@ -809,8 +809,21 @@
809
809
  document.body.classList.add("mouse"); clearTimeout(mt); mt = setTimeout(() => document.body.classList.remove("mouse"), 1600);
810
810
  const l = $("#laser"); l.style.left = e.clientX + "px"; l.style.top = e.clientY + "px";
811
811
  });
812
- let tx = null; document.addEventListener("touchstart", (e) => (tx = e.touches[0].clientX));
813
- document.addEventListener("touchend", (e) => { if (tx == null) return; const d = e.changedTouches[0].clientX - tx; if (Math.abs(d) > 50) cmd(d < 0 ? "next" : "prev"); tx = null; });
812
+ // Mobile: arrastar pro lado troca de slide; tocar (sem arrastar) avança. Toque em controle
813
+ // (botão, link, campo, vídeo, widget, ir-para) continua sendo do controle, nunca avança.
814
+ let tx = null, ty = null, tt = 0;
815
+ document.addEventListener("touchstart", (e) => { const t = e.touches[0]; tx = t.clientX; ty = t.clientY; tt = Date.now(); }, { passive: true });
816
+ document.addEventListener("touchend", (e) => {
817
+ if (tx == null) return;
818
+ const t = e.changedTouches[0], dx = t.clientX - tx, dy = t.clientY - ty, dt = Date.now() - tt;
819
+ tx = ty = null;
820
+ if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) { cmd(dx < 0 ? "next" : "prev"); return; }
821
+ if (Math.abs(dx) < 12 && Math.abs(dy) < 12 && dt < 500 && !e.target.closest("a,button,input,textarea,select,[contenteditable],video,audio,iframe,.widget,[data-goto]")) {
822
+ const sel = typeof getSelection === "function" && getSelection();
823
+ if (sel && !sel.isCollapsed) return; // selecionando texto: não avança
824
+ cmd("next");
825
+ }
826
+ }, { passive: true });
814
827
  const [i, k] = parseHash();
815
828
  slides.forEach((s) => s.classList.remove("current"));
816
829
  cur = -1;
@@ -0,0 +1,158 @@
1
+ // Chat lateral de IA (/api/ai/chat, /api/ai/chat/cancel): SSE, trabalhos canceláveis,
2
+ // pesquisa fase 2, materiais, testes de slides api e junção a três. Saiu de server.js (P1);
3
+ // o servidor passa o estado (ctx). Devolve true quando atendeu.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import crypto from "node:crypto";
7
+ import { loadSpec } from "../build.js";
8
+ import { writeRecording } from "../api-client.js";
9
+ import { editDeck } from "../ai/deck-ai.js";
10
+ import { reviewExperience } from "../ai/quality.js";
11
+ import { maybeResearch } from "../research/chat-research.js";
12
+ import { chatErrorResult } from "./errors.js";
13
+ import { cancellable, respond } from "./ai-response.js";
14
+ import { prepareDocumentMaterials, takeMaterials } from "../ai/document-materials.js";
15
+ import { styleAction } from "./style-routes.js";
16
+ import * as Project from "./project.js";
17
+ import { carryVisualEdits } from "./visual-keys.js";
18
+
19
+ export async function 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 }) {
20
+ if (pathname === "/api/ai/chat/cancel" && req.method === "POST") {
21
+ const body = await readJSON(req);
22
+ const job = W.aiChats?.get(body.requestId);
23
+ job?.abort();
24
+ res.writeHead(200, { "Content-Type": "application/json" });
25
+ res.end(JSON.stringify({ ok: true, stopped: !!job }));
26
+ return true;
27
+ }
28
+ if (pathname === "/api/ai/chat" && req.method === "POST") {
29
+ const body = await readJSON(req);
30
+ if (staleTab(body, W, res)) return true;
31
+ const owner = W;
32
+ owner.aiChats ||= new Map();
33
+ const requestId = body.requestId || crypto.randomUUID();
34
+ const controller = new AbortController();
35
+ owner.aiChats.set(requestId, controller);
36
+ const signal = controller.signal;
37
+ const onClose = () => { if (!res.writableEnded && !['melhorar','recriar'].some(m => transforms.has(`${taskWorkspace.file}|${m}`))) controller.abort(); };
38
+ res.on('close', onClose);
39
+ // Capture o arquivo da tarefa: abrir outro deck não redireciona a escrita.
40
+ const taskWorkspace = { ...owner, spec: structuredClone(owner.spec) };
41
+ const prompt = body.message || "";
42
+ const spec = body.spec || taskWorkspace.spec;
43
+ const issues = body.issues || [];
44
+ // a IA vê (e testa) os slides api no ambiente atual, que pode ser o embutido "ensaio"
45
+ if (!apiBlocked(req) && (spec.slides || []).some((sl) => sl && sl.layout === "api")) await ensureEnsaio();
46
+
47
+ try { await respond(res, body.stream, async (send) => cancellable(async () => {
48
+ const emit = ev => { signal.throwIfAborted(); send(ev); };
49
+ let result;
50
+ const history = Array.isArray(body.history) ? body.history : [];
51
+ // o cache pode ter guardado uma queda de segundos do relay: confere de novo antes de desistir
52
+ if (!(await llmAvailable()) && !(await llmAvailable({ force: true }))) {
53
+ // Sem modelo, ninguém decide nada: nem "o que é X?" nem "resuma" viram edição por palavra-chave
54
+ // (as regras antigas trocavam o layout e enfiavam texto genérico no slide). O deck fica como está.
55
+ return {
56
+ 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.`,
57
+ spec, actions: [], targetSlide: body.targetSlide, talk: true, mode: "off",
58
+ };
59
+ }
60
+ try {
61
+ const target = typeof body.targetSlide === "number" ? body.targetSlide : null;
62
+ const visuals = await lookAt(withBase(taskWorkspace, spec), target, prompt, emit);
63
+ // anexos: imagens vão como visão; documentos (id do /api/ai/context) vão como texto
64
+ let materials = await prepareDocumentMaterials(takeMaterials(taskWorkspace, (Array.isArray(body.attachments) ? body.attachments : [])
65
+ .filter((a) => a && typeof a === "object" && a.type === "doc").map((a) => a.id)), path.dirname(taskWorkspace.file), {signal,onProgress:emit});
66
+ for (const [i, url] of (Array.isArray(body.attachments) ? body.attachments : []).entries()) {
67
+ if (typeof url === "string" && url.startsWith("data:image/")) visuals.push({ label: `imagem colada pelo usuário ${i + 1}`, dataUrl: url });
68
+ }
69
+ // arquivos do projeto (contexto/): material para a IA, junto com os anexos da mensagem
70
+ const P = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? Project.projectOf(taskWorkspace.file) : null;
71
+ 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);
72
+ // links colados na mensagem: o servidor lê sozinho e conta nas ações
73
+ const linkActions = [];
74
+ for (const doc of await readPastedLinks(taskWorkspace, prompt, linkActions)) materials.push(doc);
75
+ // pesquisa na web no chat (fase 2): a IA decide se o que ela sabe basta; as fontes
76
+ // lidas entram nos materiais e a instrução de citação vai junto do pedido
77
+ const chatResearch = await maybeResearch({ prompt, materials, saveDir: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? path.dirname(taskWorkspace.file) : null, onProgress: (s) => emit({ phase: "step", text: s }) });
78
+ materials = chatResearch.materials;
79
+ if (chatResearch.report?.fontes?.length) linkActions.push(`Pesquisa na web: ${chatResearch.report.fontes.length} fonte(s) (${chatResearch.report.fontes.map((f) => f.site).join(", ")})`);
80
+ result = await editDeck({
81
+ spec: withBase(taskWorkspace, spec), signal,
82
+ instruction: chatResearch.instruction ? `${chatResearch.instruction}\n${prompt}` : prompt,
83
+ targetSlide: target,
84
+ issues,
85
+ images: true, // a IA decide (regra no prompt: só quando pedirem ou aceitarem)
86
+ imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)),
87
+ history,
88
+ onProgress: emit,
89
+ visuals,
90
+ materials,
91
+ renderNotes: Array.isArray(body.renderNotes) ? body.renderNotes.slice(0, 8) : [],
92
+ apiContext: apiContextFor(req, taskWorkspace),
93
+ drawCheck: diagramCheck,
94
+ runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
95
+ reviewCheck: (deck, indices) => reviewExperience(deck, indices, { snapshot: slideSnapshots, onProgress: emit, signal, briefing:prompt }),
96
+ styles: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? { list: taskWorkspace.library.listStyles(), current: taskWorkspace.spec?.style?.name || null } : null,
97
+ });
98
+ signal.throwIfAborted();
99
+ if (linkActions.length) result.actions = [...linkActions, ...(result.actions || [])];
100
+ // a IA decidiu transformar a apresentação inteira (transform:): o trabalho em etapas, com o andamento aqui
101
+ if (result.transform) {
102
+ const transformed = await runTransform(taskWorkspace, withBase(taskWorkspace, spec), result, emit, prompt, visuals.filter(v => v.label.startsWith("imagem colada pelo usuário")), signal);
103
+ 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.' };
104
+ }
105
+ // a IA decidiu mexer no estilo (estilo:): aplicar, salvar, tirar ou padrão das novas
106
+ if (result.style) return styleAction({ W: taskWorkspace, spec: withBase(taskWorkspace, spec), result, persist, isBundledTemplate });
107
+ // Slides api: a IA pediu para testar (test: [n]) → o Studio executa, devolve o relatório e ela
108
+ // corrige, até 3 rodadas. Quem decide testar e o que corrigir é a IA; aqui só executa.
109
+ const convo = [...history, { role: "user", text: prompt }]
110
+ for (let round = 1; result.test?.length && round <= 3; round++) {
111
+ if (apiBlocked(req)) { result.actions.push("Testes de slides api só no Studio local."); break; }
112
+ const reports = [];
113
+ for (const i of result.test) {
114
+ const slide = result.spec.slides[i];
115
+ emit({ phase: "test", text: `Testando o slide ${i + 1} (${apiEnv.currentName() || "sem ambiente"})…` });
116
+ const r = await apiEnv.runSlide(slide, { vars: taskWorkspace.apiVars || {}, deckDir: taskWorkspace.file ? path.dirname(taskWorkspace.file) : null });
117
+ signal.throwIfAborted();
118
+ if (r.saved) taskWorkspace.apiVars = { ...(taskWorkspace.apiVars || {}), ...r.saved };
119
+ if (r.record) {
120
+ const key = globalThis.SagadeckApiCore.key(slide);
121
+ if (taskWorkspace.file && !isBundledTemplate(taskWorkspace.file)) writeRecording(taskWorkspace.file, key, r.record);
122
+ else (taskWorkspace.apiRecordings = taskWorkspace.apiRecordings || {})[key] = { ...r.record, at: new Date().toISOString() };
123
+ }
124
+ reports.push({ slide: i + 1, ...r.report });
125
+ 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)}`);
126
+ }
127
+ convo.push({ role: "assistant", text: result.reply });
128
+ 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.`;
129
+ emit({ phase: "test", text: reports.every((x) => x.ok) ? "Os testes passaram; conferindo…" : "Corrigindo com base no resultado…" });
130
+ const next = await editDeck({
131
+ spec: withBase(taskWorkspace, result.spec), signal, instruction, targetSlide: target, images: false,
132
+ imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, result.spec)), history: convo, onProgress: emit, apiContext: apiContextFor(req, taskWorkspace),
133
+ drawCheck: diagramCheck, runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
134
+ });
135
+ convo.push({ role: "user", text: instruction });
136
+ result = { ...next, actions: [...result.actions, ...(next.actions || [])], spec: next.spec };
137
+ }
138
+ result.mode = "llm";
139
+ } catch (e) {
140
+ if (signal.aborted) return { reply: "Parado a pedido. Nenhuma resposta pendente foi aplicada.", spec, actions: [], talk: true, mode: "cancelled" };
141
+ // Falhou no meio: não "chuta" com as regras (poderiam fazer outra coisa); deck fica como estava.
142
+ console.error("[Studio] IA falhou:", e.message);
143
+ return chatErrorResult(e,spec,body.targetSlide);
144
+ }
145
+ // o que a pessoa salvou enquanto a IA pensava não some: junção a três (base = o que foi para a IA)
146
+ signal.throwIfAborted();
147
+ const current = taskWorkspace.file && fs.existsSync(taskWorkspace.file) ? loadSpec(taskWorkspace.file) : owner.spec;
148
+ const merged = globalThis.SagadeckMerge.mergeDecks(spec, current || spec, result.spec);
149
+ taskWorkspace.spec = merged.deck;
150
+ carryVisualEdits(spec, taskWorkspace.spec); // a IA mudou o texto de um objeto ajustado: o ajuste acompanha
151
+ persist(taskWorkspace);
152
+ if (owner.file === taskWorkspace.file) owner.spec = taskWorkspace.spec;
153
+ return { ...result, spec: taskWorkspace.spec, conflicts: merged.conflicts, kept: merged.kept };
154
+ }, signal)); } finally { owner.aiChats.delete(requestId); res.off("close", onClose); }
155
+ return true;
156
+ }
157
+ return false;
158
+ }
@@ -21,7 +21,7 @@ export function sendHtml(res,spec,audience){
21
21
 
22
22
  // Gera e envia um arquivo da apresentação. PPTX/PDF/roteiro usam o Chrome invisível (os mesmos
23
23
  // exportadores de "sagadeck pptx | pdf | roteiro"), numa pasta temporária.
24
- export async function sendExport(res, kind, spec, name, { notes = true, inline = false, audience } = {}) {
24
+ export async function sendExport(res, kind, spec, name, { notes = true, inline = false, audience, macFonts = false } = {}) {
25
25
  spec=meetingSpec(spec,audience);
26
26
  const cd = (file) => `attachment; filename="${slugify(file.replace(/\.\w+$/, ""))}${path.extname(file)}"; filename*=UTF-8''${encodeURIComponent(file)}`;
27
27
  if (kind === "sagadeck") {
@@ -52,7 +52,7 @@ export async function sendExport(res, kind, spec, name, { notes = true, inline =
52
52
  const out = path.join(tmp, `saida-${what}`);
53
53
  if (what === "pptx") {
54
54
  const { exportPptx } = await import("../export/pptx.js");
55
- errors.push(...(await exportPptx(htmlFile, out, { theme: r.theme, meta: { ...r.meta, slides: r.slidesMeta }, notes })).errors);
55
+ errors.push(...(await exportPptx(htmlFile, out, { theme: r.theme, meta: { ...r.meta, slides: r.slidesMeta }, notes, fontFallback: macFonts ? "mac" : undefined })).errors);
56
56
  } else if (what === "pdf") {
57
57
  const { pdf } = await import("../export/shots.js");
58
58
  // Preferências › Exportação: tema escuro com par claro (manual-noite → manual) sai claro no PDF, bom para imprimir
@@ -0,0 +1,158 @@
1
+ // Rotas da biblioteca (/api/library/*): tópicos, decks, capas, download, importação e geração com IA.
2
+ // Saiu de server.js (P1 monolitos); o servidor passa o estado (ctx). Sempre atende o prefixo: devolve true.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import crypto from "node:crypto";
6
+ import YAML from "yaml";
7
+ import { buildHTML, loadSpec } from "../build.js";
8
+ import { writeDeckFile } from "../deck-file.js";
9
+ import { createExperienceDeck } from "../experiences.js";
10
+ import { demoDeck } from "./demo-decks.js";
11
+ import { DEMO_FILES } from "../api-demo.js";
12
+
13
+ export async function libraryRoutes({ req, res, pathname, url, W, opts, readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS }) {
14
+ const L = W.library;
15
+ const ok = (obj = {}) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...obj })); };
16
+ const fail = (e, code = 400) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message || String(e) })); };
17
+ try {
18
+ if (pathname === "/api/library" && req.method === "GET") {
19
+ const openId = W.file && W.file.startsWith(L.root + path.sep) ? L.idOf(W.file) : null;
20
+ return ok({ ...L.list(), user: W.user, multiuser: !!opts.multiuser, open: openId });
21
+ }
22
+ if (pathname === "/api/library/gallery-cover" && req.method === "GET") {
23
+ const key = galleryKey(url.searchParams.get("key")), entry = galleryEntry(key);
24
+ const spec = entry.dir ? { ...entry.spec, _dir: entry.dir } : entry.spec;
25
+ const cacheDir = path.join(L.root, ".cache", "vitrine");
26
+ const cached = path.join(cacheDir, `${crypto.createHash("sha1").update(`${key}|${JSON.stringify(entry.spec)}`).digest("hex")}.jpg`);
27
+ if (!fs.existsSync(cached)) {
28
+ const [shot] = await slideSnapshots(spec, 0, { mode: "final", width: 480 });
29
+ fs.mkdirSync(cacheDir, { recursive: true });
30
+ fs.writeFileSync(cached, Buffer.from(shot.dataUrl.split(",")[1], "base64"));
31
+ }
32
+ res.writeHead(200, { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=86400" });
33
+ res.end(fs.readFileSync(cached));
34
+ return;
35
+ }
36
+ if (pathname === "/api/library/cover" && req.method === "GET") {
37
+ const file = L.resolveId(url.searchParams.get("id"));
38
+ const st = fs.statSync(file);
39
+ const cacheDir = path.join(L.root, ".cache", "capas");
40
+ const cached = path.join(cacheDir, `${crypto.createHash("sha1").update(`${L.idOf(file)}|${st.mtimeMs}`).digest("hex")}.jpg`);
41
+ if (!fs.existsSync(cached)) {
42
+ const spec = loadSpec(file);
43
+ const [shot] = await slideSnapshots(spec, 0, { mode: "final", width: 480 });
44
+ fs.mkdirSync(cacheDir, { recursive: true });
45
+ fs.writeFileSync(cached, Buffer.from(shot.dataUrl.split(",")[1], "base64"));
46
+ }
47
+ res.writeHead(200, { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=31536000" });
48
+ res.end(fs.readFileSync(cached));
49
+ return;
50
+ }
51
+ if (pathname === "/api/library/download" && req.method === "GET") {
52
+ const file = L.resolveId(url.searchParams.get("id"));
53
+ const kind = url.searchParams.get("kind") || "sagadeck";
54
+ if (!["sagadeck", "pptx", "pdf", "roteiro", "tudo", "estudo", "estudo-html"].includes(kind)) throw new Error("formato inválido");
55
+ 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" });
56
+ return;
57
+ }
58
+ if (pathname === "/api/library/import" && req.method === "POST") {
59
+ const name = url.searchParams.get("name") || "Importada";
60
+ // PowerPoint: importação fiel (src/import); .sagadeck/.zip: pacote do sagadeck
61
+ 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 }); }
62
+ const id = await L.importPackage(await readBody(req), url.searchParams.get("topic") || "", name);
63
+ return ok({ id });
64
+ }
65
+ if (req.method !== "POST") return fail(new Error("método não suportado"), 405);
66
+ const b = await readJSON(req);
67
+ switch (pathname) {
68
+ case "/api/library/experience": {
69
+ const spec = createExperienceDeck(b.experience, { title: b.title });
70
+ buildHTML(spec); // Valida antes de criar o arquivo na biblioteca.
71
+ return ok({ id: L.createDeck(b.topic || "", spec) });
72
+ }
73
+ case "/api/library/topics": return ok({ id: L.createTopic(b.name, b.color) });
74
+ case "/api/library/topics/update": return ok({ id: L.updateTopic(b.id, b) });
75
+ case "/api/library/topics/delete": L.deleteTopic(b.id); return ok();
76
+ case "/api/library/decks": {
77
+ // nova: em branco (uma capa) — "com IA" usa /api/library/decks/ai
78
+ const title = String(b.title || "Nova apresentação").trim();
79
+ const id = L.createDeck(b.topic || "", { title, theme: b.theme || "bauhaus", duration: 10,
80
+ slides: [{ layout: "cover", title, subtitle: "Subtítulo", author: "" }] });
81
+ // estilo padrão da biblioteca (Brand Kit): sem tema escolhido, a nova já nasce nele
82
+ if (!b.theme && L.defaultStyle()) { const f = L.resolveId(id); writeDeckFile(f, L.withDefaultStyle(YAML.parse(fs.readFileSync(f, "utf8")), path.dirname(f))); }
83
+ return ok({ id });
84
+ }
85
+ case "/api/library/decks/model": {
86
+ // modelos de fábrica (src/studio/demo-decks.js): viram uma apresentação nova na biblioteca
87
+ const id = L.createDeck(b.topic || "Modelos", demoDeck(b.kind));
88
+ copyModelAssets(L, id, b.kind);
89
+ return ok({ id });
90
+ }
91
+ case "/api/library/decks/model-preview": {
92
+ // abre o item da vitrine sem criar arquivo; a cópia só nasce na primeira mudança
93
+ const key = galleryKey(b.key || b.kind), entry = galleryEntry(key);
94
+ W.spec = entry.dir ? { ...entry.spec, _dir: entry.dir } : entry.spec;
95
+ W.file = null;
96
+ W.preview = { key, topic: String(b.topic ?? "Modelos") }; // "" = sem tópico
97
+ W.chatHistory = [];
98
+ return ok({ spec: W.spec, preview: { key, title: W.spec.title, topic: W.preview.topic } });
99
+ }
100
+ case "/api/library/decks/model-use": {
101
+ // "Usar como base": cria a cópia agora, mesmo sem mudança
102
+ const made = materializePreview(W);
103
+ if (!made) throw new Error("Não há um modelo em prévia aberto.");
104
+ return ok(made);
105
+ }
106
+ case "/api/library/decks/example-cenario": {
107
+ // demonstração do Texto no cenário: o YAML e as imagens de exemplo (fundo e recorte transparente)
108
+ const dir0 = TEMPLATE_DIRS.map((d) => path.join(d, "cenario")).find((d) => fs.existsSync(d));
109
+ if (!dir0) throw new Error("o exemplo não veio no pacote (templates/cenario)");
110
+ const tpl = fs.readdirSync(dir0).find((f) => f.endsWith(".yaml"));
111
+ const id = L.createDeck(b.topic || "", YAML.parse(fs.readFileSync(path.join(dir0, tpl), "utf8")));
112
+ fs.cpSync(path.join(dir0, "imagens"), path.join(path.dirname(L.resolveId(id)), "imagens"), { recursive: true, force: false });
113
+ return ok({ id });
114
+ }
115
+ case "/api/library/decks/example": {
116
+ // exemplo de slides api (aula de APIs de IA): roda no ambiente embutido "ensaio", sem configurar nada
117
+ const tpl = templateFile("ensaio-api.yaml");
118
+ if (!tpl) throw new Error("o exemplo não veio no pacote (templates/ensaio-api.yaml)");
119
+ const id = L.createDeck(b.topic || "", YAML.parse(fs.readFileSync(tpl, "utf8")));
120
+ const dir = path.dirname(L.resolveId(id));
121
+ for (const [name, text] of Object.entries(DEMO_FILES)) fs.writeFileSync(path.join(dir, name), text);
122
+ return ok({ id });
123
+ }
124
+ case "/api/library/decks/move": return ok({ id: L.moveDeck(b.id, b.topic || "") });
125
+ case "/api/library/decks/rename": {
126
+ const id = L.renameDeck(b.id, String(b.title || "").trim() || "Sem título");
127
+ if (W.file === L.resolveId(b.id)) { W.file = L.resolveId(id); W.spec = loadSpec(W.file); }
128
+ return ok({ id });
129
+ }
130
+ case "/api/library/decks/duplicate": return ok({ id: L.duplicateDeck(b.id) });
131
+ case "/api/library/decks/trash": return ok({ slot: L.trashDeck(b.id) });
132
+ case "/api/library/decks/restore": return ok({ id: L.restoreDeck(b.slot) });
133
+ case "/api/library/decks/purge": L.purgeDeck(b.slot); return ok();
134
+ case "/api/library/trash/empty": return ok({ purged: L.emptyTrash() });
135
+ case "/api/library/open": {
136
+ W.preview = null;
137
+ const file = L.resolveId(b.id);
138
+ W.spec = loadSpec(file);
139
+ W.file = file;
140
+ return ok({ spec: W.spec, file: W.file, id: b.id });
141
+ }
142
+ case "/api/library/decks/ai": {
143
+ // gera com IA direto numa pasta nova da biblioteca (as imagens ficam dentro dela)
144
+ if (!(await llmAvailable({ force: true }))) return fail(new Error(`Nenhum LLM respondendo em ${llmConfig().url}.`), 503);
145
+ await respond(res, b.stream, async (emit) => {
146
+ const r = await generateIntoLibrary(W, b.topic || "", b, emit);
147
+ if (r.question) return { ok: true, question: r.question }; // o Studio mostra a pergunta e gera de novo com a resposta
148
+ return { ok: true, id: r.id, images: r.images, research: r.research, quality: r.quality, variety: r.variety };
149
+ });
150
+ return;
151
+ }
152
+ }
153
+ return fail(new Error("rota da biblioteca desconhecida"), 404);
154
+ } catch (e) {
155
+ return fail(e);
156
+ }
157
+ return true;
158
+ }