sagadeck 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) 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 +54 -12
  7. package/src/ai/document-visuals.js +230 -24
  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/elements.js +37 -2
  12. package/src/export/pptx.js +8 -6
  13. package/src/figures/charts.js +11 -3
  14. package/src/figures/diagrams.js +37 -9
  15. package/src/fiscal/autofix.js +1 -1
  16. package/src/fiscal/enxugar.js +22 -0
  17. package/src/fiscal/normalize.js +19 -2
  18. package/src/import/crop.js +10 -3
  19. package/src/layouts.js +5 -3
  20. package/src/library.js +74 -2
  21. package/src/motion.js +1 -1
  22. package/src/package.js +16 -1
  23. package/src/research/brave.js +12 -0
  24. package/src/research/chat-research.js +29 -0
  25. package/src/research/links.js +37 -0
  26. package/src/research/research.js +42 -2
  27. package/src/research/wikipedia.js +15 -0
  28. package/src/runtime/base.css +6 -6
  29. package/src/runtime/motion.js +1 -1
  30. package/src/runtime/runtime.js +15 -2
  31. package/src/studio/ai-chat-route.js +158 -0
  32. package/src/studio/exporting.js +2 -2
  33. package/src/studio/library-routes.js +158 -0
  34. package/src/studio/public/app.js +44 -508
  35. package/src/studio/public/chat.js +461 -0
  36. package/src/studio/public/index.html +17 -12
  37. package/src/studio/public/library.js +13 -2
  38. package/src/studio/public/slide-clipboard.js +20 -7
  39. package/src/studio/public/slide-select.js +73 -0
  40. package/src/studio/public/studio-next.css +28 -1
  41. package/src/studio/public/style.css +7 -1
  42. package/src/studio/public-files.js +1 -1
  43. package/src/studio/server.js +14 -285
  44. package/src/studio/slide-copy-routes.js +7 -4
  45. package/src/studio/snapshot.js +3 -0
  46. package/src/themes.js +32 -2
@@ -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
+ }
@@ -14,8 +14,19 @@ const COLOR_MAP = {
14
14
  branco: "FFFFFF",
15
15
  };
16
16
 
17
- export function normalizeSpec(rawSpec) {
18
- if (!rawSpec || typeof rawSpec !== "object") return rawSpec;
17
+ // Vírgula em valor de fluxo (`text: 3,5 peixes…` dentro de [{...}]) que o YAML partiu em
18
+ // {text: 3, "5 peixes…": null}: junta de volta. Chave nula começando com dígito nunca é intencional.
19
+ function fixCommaSplit(o) {
20
+ if (!o || typeof o !== "object" || Array.isArray(o)) return;
21
+ for (const k of Object.keys(o)) {
22
+ if (o[k] === null && /^\d/.test(k)) {
23
+ const target = ["text", "title", "label", "when"].find((f) => typeof o[f] === "number" || typeof o[f] === "string");
24
+ if (target) { o[target] = `${o[target]},${k}`; delete o[k]; }
25
+ }
26
+ }
27
+ }
28
+
29
+ export function normalizeSpec(rawSpec) { if (!rawSpec || typeof rawSpec !== "object") return rawSpec;
19
30
  const spec = JSON.parse(JSON.stringify(rawSpec));
20
31
 
21
32
  // 1. Normalização no nível da raiz
@@ -100,6 +111,12 @@ export function normalizeSpec(rawSpec) {
100
111
  });
101
112
  }
102
113
 
114
+ // Vírgula em valor de fluxo YAML (`text: 3,5 peixes…` dentro de [{...}]) quebra em
115
+ // {text: 3, "5 peixes…": null}: junta de volta antes que vire dado truncado no slide.
116
+ for (const f of ["events", "steps", "items"]) {
117
+ if (Array.isArray(s[f])) s[f].forEach(fixCommaSplit);
118
+ }
119
+
103
120
  // Cores comuns em português
104
121
  if (s.color && COLOR_MAP[s.color.toLowerCase()]) {
105
122
  s.color = COLOR_MAP[s.color.toLowerCase()];
@@ -48,11 +48,18 @@ export async function cropRegions(jobs) {
48
48
  const x = Math.max(0, Math.floor(j.x)), y = Math.max(0, Math.floor(j.y));
49
49
  const w = Math.max(1, Math.min(img.width - x, Math.ceil(j.w))), h = Math.max(1, Math.min(img.height - y, Math.ceil(j.h)));
50
50
  const c = document.createElement("canvas"); c.width = w; c.height = h;
51
- c.getContext("2d").drawImage(img, x, y, w, h, 0, 0, w, h);
52
- return c.toDataURL("image/png");
51
+ const g = c.getContext("2d", { willReadFrequently: true }); g.drawImage(img, x, y, w, h, 0, 0, w, h);
52
+ // densidade de tinta (fração de pixels não-papel): recorte quase em branco = caixa errada
53
+ let ink = 0;
54
+ try {
55
+ const d = g.getImageData(0, 0, w, h).data;
56
+ for (let i = 0; i < d.length; i += 16) { if ((d[i] + d[i + 1] + d[i + 2]) / 3 < 235) ink++; }
57
+ ink /= d.length / 16;
58
+ } catch { ink = 1; }
59
+ return { png: c.toDataURL("image/png"), ink };
53
60
  });
54
61
  }, [data, list.map(({ x, y, w, h }) => ({ x, y, w, h }))]);
55
- outs.forEach((u, k) => { fs.writeFileSync(list[k].dst, Buffer.from(u.split(",")[1], "base64")); done.push(list[k].dst); });
62
+ outs.forEach((u, k) => { fs.writeFileSync(list[k].dst, Buffer.from(u.png.split(",")[1], "base64")); list[k].ink = u.ink; done.push(list[k].dst); });
56
63
  }
57
64
  } finally { await browser.close(); }
58
65
  return done;
package/src/layouts.js CHANGED
@@ -124,9 +124,11 @@ export const LAYOUTS = {
124
124
  },
125
125
 
126
126
  statement(s, ctx) {
127
- // o tamanho pelo comprimento: frase curta é manchete; parágrafo inteiro em letra de título ocupava a tela toda
128
- const words = String(s.text || (s.lines || []).map((l) => (typeof l === "string" ? l : l?.text || "")).join(" ")).split(/\s+/).filter(Boolean).length;
129
- const role = s.as || (words > 45 ? "lead" : words > 22 ? "h2" : "title");
127
+ // o tamanho pelo comprimento EM CARACTERES: contar palavras falhava com palavra longa
128
+ // e frase de 100+ caracteres em destaque grande ocupava a tela (headline é para frase curta).
129
+ // `as` explícito vence.
130
+ const chars = String(s.text || (s.lines || []).map((l) => (typeof l === "string" ? l : l?.text || "")).join(" ")).length;
131
+ const role = s.as || (chars > 100 ? "lead" : chars > 60 ? "h2" : "title");
130
132
  const lines = s.lines
131
133
  ? s.lines.map((l, i) => {
132
134
  const o = typeof l === "string" ? { text: l } : l;
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}
@@ -439,16 +439,16 @@ code.f-mono{font-size:.86em;background:color-mix(in srgb,var(--fg) 9%,transparen
439
439
  .av-perigo .av-icon{color:var(--alert)}
440
440
  .av-perigo .av-title{text-transform:uppercase;letter-spacing:.04em}
441
441
 
442
- .tl{position:relative;display:grid;grid-template-columns:repeat(var(--n),1fr);gap:40px;flex:1;align-content:center}
442
+ .tl{position:relative;display:grid;grid-template-columns:repeat(var(--n),1fr);gap:40px;flex:1;align-content:center;min-height:520px}
443
443
  .tl-line{position:absolute;left:0;right:0;top:50%;height:6px;background:var(--line);border-radius:3px;display:none}
444
- .tl-ev{position:relative;display:flex;flex-direction:column;gap:16px;padding-top:62px}
444
+ .tl-ev{position:relative;display:flex;flex-direction:column;gap:20px;padding-top:62px;min-width:0}
445
445
  .tl-dot{position:absolute;top:0;left:0;width:44px;height:44px;border-radius:50%;background:var(--bg);border:8px solid var(--fg)}
446
446
  .tl-ev::before{content:"";position:absolute;left:44px;right:-40px;top:19px;height:6px;background:var(--line)}
447
447
  .tl-ev:last-child::before{display:none}
448
448
  .tl-ev.hl .tl-dot{background:var(--hi)}
449
- .tl-when{font-size:96px;line-height:.9}
449
+ .tl-when{font-size:calc(max(54px, 96px - max(0px, (var(--n, 3) - 3) * 13px)));line-height:.95;overflow-wrap:anywhere}
450
450
  .tl-ev.hl .tl-when{color:var(--em)}
451
- .tl-title{font-size:42px}
451
+ .tl-title{font-size:42px;overflow-wrap:anywhere}
452
452
  .tl-text{font-size:30px;color:var(--muted)}
453
453
  .tl-tag{font-size:20px;color:var(--em)}
454
454
  .tl-after{margin-top:auto}
@@ -9,7 +9,7 @@ for(const frame of document.querySelectorAll('iframe[data-motion]')){
9
9
  const begin=e=>{if(!slide?.classList.contains('current')||done||e.target.closest('input,textarea,select,[contenteditable=true]'))return;if(e.type==='keydown'&&![' ','Enter','ArrowRight','PageDown'].includes(e.key))return;e.preventDefault();e.stopImmediatePropagation();if(started){finish();return;}started=true;send('start');};
10
10
  document.addEventListener('keydown',begin,true);slide.addEventListener('click',begin,true);
11
11
  }
12
- window.addEventListener('message',e=>{if(e.source!==frame.contentWindow)return;if(e.data?.sagaMotion==='done')finish();if(e.data?.sagaMotion==='start-request'&&slide?.classList.contains('current')&&!done){if(started)finish();else{started=true;send('start');}}});
12
+ window.addEventListener('message',e=>{if(e.source!==frame.contentWindow)return;if(e.data?.sagaMotion==='done')finish();if(e.data?.sagaMotion==='ready')activate();if(e.data?.sagaMotion==='start-request'&&slide?.classList.contains('current')&&!done){if(started)finish();else{started=true;send('start');}}});
13
13
  const activate=()=>{const active=slide?.classList.contains('current');send('active',active);if(document.documentElement.classList.contains('export')||document.documentElement.dataset.motion==='none'||matchMedia('(prefers-reduced-motion: reduce)').matches)send('poster');if(!active){started=false;done=false;frame.style.visibility='';send('reset');}};
14
14
  frame.addEventListener('load',activate);if(slide)new MutationObserver(activate).observe(slide,{attributes:true,attributeFilter:['class']});
15
15
  }
@@ -809,8 +809,21 @@
809
809
  document.body.classList.add("mouse"); clearTimeout(mt); mt = setTimeout(() => document.body.classList.remove("mouse"), 1600);
810
810
  const l = $("#laser"); l.style.left = e.clientX + "px"; l.style.top = e.clientY + "px";
811
811
  });
812
- let tx = null; document.addEventListener("touchstart", (e) => (tx = e.touches[0].clientX));
813
- document.addEventListener("touchend", (e) => { if (tx == null) return; const d = e.changedTouches[0].clientX - tx; if (Math.abs(d) > 50) cmd(d < 0 ? "next" : "prev"); tx = null; });
812
+ // Mobile: arrastar pro lado troca de slide; tocar (sem arrastar) avança. Toque em controle
813
+ // (botão, link, campo, vídeo, widget, ir-para) continua sendo do controle, nunca avança.
814
+ let tx = null, ty = null, tt = 0;
815
+ document.addEventListener("touchstart", (e) => { const t = e.touches[0]; tx = t.clientX; ty = t.clientY; tt = Date.now(); }, { passive: true });
816
+ document.addEventListener("touchend", (e) => {
817
+ if (tx == null) return;
818
+ const t = e.changedTouches[0], dx = t.clientX - tx, dy = t.clientY - ty, dt = Date.now() - tt;
819
+ tx = ty = null;
820
+ if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) { cmd(dx < 0 ? "next" : "prev"); return; }
821
+ if (Math.abs(dx) < 12 && Math.abs(dy) < 12 && dt < 500 && !e.target.closest("a,button,input,textarea,select,[contenteditable],video,audio,iframe,.widget,[data-goto]")) {
822
+ const sel = typeof getSelection === "function" && getSelection();
823
+ if (sel && !sel.isCollapsed) return; // selecionando texto: não avança
824
+ cmd("next");
825
+ }
826
+ }, { passive: true });
814
827
  const [i, k] = parseHash();
815
828
  slides.forEach((s) => s.classList.remove("current"));
816
829
  cur = -1;