sagadeck 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/bin/sagadeck.js +36 -3
- package/docs/ANDAMENTO.md +37 -0
- package/docs/REFERENCIA.md +4 -3
- package/package.json +1 -1
- package/src/ai/deck-ai.js +54 -12
- package/src/ai/document-visuals.js +230 -24
- package/src/ai/quality.js +1 -1
- package/src/ai/transform.js +51 -2
- package/src/diff.js +44 -0
- package/src/elements.js +37 -2
- package/src/export/pptx.js +8 -6
- package/src/figures/charts.js +11 -3
- package/src/figures/diagrams.js +37 -9
- package/src/fiscal/autofix.js +1 -1
- package/src/fiscal/enxugar.js +22 -0
- package/src/fiscal/normalize.js +19 -2
- package/src/import/crop.js +10 -3
- package/src/layouts.js +5 -3
- package/src/library.js +74 -2
- package/src/motion.js +1 -1
- package/src/package.js +16 -1
- package/src/research/brave.js +12 -0
- package/src/research/chat-research.js +29 -0
- package/src/research/links.js +37 -0
- package/src/research/research.js +42 -2
- package/src/research/wikipedia.js +15 -0
- package/src/runtime/base.css +6 -6
- package/src/runtime/motion.js +1 -1
- package/src/runtime/runtime.js +15 -2
- package/src/studio/ai-chat-route.js +158 -0
- package/src/studio/exporting.js +2 -2
- package/src/studio/library-routes.js +158 -0
- package/src/studio/public/app.js +44 -508
- package/src/studio/public/chat.js +461 -0
- package/src/studio/public/index.html +17 -12
- package/src/studio/public/library.js +13 -2
- package/src/studio/public/slide-clipboard.js +20 -7
- package/src/studio/public/slide-select.js +73 -0
- package/src/studio/public/studio-next.css +28 -1
- package/src/studio/public/style.css +7 -1
- package/src/studio/public-files.js +1 -1
- package/src/studio/server.js +14 -285
- package/src/studio/slide-copy-routes.js +7 -4
- package/src/studio/snapshot.js +3 -0
- package/src/themes.js +32 -2
package/src/figures/diagrams.js
CHANGED
|
@@ -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
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
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 = {}) {
|
package/src/fiscal/autofix.js
CHANGED
|
@@ -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/fiscal/normalize.js
CHANGED
|
@@ -14,8 +14,19 @@ const COLOR_MAP = {
|
|
|
14
14
|
branco: "FFFFFF",
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
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()];
|
package/src/import/crop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}
|
package/src/research/research.js
CHANGED
|
@@ -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
|
|
79
|
-
try { const r = await
|
|
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
|
+
}
|
package/src/runtime/base.css
CHANGED
|
@@ -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
|
|
141
|
-
mark.play{background-size:100%
|
|
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:
|
|
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:.
|
|
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}
|
package/src/runtime/motion.js
CHANGED
|
@@ -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
|
}
|
package/src/runtime/runtime.js
CHANGED
|
@@ -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
|
-
|
|
813
|
-
|
|
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;
|