sagadeck 1.8.0 → 1.9.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 (92) hide show
  1. package/README.md +19 -8
  2. package/SKILL.md +10 -3
  3. package/bin/sagadeck.js +23 -0
  4. package/docs/ANDAMENTO.md +367 -7
  5. package/docs/PLANO-MELHORIAS.md +180 -0
  6. package/docs/REFERENCIA.md +131 -110
  7. package/package.json +33 -2
  8. package/src/ai/commands.js +2 -2
  9. package/src/ai/deck-ai.js +1 -1
  10. package/src/ai/ia-check.js +127 -0
  11. package/src/ai/ia-config.js +2 -2
  12. package/src/ai/llm.js +5 -1
  13. package/src/ai/map-analysis-run.js +1 -1
  14. package/src/ai/quality.js +1 -1
  15. package/src/ai/video-generation.js +70 -25
  16. package/src/build.js +46 -14
  17. package/src/elements.js +39 -1
  18. package/src/export/pptx.js +7 -2
  19. package/src/export/shots.js +7 -2
  20. package/src/fiscal/normalize.js +19 -0
  21. package/src/fs-retry.js +37 -2
  22. package/src/import/office.js +5 -4
  23. package/src/layouts.js +104 -99
  24. package/src/lessons.js +1 -1
  25. package/src/library.js +7 -7
  26. package/src/map-analysis.js +4 -1
  27. package/src/map-config.js +10 -4
  28. package/src/map.js +21 -8
  29. package/src/mcp/server.js +17 -0
  30. package/src/motion.js +20 -6
  31. package/src/purpose.js +1 -1
  32. package/src/runtime/base.css +88 -332
  33. package/src/runtime/fit.js +3 -2
  34. package/src/runtime/fonts/amatic-sc.css +3 -0
  35. package/src/runtime/fonts/bebas-neue.css +2 -0
  36. package/src/runtime/fonts/cabin-sketch.css +2 -0
  37. package/src/runtime/fonts/fraunces.css +3 -0
  38. package/src/runtime/fonts/gelasio.css +3 -0
  39. package/src/runtime/fonts/ibm-plex-mono.css +4 -0
  40. package/src/runtime/fonts/index.json +10 -1
  41. package/src/runtime/fonts/itim.css +2 -0
  42. package/src/runtime/fonts/montserrat.css +2 -0
  43. package/src/runtime/fonts/space-grotesk.css +2 -0
  44. package/src/runtime/map-background.js +91 -0
  45. package/src/runtime/map.js +41 -9
  46. package/src/runtime/runtime.js +81 -100
  47. package/src/runtime/skins/caderno.css +33 -0
  48. package/src/runtime/skins/cartaz.css +27 -0
  49. package/src/runtime/skins/infografico.css +29 -0
  50. package/src/runtime/skins/lousa.css +22 -0
  51. package/src/runtime/skins/musgo.css +42 -0
  52. package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
  53. package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
  54. package/src/runtime/vendor/geo-contornos.json +1 -1
  55. package/src/runtime/vendor/maplibre-gl.css +1 -0
  56. package/src/runtime/vendor/maplibre-gl.js +825 -0
  57. package/src/runtime/vendor/maplibre-leaflet.js +234 -0
  58. package/src/runtime/vendor/maplibre-worker.js +10 -0
  59. package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
  60. package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
  61. package/src/studio/command-approval.js +2 -3
  62. package/src/studio/demo-decks.js +4 -2
  63. package/src/studio/export-jobs.js +86 -0
  64. package/src/studio/exporting.js +36 -18
  65. package/src/studio/feature-demos.js +144 -0
  66. package/src/studio/ia-routes.js +9 -0
  67. package/src/studio/layout-samples.js +9 -18
  68. package/src/studio/library-routes.js +0 -9
  69. package/src/studio/project.js +1 -0
  70. package/src/studio/public/ai-settings.js +2 -2
  71. package/src/studio/public/app.js +41 -51
  72. package/src/studio/public/export-status.js +154 -0
  73. package/src/studio/public/index.html +13 -10
  74. package/src/studio/public/library.css +12 -3
  75. package/src/studio/public/library.html +1 -0
  76. package/src/studio/public/library.js +36 -14
  77. package/src/studio/public/map-data.js +7 -3
  78. package/src/studio/public/map-editor.js +625 -236
  79. package/src/studio/public/map-settings.js +3 -1
  80. package/src/studio/public/slide-form.js +18 -15
  81. package/src/studio/public/style.css +54 -9
  82. package/src/studio/public/ui-icons.js +1 -1
  83. package/src/studio/server.js +27 -12
  84. package/src/studio/snapshot.js +8 -4
  85. package/src/studio/template-collections.js +1 -1
  86. package/src/studio/tour.js +89 -7
  87. package/src/themes.js +154 -0
  88. package/templates/cenario-e-ciencia.yaml +1 -21
  89. package/templates/ensaio-api.yaml +3 -3
  90. package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
  91. package/templates/cenario/imagens/cidade.svg +0 -30
  92. package/templates/cenario/imagens/pessoa.svg +0 -15
@@ -26,10 +26,9 @@ export async function estimateSubmitCost(command, { commandEnv, W }) {
26
26
  try {
27
27
  const req = typeof command.code === "object" && command.code !== null ? command.code : JSON.parse(String(command.code || "{}"));
28
28
  if (String(command.language || "").toLowerCase() !== "video" || req?.action !== "submit") return null;
29
- const { estimateVideoCost } = await import("../ai/video-generation.js");
29
+ const { estimateVideoCost, videoSetup } = await import("../ai/video-generation.js");
30
30
  const { env } = await commandEnv(W).catch(() => ({ env: {} }));
31
- const key = env.SAGADECK_VIDEO_KEY || env.OPENROUTER_API_KEY || process.env.SAGADECK_VIDEO_KEY || process.env.OPENROUTER_API_KEY;
32
- return await estimateVideoCost({ model: req.model, duration: req.duration, resolution: req.resolution }, { key });
31
+ return await estimateVideoCost({ model: req.model, duration: req.duration, resolution: req.resolution }, { setup: videoSetup({ env: { ...process.env, ...env } }) });
33
32
  } catch { return null; }
34
33
  }
35
34
 
@@ -4,16 +4,18 @@ import { COLLECTION_NAMES, COLLECTION_ASSETS, collectionDeck } from './template-
4
4
  import { LAYOUT_SAMPLES } from './layout-samples.js';
5
5
  import { explorationDemo } from './exploration-demo.js';
6
6
  import { tourDeck, TOUR_TITLE } from './tour.js';
7
- export function demoAssets(kind) { return kind === 'lavanda' || kind === 'novidades' ? ['lavanda-cover.jpg'] : COLLECTION_ASSETS[kind] || []; }
7
+ import { featureDemo, FEATURE_DEMO_NAMES } from './feature-demos.js';
8
+ export function demoAssets(kind) { return kind === 'lavanda' || kind === 'novidades' ? ['lavanda-cover.jpg'] : kind === 'conheca' || kind === 'mapas' ? ['tour-mapa-botao.jpg', 'tour-mapa-editor.jpg'] : COLLECTION_ASSETS[kind] || []; }
8
9
  // arquivos do projeto que o modelo traz (fora de imagens/): a planilha e as anotações da demo de novidades
9
10
  export function demoProjectFiles(kind) { return kind === 'novidades' ? ['contexto/vendas.csv', 'contexto/leia-me.md'] : []; }
10
11
  const item=(title,text,extra={})=>({title,text,...extra});
11
12
  const route='flowchart LR\n A([Pedido]):::hi --> B{Dados válidos?}\n B -->|sim| C[Executar tarefa]\n B -.->|não| D[Informar erro]:::vazado\n C ==> E[Guardar resultado]:::em';
12
- export const DEMO_NAMES={conheca:TOUR_TITLE,cinematica:"Demo — mundos que se transformam",explorar:'Demo — explore uma ideia',lavanda:'Estúdio lavanda',executivo:'Relatório executivo',workshop:'Workshop visual',compacto:'Demo — material de consulta',avancado:'Demo — recursos avançados',diagramas:'Demo — diagramas vivos',novidades:'Demo — novidades',hidraulica:'Aula de hidráulica',algoritmos:'Aula de algoritmos',...COLLECTION_NAMES};
13
+ export const DEMO_NAMES={conheca:TOUR_TITLE,...FEATURE_DEMO_NAMES,cinematica:"Demo — mundos que se transformam",explorar:'Demo — explore uma ideia',lavanda:'Estúdio lavanda',executivo:'Relatório executivo',workshop:'Workshop visual',compacto:'Demo — material de consulta',avancado:'Demo — recursos avançados',diagramas:'Demo — diagramas vivos',novidades:'Demo — novidades',hidraulica:'Aula de hidráulica',algoritmos:'Aula de algoritmos',...COLLECTION_NAMES};
13
14
  export function demoDeck(kind) {
14
15
  if(kind==='conheca')return tourDeck();
15
16
  if(kind==='cinematica')return portalDemo();
16
17
  if(kind==='explorar')return explorationDemo();
18
+ const feature=featureDemo(kind);if(feature)return feature;
17
19
  if(Object.hasOwn(COLLECTION_NAMES,kind))return collectionDeck(kind);
18
20
  if(!DEMO_NAMES[kind])throw Error('Modelo não encontrado.');
19
21
  // cada demo com a sua cara (tema, tom e figura da capa): na vitrine, as capas não podem parecer a mesma
@@ -0,0 +1,86 @@
1
+ // Gerações pertencem à pessoa, não à aba. O arquivo fica fora da biblioteca/nuvem por até 24 h,
2
+ // enquanto este Studio estiver ligado. Uma geração por vez limita o uso de Chrome e memória.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { generateExport, exportHeaders } from './exporting.js';
8
+ import { removeRetry } from '../fs-retry.js';
9
+
10
+ export const EXPORT_KINDS = ['sagadeck', 'pptx', 'pdf', 'roteiro', 'tudo', 'estudo', 'estudo-html'];
11
+ const TTL = 24 * 3600_000;
12
+ export function createExportJobs({ generate = generateExport, now = () => Date.now(), ttl = TTL } = {}) {
13
+ const jobs = new Map(), queue = [];
14
+ let active = null, closed = false, dir;
15
+ const visible = j => ({ id: j.id, kind: j.kind, name: j.name, title: j.title, status: j.status,
16
+ progress: j.progress, createdAt: j.createdAt, finishedAt: j.finishedAt || null,
17
+ expiresAt: j.finishedAt ? j.finishedAt + ttl : null, error: j.error || null, warnings: j.warnings || [] });
18
+ const remove = j => { if (j.path) fs.rmSync(j.path, { force: true }); jobs.delete(j.id); };
19
+ function prune() {
20
+ for (const j of jobs.values()) if (j.finishedAt && now() - j.finishedAt >= ttl) remove(j);
21
+ }
22
+ const timer = setInterval(prune, 60_000); timer.unref();
23
+ async function run(j) {
24
+ j.status = 'running';
25
+ try {
26
+ const result = await generate(j.kind, j.spec, j.name, { ...j.options, onProgress: p => { j.progress = { ...j.progress, ...p }; } });
27
+ if (closed) return;
28
+ dir ||= fs.mkdtempSync(path.join(os.tmpdir(), 'sagadeck-export-jobs-'));
29
+ j.path = path.join(dir, j.id);
30
+ fs.writeFileSync(j.path, result.body, { mode: 0o600 });
31
+ j.headers = exportHeaders(result);
32
+ j.name = result.file;
33
+ j.warnings = [...(result.warnings || []), ...(result.missing || []).map(file => `Arquivo não encontrado: ${file}`)];
34
+ j.progress = { ...j.progress, phase: 'ready', completed: j.progress.total };
35
+ j.status = 'ready';
36
+ } catch (e) {
37
+ j.status = 'failed'; j.error = e.message || String(e);
38
+ } finally {
39
+ j.finishedAt = now(); delete j.spec; delete j.options;
40
+ // Mantém as dez exportações recentes da pessoa; não guarda arquivos sem limite.
41
+ const done = [...jobs.values()].filter(x => x.owner === j.owner && x.finishedAt).sort((a, b) => b.finishedAt - a.finishedAt);
42
+ for (const old of done.slice(10)) remove(old);
43
+ }
44
+ }
45
+ function drain() {
46
+ if (closed || active || !queue.length) return;
47
+ const j = queue.shift();
48
+ active = run(j).finally(() => { active = null; setImmediate(drain); });
49
+ }
50
+ return {
51
+ create(owner, kind, spec, name, options = {}) {
52
+ if (closed) throw new Error('O Studio está encerrando. Tente novamente depois de reiniciar.');
53
+ if (!EXPORT_KINDS.includes(kind)) throw new Error('Formato de exportação inválido.');
54
+ prune();
55
+ const key = crypto.createHash('sha256').update(JSON.stringify([kind, spec, name, options])).digest('hex');
56
+ const pending = [...jobs.values()].filter(j => !j.finishedAt);
57
+ const same = pending.find(j => j.owner === owner && j.key === key);
58
+ if (same) return visible(same);
59
+ if (pending.filter(j => j.owner === owner).length >= 4 || pending.length >= 32) throw new Error('Há muitas exportações em andamento. Espere uma delas terminar.');
60
+ const j = { id: crypto.randomUUID(), owner, key, kind, spec: structuredClone(spec), name, title: spec.title || name,
61
+ options: { ...options }, status: 'queued', createdAt: now(), progress: { phase: 'queued', completed: 0, total: spec.slides?.length || 0 } };
62
+ jobs.set(j.id, j); queue.push(j); setImmediate(drain);
63
+ return visible(j);
64
+ },
65
+ list(owner) { prune(); return [...jobs.values()].filter(j => j.owner === owner).sort((a, b) => b.createdAt - a.createdAt).map(visible); },
66
+ download(owner, id, res) {
67
+ prune();
68
+ const j = jobs.get(id);
69
+ const code = !j || j.owner !== owner ? 404 : j.status !== 'ready' ? 409 : 200;
70
+ if (code !== 200) {
71
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
72
+ res.end(JSON.stringify({ error: code === 404 ? 'Exportação não encontrada ou expirada. Gere novamente.' : j.error || 'A exportação ainda não terminou.' }));
73
+ return;
74
+ }
75
+ res.writeHead(200, { ...j.headers, 'Content-Length': fs.statSync(j.path).size });
76
+ const stream = fs.createReadStream(j.path);
77
+ stream.on('error', () => res.destroy());
78
+ res.on('close', () => stream.destroy()); stream.pipe(res);
79
+ },
80
+ async close() {
81
+ closed = true; clearInterval(timer); queue.length = 0;
82
+ await active;
83
+ jobs.clear(); if (dir) removeRetry(dir);
84
+ },
85
+ };
86
+ }
@@ -1,4 +1,5 @@
1
1
  import { expandApiCollections } from '../api-collections.js';
2
+ import { removeRetry } from '../fs-retry.js';
2
3
  import {meetingSpec} from '../meeting.js';
3
4
  // Exportar a apresentação aberta ou da biblioteca: .sagadeck, PowerPoint, PDF, roteiro, material de estudo e "Baixar
4
5
  // tudo". PPTX/PDF/roteiro/estudo usam o Chrome invisível (os mesmos exportadores do CLI), numa pasta temporária.
@@ -21,14 +22,12 @@ export function sendHtml(res,spec,audience){
21
22
 
22
23
  // Gera e envia um arquivo da apresentação. PPTX/PDF/roteiro usam o Chrome invisível (os mesmos
23
24
  // exportadores de "sagadeck pptx | pdf | roteiro"), numa pasta temporária.
24
- export async function sendExport(res, kind, spec, name, { notes = true, inline = false, audience, macFonts = false } = {}) {
25
+ export async function generateExport(kind, spec, name, { notes = true, audience, macFonts = false, onProgress = () => {} } = {}) {
25
26
  spec=meetingSpec(spec,audience);
26
- const cd = (file) => `attachment; filename="${slugify(file.replace(/\.\w+$/, ""))}${path.extname(file)}"; filename*=UTF-8''${encodeURIComponent(file)}`;
27
+ onProgress({ phase: 'preparing', completed: 0, total: spec.slides?.length || 0 });
27
28
  if (kind === "sagadeck") {
28
29
  const { zip, missing } = await packDeck(spec, { baseDir: spec._dir || process.cwd(), name, generator: "sagadeck studio" });
29
- res.writeHead(200, { "Content-Type": MIME, "Content-Disposition": cd(name + EXTENSION), "X-Sagadeck-Missing": encodeURIComponent(JSON.stringify(missing)) });
30
- res.end(zip);
31
- return;
30
+ return { body: zip, file: name + EXTENSION, mime: MIME, missing, warnings: [] };
32
31
  }
33
32
  const kinds = {
34
33
  pptx: { file: `${name}.pptx`, mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation" },
@@ -41,6 +40,7 @@ export async function sendExport(res, kind, spec, name, { notes = true, inline =
41
40
  tudo: { file: `${name}.zip`, mime: "application/zip" },
42
41
  };
43
42
  const k = kinds[kind];
43
+ if (!k) throw new Error('Formato de exportação inválido.');
44
44
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `sagadeck-${kind}-`));
45
45
  try {
46
46
  if (["pptx","pdf","tudo"].includes(kind)) spec=expandApiCollections(spec);
@@ -49,27 +49,31 @@ export async function sendExport(res, kind, spec, name, { notes = true, inline =
49
49
  fs.writeFileSync(htmlFile, r.html);
50
50
  const errors = [];
51
51
  const make = async (what) => {
52
+ const progress = event => onProgress({ ...event, format: what });
53
+ progress({ phase: 'opening', completed: 0, total: r.slidesMeta.length });
52
54
  const out = path.join(tmp, `saida-${what}`);
53
55
  if (what === "pptx") {
54
56
  const { exportPptx } = await import("../export/pptx.js");
55
- errors.push(...(await exportPptx(htmlFile, out, { theme: r.theme, meta: { ...r.meta, slides: r.slidesMeta }, notes, fontFallback: macFonts ? "mac" : undefined })).errors);
57
+ errors.push(...(await exportPptx(htmlFile, out, { theme: r.theme, meta: { ...r.meta, slides: r.slidesMeta }, notes, fontFallback: macFonts ? "mac" : undefined, onProgress: progress })).errors);
56
58
  } else if (what === "pdf") {
57
59
  const { pdf } = await import("../export/shots.js");
58
60
  // Preferências › Exportação: tema escuro com par claro (manual-noite → manual) sai claro no PDF, bom para imprimir
59
61
  const light = loadPreferences().exportacao.pdfClaro === true ? lightVariant(spec) : null;
60
62
  let file = htmlFile;
61
63
  if (light) { file = path.join(tmp, "deck-claro.html"); fs.writeFileSync(file, buildHTML(light).html); }
62
- await pdf(file, out);
64
+ await pdf(file, out, { onProgress: progress });
63
65
  } else if (what === "estudo" || what === "estudo-html") {
64
66
  const { shots } = await import("../export/shots.js");
65
67
  const { estudoHTML, estudoPDF } = await import("../export/estudo.js");
66
- const { files } = await shots(htmlFile, path.join(tmp, "fotos"), { scale: what === "estudo" ? 0.6 : 0.75, jpeg: true });
68
+ const { files } = await shots(htmlFile, path.join(tmp, "fotos"), { scale: what === "estudo" ? 0.6 : 0.75, jpeg: true, onProgress: progress });
69
+ progress({ phase: 'packing', completed: files.length, total: files.length });
67
70
  const html = estudoHTML({ title: r.meta.title, author: r.meta.author, date: spec.date, slidesMeta: r.slidesMeta, shotFiles: files });
68
71
  if (what === "estudo-html") fs.writeFileSync(out, html); else await estudoPDF(html, out);
69
72
  } else {
70
73
  const { shots } = await import("../export/shots.js");
71
74
  const { roteiroPDF } = await import("../export/roteiro.js");
72
- const { files } = await shots(htmlFile, path.join(tmp, "miniaturas"), { scale: 0.5, jpeg: true });
75
+ const { files } = await shots(htmlFile, path.join(tmp, "miniaturas"), { scale: 0.5, jpeg: true, onProgress: progress });
76
+ progress({ phase: 'packing', completed: files.length, total: files.length });
73
77
  await roteiroPDF({ slidesMeta: r.slidesMeta, shotFiles: files, outFile: out, title: r.meta.title, author: r.meta.author, duration: spec.duration });
74
78
  }
75
79
  return fs.readFileSync(out);
@@ -78,20 +82,34 @@ export async function sendExport(res, kind, spec, name, { notes = true, inline =
78
82
  if (kind === "tudo") {
79
83
  const zip = new JSZip();
80
84
  for (const what of ["pptx", "pdf", "roteiro"]) zip.file(kinds[what].file, await make(what));
85
+ onProgress({ phase: 'packing', format: 'tudo', completed: r.slidesMeta.length, total: r.slidesMeta.length });
81
86
  body = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
82
87
  } else body = await make(kind);
83
- res.writeHead(200, {
84
- "Content-Type": k.mime, "Content-Disposition": inline ? "inline" : cd(k.file),
85
- // só o que afeta o arquivo (falha ao exportar, arquivo não encontrado); o fiscal de conteúdo fica no Revisar
86
- "X-Sagadeck-Warnings": encodeURIComponent(JSON.stringify([...(r.warnings || []).filter((w) => /não encontrado/.test(w)), ...errors].slice(0, 20))),
87
- });
88
- res.end(body);
88
+ // só o que afeta o arquivo; o fiscal de conteúdo fica no Revisar
89
+ return { body, file: k.file, mime: k.mime, warnings: [...(r.warnings || []).filter(w => /não encontrado/.test(w)), ...errors].slice(0, 20) };
90
+ } finally {
91
+ removeRetry(tmp);
92
+ }
93
+ }
94
+
95
+ export function exportHeaders(result, inline = false) {
96
+ const cd = file => `attachment; filename="${slugify(file.replace(/\.\w+$/, ''))}${path.extname(file)}"; filename*=UTF-8''${encodeURIComponent(file)}`;
97
+ return { 'Content-Type': result.mime, 'Content-Disposition': inline ? 'inline' : cd(result.file),
98
+ 'Cache-Control': 'private, no-store',
99
+ 'X-Sagadeck-Warnings': encodeURIComponent(JSON.stringify(result.warnings || [])),
100
+ ...(result.missing ? { 'X-Sagadeck-Missing': encodeURIComponent(JSON.stringify(result.missing)) } : {}) };
101
+ }
102
+
103
+ // Links de compartilhamento e CLI seguem recebendo o arquivo na própria resposta.
104
+ export async function sendExport(res, kind, spec, name, options = {}) {
105
+ try {
106
+ const result = await generateExport(kind, spec, name, options);
107
+ res.writeHead(200, exportHeaders(result, options.inline));
108
+ res.end(result.body);
89
109
  } catch (e) {
90
110
  console.error(`[Studio] exportação ${kind} falhou:`, e.message);
91
- res.writeHead(500, { "Content-Type": "application/json" });
111
+ res.writeHead(500, { 'Content-Type': 'application/json' });
92
112
  res.end(JSON.stringify({ error: e.message }));
93
- } finally {
94
- fs.rmSync(tmp, { recursive: true, force: true });
95
113
  }
96
114
  }
97
115
 
@@ -0,0 +1,144 @@
1
+ // Demos de um assunto só (mapas, programação, APIs com curl): curtas, para mostrar um recurso a quem vai usá-lo, sem
2
+ // passar pelo Conheça o SagaDeck inteiro. Reaproveitam os exemplos de layout (layout-samples.js) e as demos de mapa do
3
+ // tour; cada slide diz o que é e a frase para pedir à IA (no rodapé e nas notas). Abrem em prévia, como os modelos.
4
+ import { LAYOUT_INFO, LAYOUT_SAMPLES, LAYOUT_ASK } from "./layout-samples.js";
5
+ import { mapDemos } from "./tour.js";
6
+
7
+ export const FEATURE_DEMO_NAMES = { mapas: "Demo — mapas", programacao: "Demo — programação", apis: "Demo — APIs com curl" };
8
+
9
+ const asked = (ask) => `Peça à IA: “${ask}”`;
10
+ const told = (label, desc, ask) => ({ source: `**${label}**: ${desc}. ${asked(ask)}`, notes: [`${label}: ${desc}.`, asked(ask)].join("\n\n") });
11
+ // o exemplo do layout, com o nome do recurso e a frase para a IA; extra troca o conteúdo quando o exemplo é genérico
12
+ const sample = (name, extra = {}) => {
13
+ const [label, desc] = LAYOUT_INFO[name] || [name, ""];
14
+ return { ...structuredClone(LAYOUT_SAMPLES[name]), layout: name, ...told(label, desc, LAYOUT_ASK[name] || ""), ...extra };
15
+ };
16
+ const cover = (title, subtitle, figure) => ({ layout: "cover", kicker: "SAGADECK · DEMO", title, subtitle, figure });
17
+
18
+ // ---- Mapas: mostrar, calcular, editar
19
+ export function mapasDemo() {
20
+ const [bolhas, regioes, linhas, rota, raio, faixa, comIA, ondeEdita, telaMapa, noStudio] = mapDemos();
21
+ return {
22
+ title: FEATURE_DEMO_NAMES.mapas, theme: "oceano", duration: 15, maxWords: 140, slides: [
23
+ cover("Mapas ==ao vivo==", "Pontos, linhas e áreas sobre o mapa de verdade, com as contas feitas pelo código", { icon: "map", size: 320 }),
24
+ { layout: "statement", kicker: "a ideia", text: "A ==planilha== é a fonte; o mapa é a vista.",
25
+ notes: "Os pontos vêm de uma planilha do projeto (contexto/*.csv), as linhas e áreas de GeoJSON, GPX ou KML. A mesma planilha serve tabela e gráfico. Nada de mapa chapado em imagem: ele é ao vivo." },
26
+ { layout: "section", number: "01", kicker: "mostrar", title: "Dados no ==mapa==", subtitle: "pontos por categoria, bolhas, regiões por valor, linhas e áreas" },
27
+ sample("map"), bolhas, regioes, linhas,
28
+ { layout: "section", number: "02", kicker: "calcular", title: "As ==contas==", subtitle: "rota pelas ruas, alcance, raio e faixa: calculados, nunca desenhados à mão" },
29
+ rota, raio, faixa,
30
+ { layout: "section", number: "03", kicker: "editar", title: "Com a IA e no ==Studio==", subtitle: "a IA busca os dados; o Editar mapa mexe em cada ponto" },
31
+ comIA, ondeEdita, telaMapa, noStudio,
32
+ { layout: "end", title: "Agora, o seu ==mapa==.", subtitle: "Peça à IA: “Ponha no mapa os endereços da minha planilha”." },
33
+ ],
34
+ };
35
+ }
36
+
37
+ // ---- Programação: mostrar código, rodar ao vivo, Git a dois, explicar a arquitetura
38
+ const PEDIDOS = [
39
+ "def total_do_pedido(itens, cupom=None):",
40
+ " subtotal = sum(i.preco * i.qtd for i in itens)",
41
+ " if cupom and cupom.valido():",
42
+ " subtotal -= cupom.desconto(subtotal)",
43
+ " frete = 0 if subtotal >= 200 else 19.90",
44
+ " return round(subtotal + frete, 2)",
45
+ ].join("\n");
46
+ export function programacaoDemo() {
47
+ return {
48
+ title: FEATURE_DEMO_NAMES.programacao, theme: "musgo", duration: 15, maxWords: 140, slides: [
49
+ cover("Código no ==palco==", "Do trecho destacado ao algoritmo rodando, com a plateia acompanhando linha a linha", { motion: { type: "console", caption: "projeto · terminal", scenes: [
50
+ { cmd: "pytest -q", out: ["42 passed in 1.84s"] },
51
+ { cmd: "git log --oneline -3", out: ["a1f9c2e calcula o frete", "77b03d4 aplica o cupom", "3c0e1aa primeira versão"] },
52
+ { cmd: "python -m pedidos --demo", out: ["total: R$ 187,40", "frete: R$ 19,90"] },
53
+ ] } }),
54
+ { layout: "section", number: "01", kicker: "mostrar", title: "Código ==legível== de longe", subtitle: "destaque, linha a linha e material de consulta" },
55
+ sample("code", { title: "Destaque o que ==importa==", filename: "pedidos.py", language: "python", code: PEDIDOS, highlight: [3, 4],
56
+ note: "O cupom só vale se for válido: é aqui que o desconto entra." }),
57
+ sample("codewalk"),
58
+ sample("dossier"),
59
+ { layout: "section", number: "02", kicker: "rodar", title: "Executar na ==frente== de todos", subtitle: "a plateia muda o código e vê o resultado" },
60
+ sample("codelab"),
61
+ sample("algo"),
62
+ { layout: "section", number: "03", kicker: "colaborar", title: "Git e ==revisão==", subtitle: "duas pessoas, um clique por jogada" },
63
+ sample("terminals"),
64
+ sample("duel"),
65
+ sample("turns"),
66
+ { layout: "section", number: "04", kicker: "explicar", title: "A ==arquitetura==", subtitle: "fluxos, sequências e redes que dá para explorar" },
67
+ sample("diagram", { title: "Do pedido ao ==pagamento==", mermaid: "sequenceDiagram\n participant App\n participant API\n participant Pagamentos\n App->>API: POST /pedidos\n API->>Pagamentos: cobra o total\n Pagamentos-->>API: aprovado\n API-->>App: 201 Created" }),
68
+ sample("graphlab"),
69
+ { layout: "end", title: "Agora, o seu ==código==.", subtitle: "Peça à IA: “Explique este arquivo linha a linha, com a saída de cada passo”." },
70
+ ],
71
+ };
72
+ }
73
+
74
+ // ---- APIs com curl: anatomia de um curl, executar de verdade (no ambiente ENSAIO, a API de mentira que o Studio
75
+ // sobe sozinho), polling, streaming, as opções que mais ajudam e o cuidado com o token
76
+ const CURL = [
77
+ 'curl -X POST "https://api.exemplo.com/v1/pedidos" \\',
78
+ ' -H "Authorization: Bearer $API_TOKEN" \\',
79
+ ' -H "Content-Type: application/json" \\',
80
+ ` -d '{"item": "livro", "quantidade": 2}' \\`,
81
+ " -i",
82
+ ].join("\n");
83
+ const ensaio = "Roda no ambiente ENSAIO, a API de mentira que o Studio sobe sozinho: nada sai da sua máquina. Com o seu serviço, troque o endereço em Inserir › Ambientes.";
84
+ const apiSlide = (s) => {
85
+ const t = told("API ao vivo", "Requisição que executa na hora, com o curl equivalente na aba curl", LAYOUT_ASK.api || "Mostre esta chamada ao vivo com o curl");
86
+ return { layout: "api", tab: "curl", ...s, source: t.source, notes: [s.notes, ensaio, t.notes].filter(Boolean).join("\n\n") };
87
+ };
88
+ export function apisDemo() {
89
+ return {
90
+ title: FEATURE_DEMO_NAMES.apis, theme: "wine", duration: 15, maxWords: 140, slides: [
91
+ cover("APIs com ==curl==", "Uma chamada por slide, executada na frente de todos, com o curl pronto para copiar", { motion: { type: "console", caption: "curl · ao vivo", scenes: [
92
+ { cmd: `curl -X POST /v1/echo -d '{"mensagem":"Olá, API"}'`, out: ["200 OK", `{ "metodo": "POST", "recebido": { "mensagem": "Olá, API" } }`] },
93
+ { cmd: "curl /v1/echo?cidade=Recife", out: ["200 OK", `{ "consulta": { "cidade": "Recife" } }`] },
94
+ { cmd: "curl -N /v1/stream", out: ["200 OK · text/event-stream", "data: a resposta chega aos poucos..."] },
95
+ ] } }),
96
+ { layout: "statement", kicker: "a ideia", text: "O curl é a chamada de API ==mais curta== que existe.",
97
+ notes: "Todo slide de API do SagaDeck mostra o pedido, executa de verdade e traz o curl equivalente na aba curl (e também JavaScript e Python)." },
98
+ { layout: "codewalk", kicker: "anatomia", title: "Um curl em ==quatro partes==", filename: "pedido.sh", language: "bash", code: CURL,
99
+ steps: [
100
+ { title: "Verbo e endereço", text: "-X diz o que fazer (POST cria); a URL diz onde.", highlight: [1] },
101
+ { title: "Quem está pedindo", text: "O token vai no cabeçalho Authorization. Ele vem de uma variável: nunca escrito no comando.", highlight: [2] },
102
+ { title: "O que vai junto", text: "Content-Type avisa que o corpo é JSON; -d leva o corpo.", highlight: [3, 4] },
103
+ { title: "Ver a resposta inteira", text: "-i mostra o status e os cabeçalhos antes do corpo.", highlight: [5], output: "HTTP/1.1 201 Created\ncontent-type: application/json\n\n{ \"id\": \"ped-42\", \"status\": \"criado\" }" },
104
+ ], ...told("Código guiado", "Explique uma linha por vez, com destaques e saída simulada", "Explique este curl parte por parte") },
105
+ { layout: "section", number: "01", kicker: "executar", title: "Ao ==vivo==, de verdade", subtitle: "o Executar faz a chamada; a aba curl mostra o comando" },
106
+ apiSlide({ id: "post", kicker: "POST", title: "Mandar um ==corpo==", request: { method: "POST", url: "{{base}}/echo", body: { mensagem: "Olá, API", turma: "noite" } }, answer: "$.recebido",
107
+ notes: "Edite a mensagem na aba Corpo e execute de novo: a aba curl acompanha." }),
108
+ apiSlide({ id: "get", kicker: "GET", title: "Perguntar pela ==URL==", request: { method: "GET", url: "{{base}}/echo?cidade=Recife&dias=3" }, answer: "$.consulta",
109
+ notes: "GET não tem corpo: os parâmetros vão na URL, depois do ?." }),
110
+ apiSlide({ id: "token", kicker: "Token", title: "Com ==autorização==", request: { method: "POST", url: "{{base}}/sync", body: { q: "Qual é o status do pedido 42?" } }, answer: "$.choices[0].message.content",
111
+ notes: "O token sai do ambiente (o Studio pede e renova sozinho). No curl ele aparece como $API_TOKEN: o valor nunca vai para o slide." }),
112
+ { layout: "section", number: "02", kicker: "esperar", title: "Quando a resposta ==demora==", subtitle: "acompanhar até terminar, ou receber aos poucos" },
113
+ apiSlide({ id: "polling", kicker: "Polling", title: "Inicia e ==acompanha==", mode: "polling",
114
+ request: { url: "{{base}}/start", body: { workflow: "{{wf}}", input: { texto: "Resuma o contrato" } } },
115
+ polling: { id: "$.executionId", check: { url: "{{base}}/status/{{id}}" }, status: "$.status", done: ["FINISHED"], failed: ["ERROR"], interval: 0.8 },
116
+ steps: "$.responses", stepText: "$.output", notes: "O início devolve só o código da execução; a consulta repete até FINISHED. Na aba curl: o laço com sleep." }),
117
+ apiSlide({ id: "stream", kicker: "Streaming", title: "A resposta chega ==aos poucos==", mode: "stream", request: { method: "POST", url: "{{base}}/stream", body: { stream: true } },
118
+ notes: "No curl, -N desliga o buffer e o texto aparece enquanto chega." }),
119
+ { layout: "section", number: "03", kicker: "dia a dia", title: "As opções que ==salvam== o dia", subtitle: "clique em cada cartão para ver para que serve" },
120
+ // (oito cartões ocupam a área toda: aqui a frase para a IA fica só nas notas, sem a linha de rodapé)
121
+ { layout: "cards", title: "Clique para ==virar==", cols: 4, notes: told("Cartão que vira", "A frente mostra a opção; o verso explica, no clique", "Faça cartões que viram com a explicação no verso").notes, items: [
122
+ { icon: "file-text", title: "-i", text: "status e cabeçalhos", flip: "Mostra a linha de status e os cabeçalhos antes do corpo. O primeiro passo de toda investigação." },
123
+ { icon: "bug", title: "-v", text: "conversa inteira", flip: "Mostra tudo: o pedido que saiu, o certificado, o que voltou. Para quando nada faz sentido." },
124
+ { icon: "shield-alert", title: "--fail", text: "erro vira erro", flip: "Status 4xx ou 5xx faz o curl sair com erro. Essencial em script, que senão segue feliz." },
125
+ { icon: "corner-down-right", title: "-L", text: "segue o redirecionamento", flip: "Vai atrás do 301 e do 302 até o endereço final." },
126
+ { icon: "volume-x", title: "-s", text: "sem barra de progresso", flip: "Silencia o progresso; combine com -S para ainda ver os erros." },
127
+ { icon: "download", title: "-o arquivo", text: "grava a resposta", flip: "Salva o corpo num arquivo em vez de jogar na tela." },
128
+ { icon: "timer", title: "-w", text: "tempo e status", flip: "-w '%{http_code} %{time_total}s' imprime o status e quanto demorou." },
129
+ { icon: "braces", title: "| jq", text: "JSON legível", flip: "Mande a saída para o jq: indenta, colore e filtra campos, como jq '.itens[0]'." },
130
+ ] },
131
+ { layout: "statement", kicker: "cuidado", text: "O token ==nunca== vai para o slide.",
132
+ add: { aviso: { tipo: "perigo", titulo: "Segredo exposto", texto: "No deck, use {{secret.nome}}; no curl, $API_TOKEN. Os valores ficam nos Ambientes da sua máquina." } },
133
+ notes: "Os segredos ficam em ~/.sagadeck/ambientes.yaml (no Windows, cifrados para o seu usuário). O deck pode ir para outras pessoas sem vazar nada." },
134
+ { layout: "end", title: "Agora, a sua ==API==.", subtitle: "Peça à IA: “Monte um slide que chama o meu serviço e mostra o curl”." },
135
+ ],
136
+ };
137
+ }
138
+
139
+ export function featureDemo(kind) {
140
+ if (kind === "mapas") return mapasDemo();
141
+ if (kind === "programacao") return programacaoDemo();
142
+ if (kind === "apis") return apisDemo();
143
+ return null;
144
+ }
@@ -29,6 +29,15 @@ export async function iaRoutes({ req, res, pathname, opts, readJSON }) {
29
29
  json(res, 200, { config: maskedIA(loadIA()), file: iaFile(), source: cfg.source, configured: !!(cfg.url || cfg.adaptador), problem: cfg.problem || "", providers: PROVIDERS, roles: ROLES, recommended: RECOMMENDED, editable: !opts.multiuser });
30
30
  return true;
31
31
  }
32
+ // diagnóstico de todos os recursos (src/ai/ia-check.js), o mesmo do `sagadeck ia-check`: no multiusuário, só quem
33
+ // administra (diz quais modelos estão configurados)
34
+ if (pathname === "/api/ia/check" && req.method === "POST") {
35
+ if (opts.multiuser) { json(res, 403, { error: "No servidor multiusuário, o diagnóstico da IA é de quem administra: rode sagadeck ia-check no servidor." }); return true; }
36
+ const body = await readJSON(req);
37
+ const { iaCheck } = await import("../ai/ia-check.js");
38
+ json(res, 200, await iaCheck({ only: Array.isArray(body.so) ? body.so : null, rapido: !!body.rapido, videoReal: !!body.video_real }));
39
+ return true;
40
+ }
32
41
  if ((pathname === "/api/ia" || pathname === "/api/ia/test") && req.method === "POST") {
33
42
  if (opts.multiuser) { json(res, 403, { error: "No servidor multiusuário, a IA é configurada por quem administra." }); return true; }
34
43
  const body = await readJSON(req);
@@ -22,11 +22,10 @@ export const LAYOUT_INFO = {
22
22
  science: ["Fórmulas e funções", "Escreva a fórmula e ela vira curva; cada letra vira um controle deslizante. Aceita pontos colados do Excel e superfície 3D"],
23
23
  infographic: ["Infográfico", "Arco, ramos, lados, trilhas ou metrô: de 2 a 8 itens coloridos em volta de um centro"],
24
24
  diagram: ["Diagrama", "Fluxograma, sequência, estados, UML, jornada, mapa mental (Mermaid) nas cores do tema"],
25
- scenography: ["Texto no cenário", "13 composições (palco, terminal, café, embarque, pregão, cinema, revista…), sem animação"],
26
25
  cover: ["Capa", "Abertura com título, subtítulo e autor"],
27
26
  section: ["Seção", "Abre um novo ato ou capítulo"],
28
27
  statement: ["Frase de impacto", "Uma ideia em letras grandes"],
29
- calendar: ["Calendário", "Datas num calendário: um cartão por mês com o mini calendário e os eventos marcados; o que não tem data vai em \"sem data\""],
28
+ calendar: ["Calendário", "Datas num calendário: um cartão por mês, um mês grande de parede, a semana com horários ou a agenda em lista; com muitos eventos, um slide por mês"],
30
29
  poster: ["Pôster (infográfico)", "Uma página como figura de revista científica: ilustração principal e painéis com letra (a, b, c), desenho, texto curto e números"],
31
30
  definition: ["Definição", "O termo em destaque, a origem da palavra em partes e a definição, com um ícone ilustrativo"],
32
31
  headline: ["Manchete", "Uma frase enorme ocupando o slide"],
@@ -58,7 +57,6 @@ export const LAYOUT_INFO = {
58
57
  codewalk: ["Código guiado", "Explique uma linha por vez, com destaques e saída simulada"],
59
58
  api: ["API ao vivo", "Requisição que executa na hora (tipo Postman): síncrono, polling ou streaming, com código curl/Python"],
60
59
  spotlight: ["Foco guiado", "Conduza o olhar por detalhes de uma imagem ou diagrama"],
61
- kinetic: ["Tipografia cinética", "Frases em sequência viram parte da cena com ritmo e estilos tipográficos que mudam"],
62
60
  video: ["Vídeo", "Player YouTube online ou MP4 incorporado"],
63
61
  blocks: ["Livre (blocos)", "Monte com blocos em linhas e colunas"],
64
62
  canvas: ["Livre (posições)", "Posicione cada elemento à mão"],
@@ -87,7 +85,6 @@ export const LAYOUT_ASK = {
87
85
  science: "Mostre a parábola y = ax² + bx + c com controles para a, b e c",
88
86
  infographic: "Faça um infográfico com os cinco pilares da sustentabilidade em volta de um centro",
89
87
  diagram: "Desenhe o fluxo de atendimento de um pedido, da chegada à entrega",
90
- scenography: "Abra com a frase \"Além do óbvio\" num cenário de palco",
91
88
  cover: "Faça a capa da palestra sobre energia solar, com o meu nome e a data",
92
89
  section: "Abra a segunda parte, sobre os resultados",
93
90
  statement: "Uma frase de impacto: a água mais barata é a que não se desperdiça",
@@ -123,7 +120,6 @@ export const LAYOUT_ASK = {
123
120
  codewalk: "Explique este código uma linha por vez, com a saída simulada",
124
121
  api: "Faça uma chamada de API ao vivo que consulta o clima e mostra a resposta",
125
122
  spotlight: "Conduza o olhar pelas partes desta planta baixa, uma por vez",
126
- kinetic: "Faça a abertura com frases curtas aparecendo em ritmo, estilo tipografia cinética",
127
123
  video: "Ponha este vídeo do YouTube no slide",
128
124
  blocks: "Monte livremente um slide com título, dois textos e uma imagem em colunas",
129
125
  canvas: "Posicione o logotipo, o título e a foto onde eu indicar",
@@ -145,8 +141,8 @@ export const LAYOUT_SAMPLES = {
145
141
  ribbon: {layout:"ribbon",title:"Do primeiro passo ao resultado",items:[{title:"Explorar",text:"Encontre uma oportunidade."},{title:"Testar",text:"Experimente em pequena escala."},{title:"Evoluir",text:"Repita o que funciona."}]},
146
142
  dossier: {layout:"dossier",title:"Referência rápida",items:[{title:"Requisição",text:"GET consulta o recurso.",code:"GET /exemplos/42"},{title:"Resposta",text:"Confira o status antes de usar o corpo.",code:'{"id":42,"status":"ok"}'}]},
147
143
  decisionlab: {layout:"decisionlab", title:"A revisão melhora o resultado?", lab:{volume:10000,errorRate:2,reviewRate:100,catchRate:60,introducedRate:0.2,seconds:30}},
148
- infographic: { layout: "infographic", shape: "arco", title: "Os ==desafios== do projeto", center: { title: "Desafios" }, items: [{ title: "Acesso", text: "Pedido pela Bridge" }, { title: "Dados", text: "Mockados no experimento" }, { title: "Segurança", text: "Chaves no Key Vault" }, { title: "Infra", text: "Namespace e firewall" }] },
149
- diagram: { layout: "diagram", kicker: "Processo", title: "Da ideia à ==produção==", mermaid: "flowchart LR\n A([:lightbulb: Ideia]):::hi --> B{Vale testar?}\n B -->|sim| C[:flask-conical: Experimento]\n B -.->|não| X[Arquivar]:::vazado\n C ==> D[:rocket: Produção]:::em" },
144
+ infographic: { layout: "infographic", shape: "arco", title: "Os ==desafios== da viagem", center: { title: "Desafios" }, items: [{ title: "Roteiro", text: "Três cidades em dez dias" }, { title: "Orçamento", text: "Hospedagem pesa mais" }, { title: "Documentos", text: "Passaporte e visto" }, { title: "Clima", text: "Chuva na segunda semana" }] },
145
+ diagram: { layout: "diagram", kicker: "Processo", title: "Da ideia à ==produção==", mermaid: "flowchart LR\n A([:lightbulb: Ideia]):::hi --> B{Vale testar?}\n B -->|sim| C[:flask-conical: Protótipo]\n B -.->|não| X[Arquivar]:::vazado\n C ==> D[:rocket: Produção]:::em" },
150
146
  solution: {layout:"solution", title:"Exercício: quanto rende em 3 anos?", problem:"R$ 1.000 aplicados a 1% ao mês, com juros compostos. Quanto há depois de 36 meses?", givens:[{symbol:"C", value:"1000", unit:"R\\$", label:"capital"}, {symbol:"i", value:"0,01", label:"taxa ao mês"}, {symbol:"n", value:"36", label:"meses"}], find:"M", steps:[{text:"Juros compostos", latex:"M = C\\,(1 + i)^n"}, {text:"Substitui os dados", latex:"M = 1000 \\cdot 1{,}01^{36}"}, {text:"Calcula a potência", latex:"1{,}01^{36} \\approx 1{,}4308"}], answer:{latex:"M \\approx R\\$\\,1.430{,}77", text:"Rende cerca de 43% em 3 anos."}},
151
147
  calc: {layout:"calc", title:"Qual é o regime do escoamento?", inputs:{V:{label:"Velocidade", latex:"V", value:1.5, min:0.01, max:4, step:0.01, unit:"m/s"}, D:{label:"Diâmetro", latex:"D", value:0.05, min:0.005, max:0.3, step:0.005, unit:"m"}, nu:{label:"Viscosidade (água, 20 °C)", latex:"\\nu = 1{,}0\\times10^{-6}\\ \\mathrm{m^2/s}", value:0.000001, fixed:true}}, outputs:[{name:"Re", label:"Número de Reynolds", latex:"Re = \\frac{VD}{\\nu}", fn:"V*D/nu", decimals:0}, {name:"regime", label:"Regime", of:"Re", cases:[{below:2300, text:"Laminar", color:"s3"}, {below:4000, text:"Transição", color:"em"}, {text:"Turbulento", color:"hi"}], scale:{min:100, max:1000000, log:true}}]},
152
148
  algo: {layout:"algo", title:"Bubble sort passo a passo", algorithm:"bubble", array:[5, 1, 4, 2, 8, 3]},
@@ -157,15 +153,14 @@ export const LAYOUT_SAMPLES = {
157
153
  terminals: {layout:"terminals", title:"Dois terminais", file:"README.md", base:"# Projeto\nversão 1", people:["Ana","Beto"],
158
154
  turns:[{who:1, edit:{2:"versão 2"}, commit:"sobe a versão"}, {who:1, push:true}, {who:2, pull:true}, {who:2, log:true}]},
159
155
  turns: {layout:"turns", title:"Code review ao vivo", people:[{name:"Ana", role:"autora do PR"}, {name:"Beto", role:"revisor"}],
160
- turns:[{who:1, text:"Abri o PR com a validação do Pix."}, {who:2, text:"Por que não usa o **schema** que já existe?", tag:"pergunta"}, {who:1, text:"Boa. Troquei e testei assim:", method:"POST", url:"/pix", status:201, code:'{ "valor": 10 }', language:"json"}, {who:2, text:"Aprovado.", tag:"aprovado"}]},
156
+ turns:[{who:1, text:"Abri o PR com a validação das inscrições."}, {who:2, text:"Por que não usa o **schema** que já existe?", tag:"pergunta"}, {who:1, text:"Boa. Troquei e testei assim:", method:"POST", url:"/inscricoes", status:201, code:'{ "nome": "Ana" }', language:"json"}, {who:2, text:"Aprovado.", tag:"aprovado"}]},
161
157
  science: {layout:"science", title:"Mexa no a e no b", equations:[{label:"A função",latex:"f(x) = a\\sin(bx)"},{label:"A derivada",latex:"f'(x) = ab\\cos(bx)"}], plot:{functions:["a*sin(b*x)","a*b*cos(b*x)"], x:[-6.3,6.3], params:{a:{value:1.5,min:0,max:3,label:"amplitude"},b:{value:1,min:0.5,max:3,label:"frequência"}}}},
162
- scenography: {layout:"scenography", scene:"stage", title:"ALÉM DO\nÓBVIO", kicker:"UMA NOVA PERSPECTIVA", subtitle:"Ideias que ocupam espaço.", tone:"dark", deco:"none"},
163
158
  cover: { layout: "cover", kicker: "Evento 2026", title: "Título da ==apresentação==", subtitle: "Um subtítulo curto", author: "Seu nome", figure: { icon: "rocket", size: 320 } },
164
159
  section: { layout: "section", number: 2, kicker: "Parte dois", title: "O problema" },
165
160
  statement: { layout: "statement", kicker: "A ideia", text: "Uma frase que ==muda tudo==." },
166
161
  calendar: { layout: "calendar", kicker: "Agenda", title: "Próximos lançamentos", events: [
167
162
  { date: "2026-11-12", title: "Beta aberto", tag: "Produto", text: "Para os 500 primeiros" }, { date: "2026-11-28", title: "Evento de lançamento", tag: "Evento" },
168
- { date: "2027-01-15", title: "Versão 2.0", tag: "Produto", text: "Com o app de celular" }], undated: [{ title: "Integração com o banco", text: "Em negociação" }] },
163
+ { date: "2027-01-15", title: "Versão 2.0", tag: "Produto", text: "Com o app de celular" }], undated: [{ title: "Loja em outra cidade", text: "Em negociação" }] },
169
164
  poster: { layout: "poster", kicker: "Receita", title: "Pão de fermentação natural", subtitle: "Do fermento ao forno em quatro etapas", flow: true,
170
165
  panels: [
171
166
  { title: "Fermento", icon: "wheat", text: "Farinha e água, alimentados por 5 dias", facts: [{ value: "1:1", label: "farinha e água" }] },
@@ -176,9 +171,9 @@ export const LAYOUT_SAMPLES = {
176
171
  definition: { layout: "definition", kicker: "O que é", term: "Hidrologia", origin: "do grego", parts: [{ word: "hydor", meaning: "água" }, { word: "logos", meaning: "ciência" }], text: "É a ciência que estuda ==a água sobre a Terra==: sua quantidade, distribuição e circulação, e sua relação com o meio ambiente e com os seres vivos.", icon: "droplet" },
177
172
  headline: { layout: "headline", text: "78% ==desistem==", caption: "no segundo passo" },
178
173
  quote: { layout: "quote", quote: "Dados vencem opiniões.", by: "Alguém sábio" },
179
- onepage: { layout: "onepage", title: "Cadastro digital", journey: [{ icon: "user", title: "Cliente chega", text: "Fila de 40 min" }, { icon: "file-text", title: "Preenche papel" }, { icon: "check", title: "Conta aberta", text: "Até 5 dias" }], problem: { text: "Cadastro em papel atrasa a abertura de contas", numbers: [{ value: "5 dias", label: "para abrir uma conta" }] }, solution: { text: "Cadastro no tablet, com assinatura digital", items: ["Conta aberta no mesmo dia"] } },
180
- status: { layout: "status", kicker: "Semana 39", title: "Portal do cliente", health: "risco", progress: 65, done: ["Tela de login", "Integração com o cadastro"], doing: ["Recuperação de senha"], blocked: [{ text: "Liberação de firewall", owner: "Infra", due: "30/09" }], upcoming: ["Homologar com o negócio"] },
181
- hub: { layout: "hub", title: "Qual é o seu caso?", question: "Cada caminho tem o seu prazo", options: [{ icon: "flask-conical", title: "Experimento", meta: "30 dias", goto: "exp" }, { icon: "rocket", title: "Projeto", meta: "DEV, HOM e PROD", goto: "proj" }] },
174
+ onepage: { layout: "onepage", title: "Agendamento online", journey: [{ icon: "user", title: "Paciente liga", text: "Espera de 15 min" }, { icon: "file-text", title: "Preenche a ficha" }, { icon: "check", title: "Consulta marcada", text: "Até 5 dias" }], problem: { text: "Marcar por telefone atrasa as consultas", numbers: [{ value: "5 dias", label: "para marcar uma consulta" }] }, solution: { text: "Agendamento pelo celular, com confirmação automática", items: ["Consulta marcada no mesmo dia"] } },
175
+ status: { layout: "status", kicker: "Semana 39", title: "Portal do cliente", health: "risco", progress: 65, done: ["Tela de login", "Integração com o cadastro"], doing: ["Recuperação de senha"], blocked: [{ text: "Aprovação do texto de privacidade", owner: "Jurídico", due: "30/09" }], upcoming: ["Teste com 20 usuários"] },
176
+ hub: { layout: "hub", title: "Qual é o seu caso?", question: "Cada caminho tem o seu ritmo", options: [{ icon: "user", title: "Primeira vez", meta: "4 passos", goto: "inicio" }, { icon: "rocket", title: "Já uso", meta: "Atalhos", goto: "atalhos" }] },
182
177
  number: { layout: "number", value: 42, suffix: "%", label: "mais rápido" },
183
178
  split: { layout: "split", title: "Texto e figura", bullets: ["Primeiro ponto", "Segundo ponto"], figure: { icon: "lightbulb", size: 300 } },
184
179
  full: { layout: "full", figure: { chart: "donut", value: 72, center: "72%" }, title: "Figura em página inteira", overlay: "left" },
@@ -209,11 +204,7 @@ export const LAYOUT_SAMPLES = {
209
204
  hotspots: [{ x: 4, y: 18, width: 92, height: 15, title: "O pedido", text: "O verbo GET consulta o recurso. A rota identifica a coleção de aulas." },
210
205
  { x: 4, y: 38, width: 92, height: 10, title: "A confirmação", text: "200 OK confirma o sucesso. O tempo ajuda a investigar a experiência do usuário." },
211
206
  { x: 5, y: 52, width: 78, height: 39, title: "Os dados", text: "A estrutura JSON é o contrato que o front-end precisa entender." }] },
212
- kinetic: { layout: "kinetic", figure: { svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080"><defs><linearGradient id="stage" x2="0" y2="1"><stop stop-color="#697a78"/><stop offset="1" stop-color="#26353a"/></linearGradient><linearGradient id="window" x2="0" y2="1"><stop stop-color="#e1cba1"/><stop offset="1" stop-color="#8d9990"/></linearGradient></defs><path fill="url(#stage)" d="M0 0h1920v1080H0z"/><path fill="#202b2e" d="M0 0h430v1080H0zM1490 0h430v1080h-430z"/><path fill="url(#window)" d="M500 90h910v660H500z"/><path fill="#d0b88e" opacity=".4" d="M500 560h910v190H500z"/><path fill="#293432" d="M0 850h1920v230H0z"/><path fill="#394342" d="M720 814h480v36H720z"/><path fill="#26302f" d="M836 555c28-120 205-154 266-14l76 263H760z"/><circle cx="960" cy="465" r="74" fill="#39403b"/></svg>' }, autoplay: true, interval: 1800,
213
- beats: [{ text: "Uma ideia", style: "clean", position: "left", color: "white", size: "medium", tag: "GANHA FORMA" },
214
- { text: "muda o olhar", style: "clean", position: "right", color: "gold", size: "medium" },
215
- { text: "e abre caminhos", style: "clean", position: "left", color: "white", size: "medium" }] },
216
- // genérico de propósito: troque método, URL e corpo pelos do seu serviço (os exemplos prontos ficam no deck "Exemplo: aula de APIs")
207
+ // genérico de propósito: troque método, URL e corpo pelos do seu serviço (os exemplos prontos ficam na demo "APIs com curl" e no `sagadeck ensaio-api`)
217
208
  api: { layout: "api", kicker: "Ao vivo", title: "Minha ==chamada==", text: "Troque o método, a URL e o corpo pelos do seu serviço", request: { method: "POST", url: "{{base}}/echo", body: { mensagem: "Olá, API" } }, answer: "$.recebido.mensagem" },
218
209
  video: { layout: "video", title: "Assista", url: "https://example.com", label: "Assistir" },
219
210
  blocks: { layout: "blocks", title: "Blocos livres", content: [{ row: [{ lead: "Esquerda" }, { lead: "Direita" }] }] },
@@ -103,15 +103,6 @@ export async function libraryRoutes({ req, res, pathname, url, W, opts, readJSON
103
103
  if (!made) throw new Error("Não há um modelo em prévia aberto.");
104
104
  return ok(made);
105
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
106
  case "/api/library/decks/example": {
116
107
  // exemplo de slides api (aula de APIs de IA): roda no ambiente embutido "ensaio", sem configurar nada
117
108
  const tpl = templateFile("ensaio-api.yaml");
@@ -226,6 +226,7 @@ export function writeSheet(P, rel, rows) {
226
226
  if (!Array.isArray(rows) || rows.some((r) => !Array.isArray(r))) throw new Error("Planilha inválida.");
227
227
  const old = fs.existsSync(abs) ? parseCSV(fs.readFileSync(abs, "utf8"), ext === "tsv" ? "\t" : undefined) : { delimiter: ext === "tsv" ? "\t" : ",", eol: "\n" };
228
228
  const tmp = abs + ".tmp";
229
+ fs.mkdirSync(path.dirname(abs), { recursive: true }); // planilha nova numa pasta que ainda não existe (contexto/)
229
230
  const clean = rows.map((r) => r.map((c) => String(c ?? "")));
230
231
  while (clean.length > 1 && clean.at(-1).every((c) => c === "")) clean.pop(); // linha vazia no fim (o cursor passou dela) não vai para o arquivo
231
232
  fs.writeFileSync(tmp, toCSV(clean, old));
@@ -8,8 +8,8 @@ window.SagaAISettings = (() => {
8
8
  .ai-cfg .row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px;flex-wrap:wrap}.ai-cfg button{padding:7px 14px;border-radius:8px;border:1px solid var(--border,#c9ced6);background:transparent;color:inherit;cursor:pointer;font:inherit}
9
9
  .ai-cfg .rec{background:var(--selected,#eef);border-radius:8px;padding:8px 10px;margin:6px 0 4px}.ai-cfg .rec button{margin-top:6px;display:block}
10
10
  .ai-cfg button.primary{background:var(--brand,#5b4bdb);border-color:var(--brand,#5b4bdb);color:#fff}.ai-cfg [data-status]{margin-top:10px;min-height:1.4em}.ai-cfg [data-status].ok{color:#15803d}.ai-cfg [data-status].err{color:#b91c1c}`;
11
- const LABEL = { text: "Modelo de texto", vision: "Modelo que vê imagens", image: "Modelo de imagem", search: "Modelo de busca na web" };
12
- const HINT = { text: "obrigatório", vision: "opcional; vazio = o de texto", image: "opcional; para gerar ilustrações", search: "opcional; no OpenRouter, um modelo :online" };
11
+ const LABEL = { text: "Modelo de texto", vision: "Modelo que vê imagens", image: "Modelo de imagem", search: "Modelo de busca na web", video: "Modelo de vídeo" };
12
+ const HINT = { text: "obrigatório", vision: "opcional; vazio = o de texto", image: "opcional; para gerar ilustrações", search: "opcional; no OpenRouter, um modelo :online", video: "opcional; para gerar clipes (cobrado por segundo)" };
13
13
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
14
14
  let dialog, info, onSaved = () => {};
15
15