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.
- 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 +44 -11
- package/src/ai/document-visuals.js +90 -15
- package/src/ai/quality.js +1 -1
- package/src/ai/transform.js +51 -2
- package/src/diff.js +44 -0
- package/src/export/pptx.js +8 -6
- package/src/figures/charts.js +4 -2
- package/src/figures/diagrams.js +37 -9
- package/src/fiscal/autofix.js +1 -1
- package/src/fiscal/enxugar.js +22 -0
- 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 +2 -2
- 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/themes.js +32 -2
package/src/ai/transform.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/export/pptx.js
CHANGED
|
@@ -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" };
|
package/src/figures/charts.js
CHANGED
|
@@ -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
|
}
|
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/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}
|