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
@@ -106,6 +106,50 @@ function textOfProduced(slides) {
106
106
  slides.forEach((s) => walk(s));
107
107
  return acc.join("\n");
108
108
  }
109
+ // Slides repetidos (o mesmo conteúdo escrito por dois itens do plano): assinatura normalizada de
110
+ // conteúdo + mídias. Só vale para slide produzido sem decisão pendente (sem review e sem original);
111
+ // par original/proposta e capa repetida de propósito ficam intactos.
112
+ function slideSignature(s) {
113
+ const media = [];
114
+ const walk = (v) => {
115
+ if (Array.isArray(v)) return v.forEach(walk);
116
+ if (v && typeof v === "object") {
117
+ for (const [k, vv] of Object.entries(v)) {
118
+ if ((k === "image" || k === "image_prompt" || k === "image_ref" || k === "video") && typeof vv === "string" && vv) media.push(`${k}=${vv}`);
119
+ else walk(vv);
120
+ }
121
+ }
122
+ };
123
+ walk(s);
124
+ const text = textOfProduced([s]).toLowerCase().replace(/\s+/g, " ").trim();
125
+ return JSON.stringify({ layout: s.layout || "", title: (s.title || "").toLowerCase().trim(), text, media: [...new Set(media)].sort() });
126
+ }
127
+ export function dedupeSlides(slides) {
128
+ const seen = new Map(), out = [], dropped = [];
129
+ slides.forEach((s, i) => {
130
+ if (s?.review || s?.original) { out.push(s); return; }
131
+ const sig = slideSignature(s);
132
+ if (seen.has(sig)) { dropped.push({ index: i, title: s.title || "" }); return; }
133
+ seen.set(sig, true);
134
+ out.push(s);
135
+ });
136
+ return { slides: out, dropped };
137
+ }
138
+ export function hasImagePrompt(s) {
139
+ let found = false;
140
+ const walk = (v) => {
141
+ if (found) return;
142
+ if (Array.isArray(v)) return v.forEach(walk);
143
+ if (v && typeof v === "object") {
144
+ for (const [k, vv] of Object.entries(v)) {
145
+ if (k === "image_prompt" && typeof vv === "string" && vv.trim()) { found = true; return; }
146
+ walk(vv);
147
+ }
148
+ }
149
+ };
150
+ walk(s);
151
+ return found;
152
+ }
109
153
  export function missingFacts(facts, slides) {
110
154
  const text = textOfProduced(slides);
111
155
  const nums = new Set((text.match(/\d+(?:[.,]\d+)*/g) || []).flatMap(numVals));
@@ -559,6 +603,10 @@ Todos os slides de 1 a ${originals.length} precisam aparecer em algum "de". Mant
559
603
  // dica, não erro: palavra certa também pode ser a do original com uma letra a menos ("estão" de "gestão")
560
604
  if (typos.length) hints.push(`ITEM ${k + 1}: confira a grafia (letra faltando ou sem acento?): ${typos.slice(0, 15).join(", ")}`);
561
605
  }
606
+ if (/imagem|foto|ilustra|realista|desenho|maquete|3d/i.test(request || job.request || "") && !produced.some(hasImagePrompt)) {
607
+ // dica, não erro: ilustrar de menos não carimba revisão, mas entra na correção quando há outra rodada
608
+ hints.push(`ITENS ${batch.map(({ k }) => k + 1).join(", ")}: o pedido pede imagens/ilustração e nenhum slide trouxe image_prompt — prefira ilustrar capa e conceitos onde a imagem explica melhor que ícone ou diagrama, com moderação`);
609
+ }
562
610
  for (const { j, img, f } of equationImages(produced)) {
563
611
  issues.push({ k: (produced[j]?.origem || batch[0].k + 1) - 1, kind: "desenho", text: `slide novo ${j + 1} (item ${produced[j]?.origem}): a imagem ${img} é uma EQUAÇÃO${f.dados ? ` (${clip(f.dados, 200)})` : ""}, não uma figura: escreva a fórmula em LaTeX ($…$ no texto, ou equations/latex) e tire a imagem; se o slide precisava de outra figura, use o arquivo certo` });
564
612
  }
@@ -677,7 +725,8 @@ Todos os slides de 1 a ${originals.length} precisam aparecer em algum "de". Mant
677
725
  return rv ? { ...s, review: rv } : s;
678
726
  }));
679
727
  });
680
- const out = { ...deckBase, ...(mode === "melhorar" ? { import: spec.import, style: { name: style.name } } : { recreatedFrom: spec.import?.from || spec.title }), slides };
728
+ const dedup = dedupeSlides(slides);
729
+ const out = { ...deckBase, ...(mode === "melhorar" ? { import: spec.import, style: { name: style.name } } : { recreatedFrom: spec.import?.from || spec.title }), slides: dedup.slides };
681
730
  ensureUids(out);
682
731
  // mapa de cobertura: cada trecho do original e para onde foi
683
732
  const itemOfSlide = new Map();
@@ -701,7 +750,7 @@ Todos os slides de 1 a ${originals.length} precisam aparecer em algum "de". Mant
701
750
  const status = stop || missingItems ? "parcial" : report.pendentes.length || report.revisar.length ? "revisar" : "concluido";
702
751
  job.status = status; job.stage = "montado"; save();
703
752
  return {
704
- spec: out, plan,
753
+ spec: out, plan, duplicados: dedup.dropped.length,
705
754
  report: { ...report, status, parou: stop ? stop.message : missingItems ? `${missingItems} item(ns) não saíram` : null, retomada: resumed, mode, textModel: T, visionModel: V, calls: job.calls, usage: job.usage, seconds: Math.round(job.spentMs / 1000), alertas: plan.alertas || [], cobertura: { trechos: coverage.length, localizados: literal, reescritos: coverage.length - literal, arquivo: `original/cobertura-${mode}.md` } },
706
755
  };
707
756
  }
package/src/diff.js ADDED
@@ -0,0 +1,44 @@
1
+ // diff de decks por uid (cai para o índice quando não há uid): o que entrou,
2
+ // saiu, mudou de conteúdo e mudou de lugar. Base para `sagadeck diff`.
3
+ export function slideKey(s, i) {
4
+ return s && s.uid ? String(s.uid) : `#${i}`;
5
+ }
6
+
7
+ function changedFields(a, b) {
8
+ const out = [];
9
+ for (const k of new Set([...Object.keys(a || {}), ...Object.keys(b || {})])) {
10
+ if (k === "uid" || k === "auto") continue;
11
+ if (JSON.stringify(a?.[k]) !== JSON.stringify(b?.[k])) out.push(k);
12
+ }
13
+ return out;
14
+ }
15
+
16
+ export function diffDecks(a = {}, b = {}) {
17
+ const A = Array.isArray(a.slides) ? a.slides : [];
18
+ const B = Array.isArray(b.slides) ? b.slides : [];
19
+ const am = new Map(), bm = new Map();
20
+ A.forEach((s, i) => am.set(slideKey(s, i), { s, i }));
21
+ B.forEach((s, i) => bm.set(slideKey(s, i), { s, i }));
22
+ const added = [], removed = [], changed = [], moved = [];
23
+ for (const [k, { s, i }] of am) {
24
+ const o = bm.get(k);
25
+ if (!o) { removed.push({ index: i, title: s?.title || "" }); continue; }
26
+ if (o.i !== i) moved.push({ key: k, title: o.s?.title || s?.title || "", from: i, to: o.i });
27
+ const fields = changedFields(s, o.s);
28
+ if (fields.length) changed.push({ index: o.i, key: k, title: o.s?.title || s?.title || "", fields });
29
+ }
30
+ for (const [k, { s, i }] of bm) {
31
+ if (!am.has(k)) added.push({ index: i, title: s?.title || "" });
32
+ }
33
+ return { added, removed, changed, moved, same: !added.length && !removed.length && !changed.length && !moved.length };
34
+ }
35
+
36
+ export function formatDiff(d, { aName = "a", bName = "b" } = {}) {
37
+ if (d.same) return "iguais: nenhum slide entrou, saiu, mudou ou andou.";
38
+ const L = [];
39
+ for (const r of d.removed) L.push(`- slide ${r.index + 1}${r.title ? ` "${r.title}"` : ""} (só em ${aName})`);
40
+ for (const a of d.added) L.push(`+ slide ${a.index + 1}${a.title ? ` "${a.title}"` : ""} (só em ${bName})`);
41
+ for (const m of d.moved) L.push(`> "${m.title || m.key}" andou: ${m.from + 1} → ${m.to + 1}`);
42
+ for (const c of d.changed) L.push(`~ slide ${c.index + 1}${c.title ? ` "${c.title}"` : ""}: ${c.fields.join(", ")}`);
43
+ return L.join("\n");
44
+ }
@@ -10,7 +10,7 @@ import fs from "node:fs";
10
10
  import PptxGenJS from "pptxgenjs";
11
11
  import JSZip from "jszip";
12
12
  import { openDeck } from "./browser.js";
13
- import { pptxFontMap } from "../themes.js";
13
+ import { pptxFontMap, macFallbackFace } from "../themes.js";
14
14
  import { notesPlain } from "../markup.js";
15
15
 
16
16
  const PX = 1 / 144; // 1920 px = 13,333 in
@@ -247,11 +247,12 @@ const VARIABLE = {
247
247
  },
248
248
  };
249
249
  const near = (arr, v) => arr.reduce((a, b) => (Math.abs(b - v) < Math.abs(a - v) ? b : a));
250
- function fontFor(run, fmap, aliases = {}) {
250
+ function fontFor(run, fmap, aliases = {}, fallback) {
251
251
  const fam = aliases[run.family] || run.family;
252
252
  if (VARIABLE[fam]) {
253
253
  const V = VARIABLE[fam];
254
254
  const r = V.name(near(V.weights, run.weight), near(V.widths, run.stretch || 100));
255
+ if (fallback === "mac") return { face: macFallbackFace(r.face), bold: r.bold, italic: run.italic };
255
256
  return { face: r.face, bold: r.bold, italic: run.italic };
256
257
  }
257
258
  const m = fmap[run.role];
@@ -261,6 +262,7 @@ function fontFor(run, fmap, aliases = {}) {
261
262
  const f = extraBold ? m.bold : m.regular;
262
263
  face = f.face; bold = !!f.bold; italic = !!f.italic || (run.italic && !run.baseItalic);
263
264
  }
265
+ if (fallback === "mac") face = macFallbackFace(face);
264
266
  return { face, bold, italic };
265
267
  }
266
268
 
@@ -325,7 +327,7 @@ function materializePseudos() {
325
327
  }
326
328
 
327
329
  // notes: false tira as notas do apresentador (mandar a apresentação para alguém sem a "cola")
328
- export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts = false, notes = true, log = () => {} } = {}) {
330
+ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts = false, notes = true, fontFallback, log = () => {} } = {}) {
329
331
  const notesOpt = notes !== false;
330
332
  const { browser, page, errors } = await openDeck(htmlFile, { scale: 2 });
331
333
  try {
@@ -336,7 +338,7 @@ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts
336
338
  html.solo *{visibility:hidden!important}
337
339
  html.solo .solo-t,html.solo .solo-t *{visibility:visible!important}
338
340
  html.solo .solo-t.solo-bg *{visibility:hidden!important}` });
339
- const fmap = pptxFontMap(theme);
341
+ const fmap = pptxFontMap(theme, { fallback: fontFallback });
340
342
  // apelidos CSS (ex.: SagaDIN -> Bahnschrift) declarados em fontFaces do tema
341
343
  const aliases = Object.fromEntries((theme.fontFaces || []).map((f) => [f.family, (f.src.match(/local\(['"]?([^'")]+)/) || [])[1]]).filter((x) => x[1]));
342
344
  const pres = new PptxGenJS();
@@ -371,7 +373,7 @@ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts
371
373
  } else if (it.kind === "text") {
372
374
  const runs = [];
373
375
  it.paras.forEach((p, pi) => p.forEach((r, ri) => {
374
- const f = fontFor(r, fmap, aliases);
376
+ const f = fontFor(r, fmap, aliases, fontFallback);
375
377
  const o = { fontFace: f.face, fontSize: +(r.size * PT).toFixed(2), bold: f.bold, italic: f.italic, color: r.color };
376
378
  const tr = Math.round((1 - r.alpha * alpha) * 100); if (tr > 0) o.transparency = tr;
377
379
  if (r.spacing) o.charSpacing = +(r.spacing * PT).toFixed(2);
@@ -411,7 +413,7 @@ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts
411
413
  anims.push({ name, step: it.step, exit: it.exit, isSp: false });
412
414
  } else if (it.kind === "table") {
413
415
  const rows = it.rows.map((r) => r.map((c) => {
414
- const f = fontFor(c.run, fmap, aliases);
416
+ const f = fontFor(c.run, fmap, aliases, fontFallback);
415
417
  const o = { fontFace: f.face, bold: f.bold, italic: f.italic, fontSize: +(c.size * PT).toFixed(2), color: c.color, align: c.align, valign: "middle", margin: c.pad.map((v) => +(v * PT).toFixed(2)) };
416
418
  o.fill = c.fill ? { color: c.fill, transparency: Math.round((1 - c.fillA * alpha) * 100) } : { type: "none" };
417
419
  const none = { type: "none" };
@@ -34,11 +34,13 @@ const grouped = (o) => !Array.isArray(o.data) && Array.isArray(o.labels) && Arra
34
34
  const axisTitles = (o, W, H, left = 0) => `${o.xLabel ? `<text x="${left + (W - left) / 2}" y="${H - 6}" text-anchor="middle" class="ch-axis f-label" font-size="22" style="fill:var(--muted)">${esc(o.xLabel)}</text>` : ""}${o.yLabel ? `<text x="0" y="0" transform="translate(22 ${H / 2}) rotate(-90)" text-anchor="middle" class="ch-axis f-label" font-size="22" style="fill:var(--muted)">${esc(o.yLabel)}</text>` : ""}`;
35
35
  const seriesColor = (s, i) => (s.color ? cvar(s.color) : `var(--s${(i % 5) + 1})`);
36
36
  function legendSVG(series, W, y = 30) {
37
+ // f-label renderiza em maiúsculas espaçadas em todo tema (ver faces em themes.js):
38
+ // medir sempre com EM.caps, senão o texto estoura por baixo do quadrado vizinho
37
39
  let x = W, g = "";
38
40
  [...series].reverse().forEach((s, ri) => {
39
41
  const i = series.length - 1 - ri, name = String(s.name || `Série ${i + 1}`);
40
- x -= textW(name, 24) + 60;
41
- g += `<rect x="${x}" y="${y - 20}" width="24" height="24" rx="5" style="fill:${seriesColor(s, i)}"/><text x="${x + 34}" y="${y}" class="f-label" font-size="24" style="fill:var(--muted)">${esc(name)}</text>`;
42
+ x -= textW(name, 24, EM.caps) + 60;
43
+ g += `<rect class="ch-leg" x="${x}" y="${y - 20}" width="24" height="24" rx="5" style="fill:${seriesColor(s, i)}"/><text class="ch-leg" x="${x + 34}" y="${y}" class="f-label" font-size="24" style="fill:var(--muted)">${esc(name)}</text>`;
42
44
  });
43
45
  return g;
44
46
  }
@@ -1,7 +1,7 @@
1
1
  // Diagramas gerados em SVG.
2
2
  // { diagram: loop, nodes: [dados, modelo, decisão, ação], actor: in|on|out|none, at: 3 }
3
3
  // { diagram: spectrum, stops: [HITL, HOTL, HOOTL], at: 1, left: "humano decide", right: "máquina decide" }
4
- // { diagram: flow, steps: [a, b, c], highlight: 1 }
4
+ // { diagram: flow, steps: [a, b, c], highlight: 1, cols: 5 } (fila longa desce em serpentina)
5
5
  // { diagram: venn, a: "…", b: "…", both: "…" }
6
6
  import { esc } from "../markup.js";
7
7
  import { humanBody } from "./pictos.js";
@@ -84,18 +84,46 @@ export function spectrum(o = {}) {
84
84
 
85
85
  export function flow(o = {}) {
86
86
  const steps = o.steps || [];
87
- const n = steps.length, bw = 300, gap = 90, bh = 150;
88
- const W = n * bw + (n - 1) * gap;
87
+ const n = steps.length, bw = 300, gap = 90, bh = 150, vgap = 130;
88
+ const perRow = Math.max(1, o.cols || 5);
89
+ const rows = Math.max(1, Math.ceil(n / perRow));
89
90
  const id = `ar${++uid}`;
91
+ const box = (x, y, s, hl) =>
92
+ `<rect x="${x}" y="${y}" width="${bw}" height="${bh}" rx="18" style="fill:var(${hl ? "--hi" : "--surface"});stroke:var(--fg)" stroke-width="${hl ? 0 : 4}"/>` +
93
+ `<foreignObject x="${x + 16}" y="${y + 10}" width="${bw - 32}" height="${bh - 20}"><div xmlns="http://www.w3.org/1999/xhtml" class="f-heading" style="height:100%;display:flex;align-items:center;justify-content:center;text-align:center;font-size:36px;color:var(${hl ? "--on-hi" : "--fg"})">${esc(s)}</div></foreignObject>`;
94
+ const arrow = (x1, y1, x2, y2) =>
95
+ `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" style="stroke:var(--fg)" stroke-width="8" marker-end="url(#${id})"/>`;
90
96
  let g = arrowDefs(id);
97
+ if (rows === 1) {
98
+ const W = n * bw + (n - 1) * gap;
99
+ steps.forEach((s, i) => {
100
+ const x = i * (bw + gap);
101
+ g += box(x, 0, s, o.highlight === i);
102
+ if (i < n - 1) g += arrow(x + bw + 12, bh / 2, x + bw + gap - 20, bh / 2);
103
+ });
104
+ return wrap([-4, -4, W + 8, bh + 8], g, "flow");
105
+ }
106
+ // fila longa (10 a 12 caixas): serpentina em fileiras, ida e volta — a letra continua legível
107
+ const W = perRow * bw + (perRow - 1) * gap;
108
+ const at = (i) => {
109
+ const r = Math.floor(i / perRow), k = i % perRow;
110
+ const y = r * (bh + vgap);
111
+ return { x: r % 2 === 0 ? k * (bw + gap) : W - bw - k * (bw + gap), y };
112
+ };
91
113
  steps.forEach((s, i) => {
92
- const x = i * (bw + gap);
93
- const hl = o.highlight === i;
94
- g += `<rect x="${x}" y="0" width="${bw}" height="${bh}" rx="18" style="fill:var(${hl ? "--hi" : "--surface"});stroke:var(--fg)" stroke-width="${hl ? 0 : 4}"/>`;
95
- g += `<foreignObject x="${x + 16}" y="10" width="${bw - 32}" height="${bh - 20}"><div xmlns="http://www.w3.org/1999/xhtml" class="f-heading" style="height:100%;display:flex;align-items:center;justify-content:center;text-align:center;font-size:36px;color:var(${hl ? "--on-hi" : "--fg"})">${esc(s)}</div></foreignObject>`;
96
- if (i < n - 1) g += `<line x1="${x + bw + 12}" y1="${bh / 2}" x2="${x + bw + gap - 20}" y2="${bh / 2}" style="stroke:var(--fg)" stroke-width="8" marker-end="url(#${id})"/>`;
114
+ const { x, y } = at(i);
115
+ g += box(x, y, s, o.highlight === i);
116
+ if (i >= n - 1) return;
117
+ const a = at(i), b = at(i + 1);
118
+ if (a.y === b.y) {
119
+ const dir = b.x > a.x ? 1 : -1;
120
+ g += arrow(a.x + (dir > 0 ? bw + 12 : -12), a.y + bh / 2, b.x + (dir > 0 ? -20 : bw + 20), b.y + bh / 2);
121
+ } else {
122
+ const cx1 = a.x + bw / 2, cx2 = b.x + bw / 2, my = a.y + bh + vgap / 2;
123
+ g += `<path d="M${cx1} ${a.y + bh} V${my} H${cx2} V${b.y}" fill="none" style="stroke:var(--fg)" stroke-width="8" marker-end="url(#${id})"/>`;
124
+ }
97
125
  });
98
- return wrap([-4, -4, W + 8, bh + 8], g, "flow");
126
+ return wrap([-4, -4, W + 8, rows * bh + (rows - 1) * vgap + 8], g, "flow");
99
127
  }
100
128
 
101
129
  export function venn(o = {}) {
@@ -360,7 +360,7 @@ export function autofixDeck(spec, issuesReport = []) {
360
360
  };
361
361
  }
362
362
 
363
- function countSlideWords(s) {
363
+ export function countSlideWords(s) {
364
364
  const txt = [];
365
365
  const walk = (v, k) => {
366
366
  if (k === "notes" || k === "source" || k === "id" || k === "layout" || k === "tone") return;
@@ -0,0 +1,22 @@
1
+ // enxugar: relatório de palavras por slide contra o limite do material
2
+ // (a mesma conta e o mesmo limite do fiscal anti-sono do autofix).
3
+ // Base para `sagadeck enxugar deck.yaml [--fix]`.
4
+ import { countSlideWords } from "./autofix.js";
5
+ import { wordLimit } from "../purpose.js";
6
+
7
+ export function enxugarReport(spec = {}) {
8
+ const slides = Array.isArray(spec.slides) ? spec.slides : [];
9
+ return slides.map((s, i) => {
10
+ const words = countSlideWords(s || {});
11
+ const limit = wordLimit(s || {}, spec);
12
+ return { index: i, layout: s?.layout || "blocks", title: s?.title || "", words, limit, over: Math.max(0, words - limit) };
13
+ });
14
+ }
15
+
16
+ export function formatEnxugar(rows) {
17
+ const over = rows.filter((r) => r.over > 0);
18
+ if (!over.length) return `ok: ${rows.length} slide(s), todos dentro do limite.`;
19
+ return [`${over.length} de ${rows.length} slide(s) acima do limite:`,
20
+ ...over.map((r) => ` slide ${r.index + 1}${r.title ? ` "${r.title}"` : ""} [${r.layout}]: ${r.words} palavras (limite ${r.limit}, +${r.over})`),
21
+ ].join("\n");
22
+ }
package/src/library.js CHANGED
@@ -249,9 +249,80 @@ export function openLibrary(root) {
249
249
  return idOf(yaml);
250
250
  }
251
251
 
252
+ // ---- geração em andamento (invisível): a IA trabalha numa pasta oculta do tópico e a apresentação
253
+ // só aparece na biblioteca quando pronta (publicada com o título final, num renomear atômico). Nada a meio
254
+ // fazer fica visível — nem pode ser apagado sem querer no meio da geração. Interrompida: vai para a lixeira
255
+ // (recuperável, como qualquer exclusão), nunca apagada direto; órfãs vão para a lixeira na subida do Studio.
256
+ const STAGING = ".gerando";
257
+ function stagingRoot(topic) {
258
+ const base = topicDir(topic || ensureTopic("Sem tópico"));
259
+ const stage = path.join(base, STAGING);
260
+ fs.mkdirSync(stage, { recursive: true });
261
+ return { base, stage };
262
+ }
263
+ function createStagingDeck(topic) {
264
+ const { stage } = stagingRoot(topic);
265
+ const dir = uniquePath(stage, String(Date.now()));
266
+ fs.mkdirSync(dir, { recursive: true });
267
+ const file = path.join(dir, "deck.yaml");
268
+ writeDeckFile(file, { title: "Gerando…", slides: [{ layout: "cover", title: "Gerando…" }] });
269
+ return { dir, file };
270
+ }
271
+ function stagingOf(topic, dir) {
272
+ const { base } = stagingRoot(topic);
273
+ const abs = path.resolve(dir);
274
+ const rel = path.relative(base, abs);
275
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel) || path.dirname(rel) !== STAGING) throw new Error("pasta temporária inválida");
276
+ return { base, abs };
277
+ }
278
+ // publica: confere o .yaml ANTES de mover; o renomear é atômico (mesmo disco) e o .yaml acompanha a pasta
279
+ function publishStagedDeck(topic, dir, title) {
280
+ const { base, abs } = stagingOf(topic, dir);
281
+ const yaml = path.join(abs, "deck.yaml");
282
+ const spec = YAML.parse(fs.readFileSync(yaml, "utf8")) || {};
283
+ if (!Array.isArray(spec.slides) || !spec.slides.length) throw new Error("a geração não produziu slides: nada foi publicado");
284
+ const dest = uniquePath(base, safeName(title || spec.title || "Nova apresentação"));
285
+ spec.title = title || spec.title || path.basename(dest);
286
+ fs.renameSync(abs, dest);
287
+ const final = path.join(dest, path.basename(dest) + ".yaml");
288
+ fs.renameSync(path.join(dest, "deck.yaml"), final);
289
+ writeDeckFile(final, spec); // só o título muda no arquivo
290
+ return idOf(final);
291
+ }
292
+ // descarta com cuidado: lixeira (visível e restaurável), nunca rm direto — pode ter material da pessoa lá dentro
293
+ function discardStaging(topic, dir, reason = "Geração interrompida") {
294
+ const { abs } = stagingOf(topic, dir);
295
+ const tdir = path.join(root, TRASH);
296
+ fs.mkdirSync(tdir, { recursive: true });
297
+ const slot = path.join(tdir, `${Date.now()}-${STAGING}`);
298
+ fs.mkdirSync(slot);
299
+ fs.renameSync(abs, path.join(slot, path.basename(abs)));
300
+ const yaml = path.join(slot, path.basename(abs), "deck.yaml");
301
+ let slides = 0, title = reason;
302
+ try { const spec = YAML.parse(fs.readFileSync(yaml, "utf8")) || {}; slides = Array.isArray(spec.slides) ? spec.slides.length : 0; if (spec.title && spec.title !== "Gerando…") title = spec.title; } catch {}
303
+ fs.writeFileSync(path.join(slot, ".origem.json"), JSON.stringify({
304
+ topic, title, deleted: Date.now(), yaml: "deck.yaml",
305
+ unit: path.basename(abs), slides, staging: true,
306
+ }));
307
+ return path.basename(slot);
308
+ }
309
+ // na subida: toda pasta temporária é órfã (a geração morreu com o processo) — vai para a lixeira, nunca some
310
+ function recoverStaging() {
311
+ const moved = [];
312
+ for (const e of fs.readdirSync(root, { withFileTypes: true })) {
313
+ if (!e.isDirectory() || hidden(e.name)) continue;
314
+ const stage = path.join(root, e.name, STAGING);
315
+ if (!fs.existsSync(stage)) continue;
316
+ for (const s of fs.readdirSync(stage, { withFileTypes: true })) {
317
+ if (!s.isDirectory()) continue;
318
+ try { moved.push(discardStaging(e.name, path.join(stage, s.name), "Geração interrompida (Studio reiniciado)")); } catch {}
319
+ }
320
+ }
321
+ return moved;
322
+ }
323
+
252
324
  // ---- lixeira ----
253
- function trashDeck(id) {
254
- const { file, unit, info } = unitOf(id);
325
+ function trashDeck(id) { const { file, unit, info } = unitOf(id);
255
326
  const tdir = path.join(root, TRASH);
256
327
  fs.mkdirSync(tdir, { recursive: true });
257
328
  const slot = path.join(tdir, `${Date.now()}-${path.basename(unit)}`);
@@ -393,6 +464,7 @@ export function openLibrary(root) {
393
464
  root, list, resolveId, idOf,
394
465
  createTopic, updateTopic, deleteTopic,
395
466
  createDeck, moveDeck, renameDeck, duplicateDeck,
467
+ createStagingDeck, publishStagedDeck, discardStaging, recoverStaging,
396
468
  trashDeck, restoreDeck, purgeDeck, purgeOld, listTrash, emptyTrash,
397
469
  importPackage,
398
470
  };
package/src/motion.js CHANGED
@@ -26,7 +26,7 @@ function scene(o){
26
26
  const message=(kind)=>parent.postMessage({sagaMotion:kind},'*');
27
27
  const chat=document.querySelector('.chat'),input=document.querySelector('.input'),thread=document.querySelector('.thread'),status=document.querySelector('.status');
28
28
  function reset(){started=false;phase='idle';elapsed=0;chat.hidden=true;input.textContent='';thread.replaceChildren();status.textContent='';}
29
- window.addEventListener('message',e=>{if(e.source!==parent)return;if(e.data?.sagaMotion==='poster'){reduced=true;if(o.type==='terminal')document.querySelector('.feed').textContent=o.lines.join('\n');}if(e.data?.sagaMotion==='active')active=!!e.data.active;if(e.data?.sagaMotion==='reset')reset();if(e.data?.sagaMotion==='start'){reset();started=true;active=true;phase='recognize';chat.hidden=false;}});
29
+ message('ready');window.addEventListener('message',e=>{if(e.source!==parent)return;if(e.data?.sagaMotion==='poster'){reduced=true;if(o.type==='terminal')document.querySelector('.feed').textContent=o.lines.join('\n');}if(e.data?.sagaMotion==='active')active=!!e.data.active;if(e.data?.sagaMotion==='reset')reset();if(e.data?.sagaMotion==='start'){reset();started=true;active=true;phase='recognize';chat.hidden=false;}});
30
30
  if(o.type==='chat'&&o.manual){const request=e=>{if(!active)return;if(e.type==='keydown'&&![' ','Enter','ArrowRight','PageDown'].includes(e.key))return;e.preventDefault();message('start-request');};document.addEventListener('click',request);document.addEventListener('keydown',request);}
31
31
  if(o.type==='chat'&&!o.manual){started=true;phase='recognize';chat.hidden=false;}
32
32
  function bubble(text,kind){const el=document.createElement('div');el.className='bubble '+kind;el.textContent=text;thread.append(el);return el;}
package/src/package.js CHANGED
@@ -78,6 +78,21 @@ export async function packDeck(spec, { baseDir, name = "apresentacao", generator
78
78
  created: new Date().toISOString(), generator }, null, 2));
79
79
  root.file(main, YAML.stringify(packagedSpec(spec, assets), { indent: 2, lineWidth: 0 }));
80
80
  const files = [], missing = [];
81
+ const pesquisa = [];
82
+ try {
83
+ const pdir = path.join(dir, "contexto", "pesquisa");
84
+ for (const f of fs.readdirSync(pdir)) {
85
+ const abs = path.join(pdir, f);
86
+ if (fs.statSync(abs).isFile()) pesquisa.push({ rel: `contexto/pesquisa/${f}`, abs });
87
+ }
88
+ } catch {}
89
+ let budget = 2 * 1024 * 1024; // a pesquisa viaja junto, sem estourar o pacote
90
+ const skipped = [];
91
+ for (const q of pesquisa) {
92
+ const size = fs.statSync(q.abs).size;
93
+ if (size <= budget) { root.file(q.rel, fs.readFileSync(q.abs)); files.push(q.rel); budget -= size; }
94
+ else skipped.push(q.rel);
95
+ }
81
96
  for (const a of assets) {
82
97
  if (a.exists) { root.file(a.rel, fs.readFileSync(a.abs)); files.push(a.rel); }
83
98
  else missing.push(a.ref);
@@ -86,7 +101,7 @@ export async function packDeck(spec, { baseDir, name = "apresentacao", generator
86
101
  root.file("FALTANDO.txt", `Estes arquivos são usados pela apresentação, mas não foram encontrados em ${dir}:\n\n` +
87
102
  missing.map((m) => `- ${m}`).join("\n") + "\n\nColoque-os nesta pasta (mesmo caminho) para a apresentação ficar completa.\n");
88
103
  }
89
- return { zip: await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }), files, missing };
104
+ return { zip: await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }), files, missing, pesquisaSkipped: skipped };
90
105
  }
91
106
 
92
107
  // Extrai um pacote numa pasta nova (nunca sobrescreve) e devolve o .yaml principal.
@@ -0,0 +1,12 @@
1
+ // Buscador com chave (fase 3): Brave Search API. Só entra na cadeia com BRAVE_SEARCH_KEY
2
+ // (sem chave, é pulado em silêncio — nunca trava a pesquisa por falta de configuração).
3
+ export async function searchBrave(query, { limit = 8, timeoutMs = 12000, key = process.env.BRAVE_SEARCH_KEY } = {}) {
4
+ if (!key) return [];
5
+ const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${Math.min(limit, 20)}&text_decorations=0`;
6
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: { "X-Subscription-Token": key, "Accept": "application/json" } });
7
+ if (!res.ok) throw new Error(`Brave respondeu HTTP ${res.status}`);
8
+ const j = await res.json();
9
+ return ((j.web && j.web.results) || []).slice(0, limit)
10
+ .map((r) => ({ title: String(r.title || r.url), url: String(r.url || ""), snippet: String(r.description || "").slice(0, 300), date: String(r.age || "") }))
11
+ .filter((r) => /^https?:\/\//.test(r.url));
12
+ }
@@ -0,0 +1,29 @@
1
+ // Pesquisa web no chat de um deck aberto (fase 2): a mesma decisão do `new`
2
+ // (a IA diz se o que ela sabe basta), mas com o pedido da mensagem.
3
+ // Se precisar, busca, lê e anota; os materiais [F1]… entram no prompt junto
4
+ // com a instrução de citação, e tudo fica em contexto/pesquisa/ do deck.
5
+ // Sem internet (SAGADECK_WEB=0, a rede do banco): avisa e segue sem inventar.
6
+ // Falhou: segue com o que a IA sabe (o chat nunca trava por causa da pesquisa).
7
+ import { decideResearch, runResearch, researchInstruction, defaultWeb } from "./research.js";
8
+
9
+ const today = () => new Date().toISOString().slice(0, 10);
10
+
11
+ export async function maybeResearch({ prompt, materials = [], saveDir = null, onProgress = () => {}, web = null } = {}) {
12
+ try {
13
+ const plan = await decideResearch(prompt, { materials });
14
+ if (!plan.pesquisar) return { materials, report: { pesquisou: false, motivo: plan.motivo }, instruction: "" };
15
+ onProgress(`vou pesquisar: ${plan.motivo}`);
16
+ if (process.env.SAGADECK_WEB === "0") {
17
+ const report = { pesquisou: true, motivo: plan.motivo, buscas: plan.buscas, fontes: [], falhas: ["web desligada"], offline: true, data: today() };
18
+ return { materials, report, instruction: researchInstruction(report) };
19
+ }
20
+ const r = await runResearch(plan, { briefing: prompt, web: web || defaultWeb, onProgress, saveDir });
21
+ onProgress(r.materials.length
22
+ ? `pesquisa: ${r.materials.length} fonte(s) lida(s) (${r.report.fontes.map((f) => f.site).join(", ")})`
23
+ : "a pesquisa não achou fonte que desse para ler; sigo com o que eu sei, sem inventar dado recente.");
24
+ return { materials: [...materials, ...r.materials], report: r.report, instruction: researchInstruction(r.report) };
25
+ } catch (e) {
26
+ onProgress(`a pesquisa falhou (${e.message}); sigo com o que eu sei`);
27
+ return { materials, report: null, instruction: "" };
28
+ }
29
+ }
@@ -0,0 +1,37 @@
1
+ // Checagem de links das fontes da pesquisa: a URL continua de pé?
2
+ // Base para `sagadeck links deck.yaml` (lê contexto/pesquisa/fontes.json ao lado do deck).
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ export function linksOfDeck(deckDir) {
7
+ try {
8
+ const report = JSON.parse(fs.readFileSync(path.join(deckDir, "contexto", "pesquisa", "fontes.json"), "utf8"));
9
+ const urls = [...new Set((report.fontes || []).map((f) => f?.url).filter((u) => typeof u === "string" && /^https?:/i.test(u)))];
10
+ return urls;
11
+ } catch { return []; }
12
+ }
13
+
14
+ export async function checkLinks(urls, { fetchFn = fetch, timeoutMs = 15000 } = {}) {
15
+ const out = [];
16
+ for (const url of urls || []) {
17
+ try {
18
+ const ctrl = new AbortController();
19
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
20
+ try {
21
+ const r = await fetchFn(url, { redirect: "follow", signal: ctrl.signal });
22
+ out.push({ url, ok: !!r.ok, status: r.status });
23
+ } finally { clearTimeout(t); }
24
+ } catch (e) {
25
+ out.push({ url, ok: false, status: 0, erro: e?.name === "AbortError" ? "tempo esgotado" : String(e?.message || e).slice(0, 120) });
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+
31
+ export function formatLinks(rows) {
32
+ if (!rows.length) return "sem links para conferir.";
33
+ const bad = rows.filter((r) => !r.ok);
34
+ return [`${rows.length - bad.length} de ${rows.length} links respondendo:`,
35
+ ...rows.map((r) => ` ${r.ok ? "ok " : "QUEBROU"} ${r.status || "-"} ${r.url}${r.erro ? ` (${r.erro})` : ""}`),
36
+ ].join("\n");
37
+ }
@@ -14,6 +14,8 @@ import path from "node:path";
14
14
  import { chat } from "../ai/llm.js";
15
15
  import { fetchUrlDoc } from "../ai/context.js";
16
16
  import { searchDuckDuckGo } from "./duckduckgo.js";
17
+ import { searchWikipedia } from "./wikipedia.js";
18
+ import { searchBrave } from "./brave.js";
17
19
 
18
20
  const today = () => new Date().toISOString().slice(0, 10);
19
21
  const clip = (s, n) => (String(s ?? "").length > n ? String(s).slice(0, n - 1) + "…" : String(s ?? ""));
@@ -73,10 +75,21 @@ export async function llmSearch(q, { limit = 8 } = {}) {
73
75
  const t = r.text || "", m = t.match(/```(?:json)?\s*([\s\S]*?)```/), raw = m ? m[1] : t.slice(t.indexOf("["), t.lastIndexOf("]") + 1);
74
76
  return (JSON.parse(raw) || []).filter((x) => /^https?:\/\//.test(x?.url || "")).slice(0, limit).map((x) => ({ title: String(x.title || x.url), url: String(x.url), snippet: String(x.snippet || ""), date: String(x.date || "") }));
75
77
  }
78
+ const BACKENDS = {
79
+ llm: (q) => llmSearch(q),
80
+ duckduckgo: (q) => searchDuckDuckGo(q, { limit: 8, timeoutMs: 12000 }),
81
+ brave: (q) => searchBrave(q),
82
+ wikipedia: (q) => searchWikipedia(q),
83
+ };
84
+ // cadeia configurável: SAGADECK_SEARCH_BACKENDS="brave,llm,duckduckgo" (nomes desconhecidos são ignorados)
85
+ export function searchChain() {
86
+ const names = String(process.env.SAGADECK_SEARCH_BACKENDS || "llm,duckduckgo,wikipedia").split(",").map((s) => s.trim()).filter(Boolean);
87
+ return names.filter((n) => BACKENDS[n]);
88
+ }
76
89
  async function searchAny(q) {
77
90
  const errors = [];
78
- for (const f of [() => llmSearch(q), () => searchDuckDuckGo(q, { limit: 8, timeoutMs: 12000 })]) {
79
- try { const r = await f(); if (r.length) return r; errors.push("nenhum resultado"); } catch (e) { errors.push(e.message); }
91
+ for (const name of searchChain()) {
92
+ try { const r = await BACKENDS[name](q); if (r.length) return r; errors.push(`${name}: nenhum resultado`); } catch (e) { errors.push(`${name}: ${e.message}`); }
80
93
  }
81
94
  throw new Error(`nenhum buscador respondeu (${errors.join("; ")})`);
82
95
  }
@@ -109,11 +122,38 @@ Responda só JSON: {"pesquisar": true|false, "externalAuthorized": true|false, "
109
122
  return { pesquisar: !!r.pesquisar && (!materials.some(m=>m.kind !== 'pesquisa') || r.externalAuthorized === true), motivo: String(r.motivo || ""), academico: !!r.academico, buscas: (Array.isArray(r.buscas) ? r.buscas : []).map(String).filter(Boolean).slice(0, 5) };
110
123
  }
111
124
 
125
+ // ---- cache de busca: a mesma pergunta não paga o buscador duas vezes (TTL em dias) -------------------------------
126
+ const cacheFile = (saveDir) => (saveDir ? path.join(saveDir, "contexto", "pesquisa", "busca-cache.json") : null);
127
+ const cacheDays = () => Number(process.env.SAGADECK_SEARCH_CACHE_DIAS || 7);
128
+ function readSearchCache(saveDir) {
129
+ try { return JSON.parse(fs.readFileSync(cacheFile(saveDir), "utf8")); } catch { return {}; }
130
+ }
131
+ function writeSearchCache(saveDir, cache) {
132
+ fs.mkdirSync(path.dirname(cacheFile(saveDir)), { recursive: true });
133
+ fs.writeFileSync(cacheFile(saveDir), JSON.stringify(cache));
134
+ }
135
+ export function cachedWeb(web, saveDir) {
136
+ if (!saveDir) return web;
137
+ const memo = async (kind, q, fn) => {
138
+ const key = `${kind}:${String(q).trim().toLowerCase()}`;
139
+ const hit = readSearchCache(saveDir)[key];
140
+ if (hit && Array.isArray(hit.resultados) && Date.now() - hit.quando < cacheDays() * 864e5) return hit.resultados;
141
+ const r = await fn(q);
142
+ try { const c = readSearchCache(saveDir); c[key] = { quando: Date.now(), resultados: r }; writeSearchCache(saveDir, c); } catch {}
143
+ return r;
144
+ };
145
+ return { ...web,
146
+ search: (q) => memo("web", q, (x) => web.search(x)),
147
+ ...(web.arxiv ? { arxiv: (q) => memo("arxiv", q, (x) => web.arxiv(x)) } : {}),
148
+ };
149
+ }
150
+
112
151
  // ---- 2 a 5 -------------------------------------------------------------------------------------------------------
113
152
  const siteOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return ""; } };
114
153
  const isArxiv = (u) => /arxiv\.org\/(abs|pdf)\//.test(u);
115
154
 
116
155
  export async function runResearch(plan, { briefing = "", web = defaultWeb, onProgress = () => {}, saveDir = null, maxSources = 8, annotationTimeoutMs = 45000 } = {}) {
156
+ web = cachedWeb(web, saveDir);
117
157
  const report = { pesquisou: true, motivo: plan.motivo, buscas: plan.buscas, fontes: [], descartadas: 0, falhas: [], offline: false, data: today() };
118
158
  // 2. buscar
119
159
  onProgress(`Pesquisando: ${plan.buscas.join(" · ")}`);
@@ -0,0 +1,15 @@
1
+ // Buscador sem chave (fase 3): Wikipedia via OpenSearch. Cobre o caso "referência estável"
2
+ // quando o LLM e o DuckDuckGo falham; sem snippet longo, mas com URL exata e descrição curta.
3
+ export async function searchWikipedia(query, { limit = 5, lang = "pt", timeoutMs = 10000 } = {}) {
4
+ const attempt = async (wiki) => {
5
+ const url = `https://${wiki}.wikipedia.org/w/api.php?action=opensearch&search=${encodeURIComponent(query)}&limit=${limit}&namespace=0&format=json`;
6
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: { "user-agent": "sagadeck/1.0 (pesquisa para apresentação)" } });
7
+ if (!res.ok) throw new Error(`Wikipedia (${wiki}) respondeu HTTP ${res.status}`);
8
+ const [, titles = [], descs = [], links = []] = await res.json();
9
+ return titles.map((title, i) => ({ title, url: links[i] || "", snippet: (descs[i] || "").slice(0, 300), date: "" })).filter((r) => /^https?:\/\//.test(r.url));
10
+ };
11
+ const pt = await attempt(lang).catch(() => []);
12
+ if (pt.length || lang === "en") return pt.slice(0, limit);
13
+ const en = await attempt("en").catch(() => []);
14
+ return [...pt, ...en].slice(0, limit);
15
+ }
@@ -137,8 +137,8 @@ body{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;font-k
137
137
  .muted{color:var(--muted)}
138
138
  .em{color:var(--em)}
139
139
  .t a{color:inherit;text-decoration-thickness:.06em;text-underline-offset:.14em}
140
- mark{color:var(--on-hi);background:linear-gradient(var(--hi),var(--hi)) no-repeat 0 55%/0% 92%;padding:0 .1em;margin:0 -.04em;-webkit-box-decoration-break:clone;box-decoration-break:clone;transition:background-size .8s var(--ease-out) .25s}
141
- mark.play{background-size:100% 92%}
140
+ mark{color:var(--on-hi);background:linear-gradient(var(--hi),var(--hi)) no-repeat 0 58%/0% 84%;padding:0 .1em;margin:0 -.04em;-webkit-box-decoration-break:clone;box-decoration-break:clone;transition:background-size .8s var(--ease-out) .25s}
141
+ mark.play{background-size:100% 84%}
142
142
  /* estilos alternativos do ==destaque== (markStyle) */
143
143
  .ms-sublinhado mark{background:none;color:inherit;padding:0;margin:0;text-decoration:underline;text-decoration-color:var(--hi);text-decoration-thickness:.12em;text-underline-offset:.16em}
144
144
  .ms-cor mark{background:none;color:var(--em);padding:0;margin:0}