sagadeck 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +4 -4
  2. package/bin/sagadeck.js +9 -1
  3. package/docs/ANDAMENTO.md +376 -0
  4. package/docs/REFERENCIA.md +4 -4
  5. package/package.json +1 -1
  6. package/src/ai/bench.js +132 -0
  7. package/src/ai/context.js +12 -3
  8. package/src/ai/critique.js +114 -0
  9. package/src/ai/deck-ai.js +234 -30
  10. package/src/ai/document-materials.js +2 -2
  11. package/src/ai/document-visuals.js +400 -20
  12. package/src/ai/llm.js +19 -5
  13. package/src/ai/progress.js +23 -0
  14. package/src/ai/quality.js +60 -0
  15. package/src/api-client.js +2 -1
  16. package/src/build.js +5 -1
  17. package/src/decision-lab.js +2 -1
  18. package/src/deck-file.js +2 -1
  19. package/src/elements.js +44 -7
  20. package/src/figures/charts.js +7 -4
  21. package/src/figures/diagrams.js +15 -1
  22. package/src/figures/ufmap.js +2 -1
  23. package/src/fs-retry.js +19 -0
  24. package/src/import/crop.js +27 -8
  25. package/src/layouts.js +22 -2
  26. package/src/library.js +68 -16
  27. package/src/locale.js +18 -0
  28. package/src/mcp/server.js +2 -2
  29. package/src/motion.js +4 -2
  30. package/src/preferences.js +2 -1
  31. package/src/runtime/base.css +3 -0
  32. package/src/runtime/calc.js +3 -3
  33. package/src/runtime/decision-lab.js +1 -1
  34. package/src/runtime/fit.js +20 -1
  35. package/src/runtime/runtime.js +2 -1
  36. package/src/studio/ai-chat-route.js +119 -4
  37. package/src/studio/code-version.js +35 -0
  38. package/src/studio/generate.js +53 -0
  39. package/src/studio/project.js +8 -7
  40. package/src/studio/public/app.js +7 -216
  41. package/src/studio/public/chat.js +71 -1
  42. package/src/studio/public/header-footer.js +140 -0
  43. package/src/studio/public/index.html +5 -0
  44. package/src/studio/public/library.html +1 -0
  45. package/src/studio/public/library.js +4 -3
  46. package/src/studio/public/preferences-ui.js +83 -0
  47. package/src/studio/public/provenance.js +13 -0
  48. package/src/studio/public/style.css +17 -0
  49. package/src/studio/public/ui-icons.js +1 -1
  50. package/src/studio/public/update-notice.js +25 -0
  51. package/src/studio/public-files.js +11 -1
  52. package/src/studio/server.js +20 -29
  53. package/src/studio/share-routes.js +2 -1
package/src/library.js CHANGED
@@ -9,25 +9,74 @@
9
9
  // rascunho.yaml um .yaml solto no tópico também é uma apresentação
10
10
  // rascunho.yaml .yaml solto na raiz = apresentação sem tópico
11
11
  // Na raiz, pasta é SEMPRE tópico. "Nova sem tópico" vai para "Sem tópico"; importar sem tópico, para "Importados".
12
- // .lixeira/ excluídas (30 dias), com .origem.json
12
+ // .lixeira/ excluídas (1 dia), com .origem.json
13
13
  // .cache/ capas geradas
14
14
  //
15
15
  // O id de uma apresentação é o caminho do .yaml dela, relativo à biblioteca, com "/" (ex.: "Palestras/X/X.yaml").
16
16
  import fs from "node:fs";
17
+ import { renameRetry } from "./fs-retry.js";
17
18
  import os from "node:os";
19
+ import { execFileSync } from "node:child_process";
18
20
  import path from "node:path";
19
21
  import YAML from "yaml";
20
22
  import * as MASTER from "./master.js";
21
23
  import { unpackDeck } from "./package.js";
22
24
  import { writeDeckFile } from "./deck-file.js";
23
25
 
24
- export const TRASH_DAYS = 30;
26
+ export const TRASH_DAYS = 1;
25
27
  const TRASH = ".lixeira";
26
28
  const TOPIC_META = ".topico.json";
27
29
  const COLORS = ["#d33a2c", "#0f6cbd", "#e5a50a", "#8b5cf6", "#0e9f6e", "#e8590c", "#d6336c", "#495057"];
28
30
 
29
- export function defaultLibraryRoot(env = process.env) {
30
- return path.resolve(env.SAGADECK_HOME || path.join(os.homedir(), "sagadeck"));
31
+ // Pasta Documentos do Windows como o Explorer a mostra: a pasta conhecida "Personal" do registro, que pode estar
32
+ // redirecionada (OneDrive\Documents, outro disco, "Documentos"). Sem ela, %USERPROFILE%\Documents. Fora do Windows, null.
33
+ let documentsCache;
34
+ export function documentsDir({ platform = process.platform, home = os.homedir() } = {}) {
35
+ if (platform !== "win32") return null;
36
+ if (documentsCache !== undefined && home === os.homedir()) return documentsCache;
37
+ let found = null;
38
+ try {
39
+ const out = execFileSync("reg", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders", "/v", "Personal"], { encoding: "utf8", windowsHide: true, timeout: 5000 });
40
+ found = out.match(/Personal\s+REG_\w+\s+(.+)/)?.[1]?.trim() || null;
41
+ } catch { /* sem registro: o padrão abaixo */ }
42
+ const dir = found && path.isAbsolute(found) ? found : path.join(home, "Documents");
43
+ if (home === os.homedir()) documentsCache = dir;
44
+ return dir;
45
+ }
46
+
47
+ // A biblioteca: SAGADECK_HOME, senão <Documentos>\sagadeck no Windows e ~/sagadeck fora dele. Enquanto a biblioteca
48
+ // antiga (~/sagadeck, o padrão até a 1.4) não foi movida para Documentos, ela continua valendo: nunca abre vazia.
49
+ export function defaultLibraryRoot(env = process.env, { platform = process.platform, home = os.homedir(), documents } = {}) {
50
+ if (env.SAGADECK_HOME) return path.resolve(env.SAGADECK_HOME);
51
+ const legacy = path.join(home, "sagadeck");
52
+ const docs = documents !== undefined ? documents : documentsDir({ platform, home });
53
+ if (!docs) return path.resolve(legacy);
54
+ const target = path.join(docs, "sagadeck");
55
+ if (!fs.existsSync(target) && hasContent(legacy) && !isLink(legacy)) return path.resolve(legacy);
56
+ return path.resolve(target);
57
+ }
58
+
59
+ const isLink = (p) => { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } };
60
+ const hasContent = (p) => { try { return fs.readdirSync(p).length > 0; } catch { return false; } };
61
+
62
+ // Leva a biblioteca antiga (~/sagadeck) para <Documentos>\sagadeck, uma vez: move a pasta inteira (mesma unidade:
63
+ // instantâneo) e deixa no lugar antigo uma junção para a nova, para atalhos e configurações antigas continuarem
64
+ // valendo. Não mexe se SAGADECK_HOME manda, se a nova já tem conteúdo (não mistura bibliotecas) ou se a antiga já
65
+ // é a junção. Falhou (arquivo aberto, outra unidade): a antiga continua sendo a biblioteca e tenta de novo depois.
66
+ export function migrateLegacyLibrary(env = process.env, { platform = process.platform, home = os.homedir(), documents } = {}) {
67
+ if (env.SAGADECK_HOME) return { skipped: "SAGADECK_HOME" };
68
+ const docs = documents !== undefined ? documents : documentsDir({ platform, home });
69
+ if (!docs) return { skipped: "fora do Windows" };
70
+ const legacy = path.join(home, "sagadeck"), target = path.join(docs, "sagadeck");
71
+ if (path.resolve(legacy) === path.resolve(target) || isLink(legacy) || !hasContent(legacy)) return { skipped: "nada a mover" };
72
+ if (hasContent(target)) return { skipped: "as duas têm conteúdo", legacy, target };
73
+ try {
74
+ fs.mkdirSync(docs, { recursive: true });
75
+ if (fs.existsSync(target)) fs.rmdirSync(target); // pasta vazia criada antes
76
+ renameRetry(legacy, target);
77
+ } catch (e) { return { error: e.message, legacy, target }; }
78
+ try { fs.symlinkSync(target, legacy, "junction"); } catch { /* sem a junção, a nova pasta já é a biblioteca */ }
79
+ return { moved: true, legacy, target };
31
80
  }
32
81
 
33
82
  // Onde uma apresentação NOVA é gravada: sempre na biblioteca. Um caminho dentro dela é respeitado; qualquer
@@ -65,8 +114,11 @@ const isYaml = (name) => /\.ya?ml$/i.test(name) && !hidden(name);
65
114
 
66
115
  // nome de pasta/arquivo válido no Windows (e no Linux): sem <>:"/\|?*, sem ponto/espaço no fim
67
116
  export function safeName(s, fallback = "Sem título") {
68
- const clean = String(s || "").normalize("NFC").replace(/[<>:"/\\|?*\u0000-\u001f]/g, " ").replace(/\s+/g, " ").trim()
69
- .replace(/[. ]+$/, "").slice(0, 80).trim();
117
+ let clean = String(s || "").normalize("NFC").replace(/[<>:"/\\|?*\u0000-\u001f]/g, " ").replace(/\s+/g, " ").trim()
118
+ .replace(/[. ]+$/, "");
119
+ // nome longo corta no último espaço antes de 80 (a pasta saía "…and Urban Flood Predic")
120
+ if (clean.length > 80) { const cut = clean.slice(0, 81).lastIndexOf(" "); clean = clean.slice(0, cut >= 40 ? cut : 80); }
121
+ clean = clean.replace(/[\s.,;:—–-]+$/, "").trim();
70
122
  return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(clean) || !clean ? fallback : clean;
71
123
  }
72
124
 
@@ -164,7 +216,7 @@ export function openLibrary(root) {
164
216
  if (color) fs.writeFileSync(path.join(dir, TOPIC_META), JSON.stringify({ ...topicMeta(dir), color }));
165
217
  if (name && safeName(name) !== path.basename(dir)) {
166
218
  const to = uniquePath(root, safeName(name));
167
- fs.renameSync(dir, to);
219
+ renameRetry(dir, to);
168
220
  dir = to;
169
221
  }
170
222
  return path.basename(dir);
@@ -208,7 +260,7 @@ export function openLibrary(root) {
208
260
  if (path.dirname(unit) === dest) return id;
209
261
  const ext = unit === file ? path.extname(file) : "";
210
262
  const to = uniquePath(dest, path.basename(unit, ext), ext);
211
- fs.renameSync(unit, to);
263
+ renameRetry(unit, to);
212
264
  return idOf(unit === file ? to : path.join(to, path.basename(file)));
213
265
  }
214
266
 
@@ -220,12 +272,12 @@ export function openLibrary(root) {
220
272
  if (unit === file) return id; // .yaml solto: só o título
221
273
  const to = uniquePath(path.dirname(unit), safeName(title));
222
274
  if (path.basename(to) === path.basename(unit)) return id;
223
- fs.renameSync(unit, to);
275
+ renameRetry(unit, to);
224
276
  // o .yaml com o nome da pasta acompanha a pasta
225
277
  let yaml = path.join(to, path.basename(file));
226
278
  if (path.basename(file).replace(/\.ya?ml$/i, "") === path.basename(unit)) {
227
279
  const renamed = path.join(to, path.basename(to) + path.extname(file));
228
- fs.renameSync(yaml, renamed);
280
+ renameRetry(yaml, renamed);
229
281
  yaml = renamed;
230
282
  }
231
283
  return idOf(yaml);
@@ -240,7 +292,7 @@ export function openLibrary(root) {
240
292
  let yaml = unit === file ? to : path.join(to, path.basename(file));
241
293
  if (unit !== file && path.basename(file).replace(/\.ya?ml$/i, "") === path.basename(unit)) {
242
294
  const renamed = path.join(to, path.basename(to) + path.extname(file));
243
- fs.renameSync(yaml, renamed);
295
+ renameRetry(yaml, renamed);
244
296
  yaml = renamed;
245
297
  }
246
298
  const spec = YAML.parse(fs.readFileSync(yaml, "utf8")) || {};
@@ -283,9 +335,9 @@ export function openLibrary(root) {
283
335
  if (!Array.isArray(spec.slides) || !spec.slides.length) throw new Error("a geração não produziu slides: nada foi publicado");
284
336
  const dest = uniquePath(base, safeName(title || spec.title || "Nova apresentação"));
285
337
  spec.title = title || spec.title || path.basename(dest);
286
- fs.renameSync(abs, dest);
338
+ renameRetry(abs, dest);
287
339
  const final = path.join(dest, path.basename(dest) + ".yaml");
288
- fs.renameSync(path.join(dest, "deck.yaml"), final);
340
+ renameRetry(path.join(dest, "deck.yaml"), final);
289
341
  writeDeckFile(final, spec); // só o título muda no arquivo
290
342
  return idOf(final);
291
343
  }
@@ -296,7 +348,7 @@ export function openLibrary(root) {
296
348
  fs.mkdirSync(tdir, { recursive: true });
297
349
  const slot = path.join(tdir, `${Date.now()}-${STAGING}`);
298
350
  fs.mkdirSync(slot);
299
- fs.renameSync(abs, path.join(slot, path.basename(abs)));
351
+ renameRetry(abs, path.join(slot, path.basename(abs)));
300
352
  const yaml = path.join(slot, path.basename(abs), "deck.yaml");
301
353
  let slides = 0, title = reason;
302
354
  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 {}
@@ -327,7 +379,7 @@ export function openLibrary(root) {
327
379
  fs.mkdirSync(tdir, { recursive: true });
328
380
  const slot = path.join(tdir, `${Date.now()}-${path.basename(unit)}`);
329
381
  fs.mkdirSync(slot);
330
- fs.renameSync(unit, path.join(slot, path.basename(unit)));
382
+ renameRetry(unit, path.join(slot, path.basename(unit)));
331
383
  fs.writeFileSync(path.join(slot, ".origem.json"), JSON.stringify({
332
384
  topic: info.topic, title: info.title, deleted: Date.now(), yaml: path.relative(unit === file ? path.dirname(file) : unit, file),
333
385
  unit: path.basename(unit), slides: info.slides,
@@ -348,7 +400,7 @@ export function openLibrary(root) {
348
400
  const isFile = /\.ya?ml$/i.test(o.unit);
349
401
  const ext = isFile ? path.extname(o.unit) : "";
350
402
  const to = uniquePath(dest, path.basename(o.unit, ext), ext);
351
- fs.renameSync(path.join(slot, o.unit), to);
403
+ renameRetry(path.join(slot, o.unit), to);
352
404
  fs.rmSync(slot, { recursive: true, force: true });
353
405
  return idOf(isFile ? to : path.join(to, o.yaml));
354
406
  }
package/src/locale.js ADDED
@@ -0,0 +1,18 @@
1
+ // Idioma dos números do deck (`lang`, padrão pt-BR): contador, gráficos e mapas formatavam sempre em português, e um
2
+ // deck em inglês mostrava "1.403" (mil quatrocentos e três) e "0,93". A renderização é síncrona, slide a slide: quem
3
+ // monta o deck (build.js) define o idioma antes e os desenhos leem daqui.
4
+ let current = "pt-BR";
5
+
6
+ export function normalizeLocale(lang) {
7
+ const l = String(lang || "").trim();
8
+ if (!l) return "pt-BR";
9
+ if (/^pt/i.test(l)) return "pt-BR";
10
+ if (/^en/i.test(l)) return /^en-GB/i.test(l) ? "en-GB" : "en-US";
11
+ try { return Intl.getCanonicalLocales(l)[0]; } catch { return "pt-BR"; }
12
+ }
13
+
14
+ export function setDeckLocale(lang) { current = normalizeLocale(lang); return current; }
15
+ export const deckLocale = () => current;
16
+ export const formatNumber = (v, opts) => Number(v).toLocaleString(current, opts);
17
+ // separador decimal do idioma atual ("," em pt-BR, "." em en-US)
18
+ export const decimalSeparator = (locale = current) => (1.5).toLocaleString(locale).replace(/\d/g, "")[0] || ",";
package/src/mcp/server.js CHANGED
@@ -82,7 +82,7 @@ const TOOLS = [
82
82
  inputSchema: {
83
83
  type: "object",
84
84
  properties: {
85
- path: { type: "string", description: "Nome da apresentação (ou caminho dentro da biblioteca SAGADECK_HOME/~/sagadeck). Toda apresentação é gravada na biblioteca: caminho de fora vira só o nome, e nada é sobrescrito" },
85
+ path: { type: "string", description: "Nome da apresentação (ou caminho dentro da biblioteca SAGADECK_HOME ou Documentos/sagadeck). Toda apresentação é gravada na biblioteca: caminho de fora vira só o nome, e nada é sobrescrito" },
86
86
  title: { type: "string", description: "Título provisório da apresentação" },
87
87
  theme: {
88
88
  type: "string",
@@ -105,7 +105,7 @@ const TOOLS = [
105
105
  inputSchema: {
106
106
  type: "object",
107
107
  properties: {
108
- path: { type: "string", description: "Nome da apresentação (ou caminho dentro da biblioteca SAGADECK_HOME/~/sagadeck). Toda apresentação é gravada na biblioteca: caminho de fora vira só o nome, e nada é sobrescrito" },
108
+ path: { type: "string", description: "Nome da apresentação (ou caminho dentro da biblioteca SAGADECK_HOME ou Documentos/sagadeck). Toda apresentação é gravada na biblioteca: caminho de fora vira só o nome, e nada é sobrescrito" },
109
109
  title: { type: "string", description: "Título da apresentação" },
110
110
  theme: {
111
111
  type: "string",
package/src/motion.js CHANGED
@@ -22,7 +22,7 @@ function drawTrace(ctx,o,t,w,h){
22
22
  ctx.restore();
23
23
  }
24
24
  function scene(o){
25
- let active=o.type!=='chat',started=false,epoch=performance.now(),serial=0,phase='idle',elapsed=0,previous=0;let reduced=matchMedia('(prefers-reduced-motion:reduce)').matches;const main=document.querySelector('main'),canvas=document.querySelector('canvas'),ctx=canvas.getContext('2d');
25
+ let active=o.type!=='chat',started=false,epoch=performance.now(),serial=0,phase='idle',elapsed=0,previous=0;let reduced=!!o.still||matchMedia('(prefers-reduced-motion:reduce)').matches;const main=document.querySelector('main'),canvas=document.querySelector('canvas'),ctx=canvas.getContext('2d');
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='';}
@@ -51,6 +51,8 @@ function scene(o){
51
51
  }
52
52
  requestAnimationFrame(draw);
53
53
  }
54
- export function motionHTML(value,colors){const o=motionOptions(value,colors);const css=`html,body{height:100%;background:${o.type==='chat'?'transparent':o.background};color:${o.foreground};overflow:hidden}main{height:100%;position:relative}canvas{position:absolute;inset:0;width:100%;height:100%}.caption{position:absolute;top:12px;left:16px;color:${o.secondary};font:11px monospace;letter-spacing:1.5px}.feed{position:absolute;top:36px;left:16px;right:16px;bottom:12px;overflow:hidden;font:${o.type==='terminal'?o.fontSize:12}px/1.5 monospace;white-space:pre}.chat{position:absolute;inset:0;border:1px solid ${o.secondary};border-radius:16px;padding:18px;background:${o.background};display:flex;flex-direction:column;gap:12px}.chat[hidden]{display:none}.thread{flex:1;overflow:auto;display:flex;flex-direction:column;gap:12px}.bubble{font:18px/1.45 system-ui;max-width:90%;padding:10px 14px;border-radius:10px;white-space:pre-wrap}.user{align-self:flex-end;background:#263a51}.assistant{align-self:flex-start}.status{font:14px system-ui;color:${o.secondary};min-height:18px}.input{border:1px solid #51617b;border-radius:9px;min-height:48px;padding:12px;font:17px system-ui;white-space:pre-wrap}.chat-title{font:12px system-ui;letter-spacing:2px;color:${o.secondary}}`;
54
+ // still: o deck pediu movimento nenhum (motion: none). A cena já nasce parada e completa; antes ela só sabia pela
55
+ // mensagem da página, que sob carga chegava depois de o terminal começar a digitar.
56
+ export function motionHTML(value,colors,{still=false}={}){const o={...motionOptions(value,colors),...(still?{still:true}:{})};const css=`html,body{height:100%;background:${o.type==='chat'?'transparent':o.background};color:${o.foreground};overflow:hidden}main{height:100%;position:relative}canvas{position:absolute;inset:0;width:100%;height:100%}.caption{position:absolute;top:12px;left:16px;color:${o.secondary};font:11px monospace;letter-spacing:1.5px}.feed{position:absolute;top:36px;left:16px;right:16px;bottom:12px;overflow:hidden;font:${o.type==='terminal'?o.fontSize:12}px/1.5 monospace;white-space:pre}.chat{position:absolute;inset:0;border:1px solid ${o.secondary};border-radius:16px;padding:18px;background:${o.background};display:flex;flex-direction:column;gap:12px}.chat[hidden]{display:none}.thread{flex:1;overflow:auto;display:flex;flex-direction:column;gap:12px}.bubble{font:18px/1.45 system-ui;max-width:90%;padding:10px 14px;border-radius:10px;white-space:pre-wrap}.user{align-self:flex-end;background:#263a51}.assistant{align-self:flex-start}.status{font:14px system-ui;color:${o.secondary};min-height:18px}.input{border:1px solid #51617b;border-radius:9px;min-height:48px;padding:12px;font:17px system-ui;white-space:pre-wrap}.chat-title{font:12px system-ui;letter-spacing:2px;color:${o.secondary}}`;
55
57
  const html=`<main><canvas aria-hidden="true"></canvas><div class="caption">${esc(o.type==='chat'?'':o.caption)}</div><div class="feed" aria-hidden="true"></div><section class="chat" hidden><div class="chat-title">${esc(o.caption||'ASSISTENTE')}</div><div class="thread" aria-live="polite"></div><div class="status"></div><div class="input" aria-label="Mensagem digitada"></div></section></main>`;
56
58
  const doc=playgroundDocument({html,css,javascript:`${drawHologram.toString()}\n${drawTrace.toString()}\n(${scene.toString()})(${JSON.stringify(o)});`});return `<iframe data-motion="${o.type}" data-motion-manual="${o.manual}" data-motion-finish="${o.finish}" title="${esc(o.caption||'Detalhe animado')}" sandbox="allow-scripts" style="width:100%;height:100%;border:0" srcdoc="${esc(doc)}"></iframe>`;}
@@ -1,6 +1,7 @@
1
1
  // Preferências do sagadeck nesta máquina: ~/.sagadeck/preferencias.json (ou SAGADECK_PREFERENCIAS), ao lado do
2
2
  // ambientes.yaml e do identidades.yaml. Valem para o Studio inteiro; o deck (fit:, author…) vence quando define.
3
3
  import fs from "node:fs";
4
+ import { renameRetry } from "./fs-retry.js";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
6
7
 
@@ -56,6 +57,6 @@ export function savePreferences(patch, file = preferencesFile()) {
56
57
  fs.mkdirSync(path.dirname(file), { recursive: true });
57
58
  const tmp = `${file}.tmp-${process.pid}`;
58
59
  fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", "utf8");
59
- fs.renameSync(tmp, file);
60
+ renameRetry(tmp, file);
60
61
  return out;
61
62
  }
@@ -247,6 +247,9 @@ code.f-mono{font-size:.86em;background:color-mix(in srgb,var(--fg) 9%,transparen
247
247
  .cd-num{font-size:110px;line-height:.9}
248
248
  .cd-title{font-size:52px}
249
249
  .cd-text{font-size:33px;color:var(--muted)}
250
+ /* cartão cujo texto é só uma fórmula: a fórmula é o conteúdo, não nota de rodapé (no tamanho do texto ela sumia) */
251
+ .cd-text.cd-math{font-size:52px;color:var(--fg);margin:.15em 0}
252
+ .card-hi .cd-text.cd-math{color:var(--on-hi)}
250
253
  .card-hi .cd-text{color:var(--on-hi)}
251
254
  .cd-foot{margin-top:auto;font-size:20px;color:var(--muted)}
252
255
 
@@ -6,10 +6,10 @@
6
6
  if (v == null || !Number.isFinite(v)) return "—";
7
7
  if (o.sci || Math.abs(v) >= 1e7 || (Math.abs(v) < 1e-3 && v !== 0)) {
8
8
  const [m, e] = v.toExponential(2).split("e");
9
- return `${m.replace(".", ",")} × 10${String(Number(e)).split("").map((c) => SUP[c] ?? c).join("")}`;
9
+ return `${(1.5).toLocaleString((typeof document !== "undefined" && document.documentElement.lang) || "pt-BR").includes(",") ? m.replace(".", ",") : m} × 10${String(Number(e)).split("").map((c) => SUP[c] ?? c).join("")}`;
10
10
  }
11
- if (o.decimals != null) return v.toLocaleString("pt-BR", { minimumFractionDigits: o.decimals, maximumFractionDigits: o.decimals });
12
- return v.toLocaleString("pt-BR", { maximumSignificantDigits: Math.abs(v) >= 1000 ? 7 : 4 });
11
+ if (o.decimals != null) return v.toLocaleString((typeof document !== "undefined" && document.documentElement.lang) || "pt-BR", { minimumFractionDigits: o.decimals, maximumFractionDigits: o.decimals });
12
+ return v.toLocaleString((typeof document !== "undefined" && document.documentElement.lang) || "pt-BR", { maximumSignificantDigits: Math.abs(v) >= 1000 ? 7 : 4 });
13
13
  }
14
14
  // todas as saídas na ordem (uma saída usa as anteriores); `cases` escolhe texto/cor/fórmula pela faixa de `of`
15
15
  function evaluate(model, values = {}) {
@@ -17,7 +17,7 @@
17
17
  return { ...s, automatic, caught, introduced, reviewed: automatic - caught + introduced,
18
18
  hours: s.volume * r * s.seconds / 3600, delta: introduced - caught };
19
19
  }
20
- const fmt = n => n.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
20
+ const fmt = n => n.toLocaleString((typeof document !== "undefined" && document.documentElement.lang) || "pt-BR", { maximumFractionDigits: 1 });
21
21
  function mount(container = document) {
22
22
  container.querySelectorAll('[data-decision-lab]').forEach(el => {
23
23
  if (el.dataset.mounted) return;
@@ -225,6 +225,24 @@
225
225
  });
226
226
  }
227
227
 
228
+ // Recorte (crop): a região vira a caixa por porcentagem, o que só é proporcional quando a caixa tem a proporção da
229
+ // região. Medida a caixa de verdade, se não bater (mais de 3%), a região inteira é encaixada no centro (como
230
+ // contain), em vez de esticar a figura na largura ou na altura.
231
+ function fitCrops(root) {
232
+ root.querySelectorAll(".fig-crop img[data-crop]").forEach((img) => {
233
+ if (!img.naturalWidth) { if (!img.dataset.cropWait) { img.dataset.cropWait = "1"; img.addEventListener("load", () => fitCrops(img.parentElement || img), { once: true }); } return; }
234
+ const box = img.parentElement, W = box.clientWidth, H = box.clientHeight;
235
+ if (!W || !H) return;
236
+ const [l, t, r, b] = img.dataset.crop.split(",").map(Number);
237
+ const fw = Math.max(0.01, 1 - l - r), fh = Math.max(0.01, 1 - t - b);
238
+ const region = (fw * img.naturalWidth) / (fh * img.naturalHeight);
239
+ if (Math.abs(region - W / H) / (W / H) < 0.03) return;
240
+ const rw = region > W / H ? W : H * region, rh = rw / region; // a região inteira, sem esticar
241
+ const iw = rw / fw, ih = rh / fh;
242
+ Object.assign(img.style, { width: `${iw}px`, height: `${ih}px`, left: `${(W - rw) / 2 - l * iw}px`, top: `${(H - rh) / 2 - t * ih}px` });
243
+ });
244
+ }
245
+
228
246
  // Passos do exercício resolvido (solution): a coluna é alinhada pelo fim (o passo novo embaixo) e o que sobra vaza por
229
247
  // cima, apagando. Antes de cortar, encolhe (até 60%) para os passos caberem inteiros. Quadro escondido não mede: o
230
248
  // runtime chama de novo a cada clique.
@@ -257,6 +275,7 @@
257
275
  function fitAllIn(root) {
258
276
  fitStages(root);
259
277
  fitImages(root);
278
+ fitCrops(root);
260
279
  fitSteps(root);
261
280
  fitChartText(root);
262
281
  fitCode(root);
@@ -264,5 +283,5 @@
264
283
  fitText(root);
265
284
  }
266
285
 
267
- g.SagadeckFit = { fitText: fitAllIn, fitChartText, fitCode, fitWide, fitImages, fitSteps, fitStages, shrink };
286
+ g.SagadeckFit = { fitText: fitAllIn, fitChartText, fitCode, fitWide, fitImages, fitCrops, fitSteps, fitStages, shrink };
268
287
  })(typeof window !== "undefined" ? window : globalThis);
@@ -65,7 +65,8 @@
65
65
  }
66
66
 
67
67
  // ---------- contador ----------
68
- function fmtNum(v, dec) { return v.toLocaleString("pt-BR", { minimumFractionDigits: dec, maximumFractionDigits: dec }); }
68
+ // no idioma do deck (<html lang>, que vem do lang do deck): em inglês "1,403" e "0.93"
69
+ function fmtNum(v, dec) { return v.toLocaleString((typeof document !== "undefined" && document.documentElement.lang) || "pt-BR", { minimumFractionDigits: dec, maximumFractionDigits: dec }); }
69
70
  function runCounter(c, instant) {
70
71
  const to = +c.dataset.to, from = +c.dataset.from, dec = +c.dataset.dec, cv = $(".cv", c);
71
72
  const pre = c.dataset.prefix, suf = c.dataset.suffix;
@@ -6,16 +6,36 @@ import path from "node:path";
6
6
  import crypto from "node:crypto";
7
7
  import { loadSpec } from "../build.js";
8
8
  import { writeRecording } from "../api-client.js";
9
- import { editDeck } from "../ai/deck-ai.js";
9
+ import { editDeck, coverDocumentVisuals, checkNumbers } from "../ai/deck-ai.js";
10
10
  import { reviewExperience } from "../ai/quality.js";
11
11
  import { maybeResearch } from "../research/chat-research.js";
12
12
  import { chatErrorResult } from "./errors.js";
13
13
  import { cancellable, respond } from "./ai-response.js";
14
- import { prepareDocumentMaterials, takeMaterials } from "../ai/document-materials.js";
14
+ import { prepareDocumentMaterials, takeMaterials, storedDocumentMaterials } from "../ai/document-materials.js";
15
+ import { critiqueMaterials, critiqueMarkdown, saveCritique, loadCritique, critiqueMaterial, markCritiqueItem } from "../ai/critique.js";
15
16
  import { styleAction } from "./style-routes.js";
16
17
  import * as Project from "./project.js";
17
18
  import { carryVisualEdits } from "./visual-keys.js";
18
19
 
20
+ // Arquivos que a IA pode pedir para ver (ver: […]): imagens DENTRO da pasta da apresentação (os recortes e as
21
+ // páginas do material ficam em contexto/visuais/). Caminho que sai da pasta, que não existe ou que não é imagem fica
22
+ // de fora; no máximo 6.
23
+ export function lookableFiles(deckDir, wanted = []) {
24
+ if (!deckDir) return [];
25
+ const root = path.resolve(deckDir);
26
+ const out = [];
27
+ for (const w of wanted) {
28
+ const rel = String(w || "").replace(/\\/g, "/").replace(/^\.\//, "").trim();
29
+ if (!rel || !/\.(png|jpe?g|webp|gif)$/i.test(rel)) continue;
30
+ const abs = path.resolve(root, rel);
31
+ if (abs !== root && !abs.startsWith(root + path.sep)) continue;
32
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
33
+ if (!out.some((o) => o.abs === abs)) out.push({ rel, abs });
34
+ if (out.length >= 6) break;
35
+ }
36
+ return out;
37
+ }
38
+
19
39
  export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, staleTab, llmAvailable, llmConfig, lookAt, withBase, apiBlocked, ensureEnsaio, readPastedLinks, imageOptions, apiContextFor, commandRunner, diagramCheck, slideSnapshots, persist, isBundledTemplate, runTransform, transforms, apiEnv }) {
20
40
  if (pathname === "/api/ai/chat/cancel" && req.method === "POST") {
21
41
  const body = await readJSON(req);
@@ -25,6 +45,52 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
25
45
  res.end(JSON.stringify({ ok: true, stopped: !!job }));
26
46
  return true;
27
47
  }
48
+ // Leitura crítica do material da apresentação aberta (botão do chat): src/ai/critique.js. Nada muda nos slides; o
49
+ // resultado fica em .sagadeck/leitura-critica.json (acompanha os próximos pedidos ao chat) e volta como conversa.
50
+ if (pathname === "/api/ai/critique" && req.method === "POST") {
51
+ const body = await readJSON(req);
52
+ if (staleTab(body, W, res)) return true;
53
+ const deckDir = W.file && !isBundledTemplate(W.file) ? path.dirname(W.file) : null;
54
+ const spec = body.spec || W.spec;
55
+ await respond(res, body.stream, async (emit) => {
56
+ const P = deckDir ? Project.projectOf(W.file) : null;
57
+ let materials = deckDir ? storedDocumentMaterials(deckDir) : [];
58
+ if (!materials.length && P) materials = (await Project.contextMaterials(P)).filter((m) => String(m.text || "").length > 1500);
59
+ if (!materials.length) return { talk: true, reply: "Esta apresentação não tem material anexado para ler criticamente. Anexe o documento (PDF, Word) no chat e peça de novo.", options: [] };
60
+ if (!(await llmAvailable({ force: true }))) return { talk: true, reply: `A IA está desligada (nenhum LLM respondendo em ${llmConfig().url}).`, options: [] };
61
+ emit({ phase: "step", text: `Lendo ${materials.map((m) => m.name).join(", ")} com olho crítico…` });
62
+ const c = await critiqueMaterials(materials, { spec, briefing: spec?.context ? JSON.stringify(spec.context) : "" });
63
+ saveCritique(deckDir, c);
64
+ const autor = spec?.context?.autoria !== "livre";
65
+ const options = c.itens.length
66
+ ? (autor
67
+ ? ["Leve para os slides os achados que estão no material", "Ponha as perguntas prováveis e as inconsistências nas notes", "Corrija os slides apontados", "Só queria ler, obrigado"]
68
+ : ["Transforme os achados em conteúdo, marcando o que é do sagadeck", "Crie um slide de discussão crítica", "Corrija os slides apontados", "Só queria ler, obrigado"])
69
+ : [];
70
+ const head = c.itens.length
71
+ ? `Li ${c.material.join(", ")} como um revisor da área. ${c.itens.length} ponto(s), cada um com o trecho do material que o sustenta (conferido no texto). ${autor ? "Como a apresentação é do próprio autor, nada disso entra nos slides sem você pedir: o que é achado do material pode ir para os slides; crítica e perguntas, para as notes." : "Como a apresentação é livre, posso transformar isso em conteúdo, marcando o que é do sagadeck."}`
72
+ : "Não achei nada com trecho conferido no material para apontar.";
73
+ // os pontos vão também estruturados: o chat mostra cada um como cartão, ligado ao slide, com Aplicar e Ignorar
74
+ return { talk: true, reply: `${head}\n\n${critiqueMarkdown(c)}`, critique: { itens: c.itens, autor, summary: `${head}${c.naoConfirmados.length ? `
75
+
76
+ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado não foi encontrado no material._` : ""}` }, options, actions: [`Leitura crítica: ${c.itens.length} ponto(s) conferido(s)${c.naoConfirmados.length ? `, ${c.naoConfirmados.length} descartado(s) sem trecho no material` : ""}`] };
77
+ });
78
+ return true;
79
+ }
80
+ // cartão da leitura crítica: a pessoa aplicou ou ignorou o ponto (ignorado deixa de acompanhar os pedidos)
81
+ if (pathname === "/api/ai/critique/item" && req.method === "POST") {
82
+ const body = await readJSON(req);
83
+ const deckDir = W.file && !isBundledTemplate(W.file) ? path.dirname(W.file) : null;
84
+ try {
85
+ const item = markCritiqueItem(deckDir, String(body.id || ""), String(body.status || ""));
86
+ res.writeHead(200, { "Content-Type": "application/json" });
87
+ res.end(JSON.stringify({ ok: true, item }));
88
+ } catch (e) {
89
+ res.writeHead(400, { "Content-Type": "application/json" });
90
+ res.end(JSON.stringify({ error: e.message }));
91
+ }
92
+ return true;
93
+ }
28
94
  if (pathname === "/api/ai/chat" && req.method === "POST") {
29
95
  const body = await readJSON(req);
30
96
  if (staleTab(body, W, res)) return true;
@@ -69,6 +135,9 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
69
135
  // arquivos do projeto (contexto/): material para a IA, junto com os anexos da mensagem
70
136
  const P = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? Project.projectOf(taskWorkspace.file) : null;
71
137
  if (P) for (const doc of await Project.contextMaterials(P)) if (!materials.some((m) => m.name === doc.name || m.name === doc.name.split("/").pop())) materials.push(doc);
138
+ // a leitura crítica já feita (geração ou botão) acompanha o pedido, rotulada como do sagadeck
139
+ const critique = critiqueMaterial(loadCritique(P ? path.dirname(taskWorkspace.file) : null));
140
+ if (critique) materials.push(critique);
72
141
  // links colados na mensagem: o servidor lê sozinho e conta nas ações
73
142
  const linkActions = [];
74
143
  for (const doc of await readPastedLinks(taskWorkspace, prompt, linkActions)) materials.push(doc);
@@ -77,9 +146,10 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
77
146
  const chatResearch = await maybeResearch({ prompt, materials, saveDir: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? path.dirname(taskWorkspace.file) : null, onProgress: (s) => emit({ phase: "step", text: s }) });
78
147
  materials = chatResearch.materials;
79
148
  if (chatResearch.report?.fontes?.length) linkActions.push(`Pesquisa na web: ${chatResearch.report.fontes.length} fonte(s) (${chatResearch.report.fontes.map((f) => f.site).join(", ")})`);
80
- result = await editDeck({
149
+ const firstInstruction = chatResearch.instruction ? `${chatResearch.instruction}\n${prompt}` : prompt;
150
+ const editOpts = (extra = {}) => ({
81
151
  spec: withBase(taskWorkspace, spec), signal,
82
- instruction: chatResearch.instruction ? `${chatResearch.instruction}\n${prompt}` : prompt,
152
+ instruction: firstInstruction,
83
153
  targetSlide: target,
84
154
  issues,
85
155
  images: true, // a IA decide (regra no prompt: só quando pedirem ou aceitarem)
@@ -94,8 +164,53 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
94
164
  runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
95
165
  reviewCheck: (deck, indices) => reviewExperience(deck, indices, { snapshot: slideSnapshots, onProgress: emit, signal, briefing:prompt }),
96
166
  styles: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? { list: taskWorkspace.library.listStyles(), current: taskWorkspace.spec?.style?.name || null } : null,
167
+ ...extra,
97
168
  });
169
+ result = await editDeck(editOpts());
98
170
  signal.throwIfAborted();
171
+ // A IA pediu para VER o material (ver: [recortes, páginas]): do documento ela só recebe o texto e o
172
+ // inventário. O Studio carrega as imagens (só arquivos da pasta desta apresentação) e chama de novo, com elas
173
+ // anexadas; até 2 rodadas. Antes ela respondia "não tenho acesso ao PDF" ou dizia ter visto o que não viu.
174
+ const deckDir = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? path.dirname(taskWorkspace.file) : null;
175
+ const seen = [];
176
+ for (let round = 1; result.look?.length && round <= 2; round++) {
177
+ const files = lookableFiles(deckDir, result.look);
178
+ const missing = result.look.filter((f) => !files.some((x) => x.rel === f.replace(/\\/g, "/").replace(/^\.\//, "")));
179
+ emit({ phase: "step", text: files.length ? `Olhando o material: ${files.map((f) => path.basename(f.rel)).join(", ")}…` : "Os arquivos pedidos não estão na pasta da apresentação." });
180
+ const { imagesAsDataUrls } = await import("../import/crop.js");
181
+ const urls = files.length ? await imagesAsDataUrls(files.map((f) => f.abs), { width: 1600, quality: 0.9 }) : [];
182
+ signal.throwIfAborted();
183
+ files.forEach((f, k) => urls[k] && seen.push({ label: `material: ${f.rel}`, dataUrl: urls[k] }));
184
+ result.actions = [...(result.actions || []), files.length ? `Olhei no material: ${files.map((f) => f.rel).join(", ")}` : "Pedido de ver o material sem arquivo válido"];
185
+ const before = result;
186
+ result = await editDeck(editOpts({
187
+ visuals: [...visuals, ...seen],
188
+ history: [...history, { role: "user", text: prompt }, { role: "assistant", text: before.reply }],
189
+ instruction: `${files.length ? `Aqui estão as imagens do material que você pediu para ver (${files.map((f) => f.rel).join(", ")}), anexadas com o rótulo "material: <arquivo>".` : "Nenhuma das imagens pedidas existe na pasta da apresentação."}${missing.length ? ` Não encontrei: ${missing.join(", ")} (use os caminhos exatos do inventário: recortes em items[].image e páginas inteiras em pages).` : ""} Agora atenda o pedido da pessoa já vendo, sem pedir de novo o que já está aqui:\n"""\n${firstInstruction}\n"""`,
190
+ }));
191
+ signal.throwIfAborted();
192
+ result.actions = [...before.actions, ...(result.actions || [])];
193
+ }
194
+ if (result.look?.length) result = { ...result, look: undefined, talk: true, reply: `${result.reply}\n(Já mostrei as imagens duas vezes; me diga o que procurar nelas.)` };
195
+ // documento anexado NESTA mensagem e a resposta montou/reescreveu a maior parte do deck (criar a apresentação
196
+ // do paper pelo chat): a mesma conferência de cobertura da geração. Pedido pontual ("use a figura 3 no slide
197
+ // 5") não dispara: mexe em poucos slides.
198
+ const attachedDocs = (Array.isArray(body.attachments) ? body.attachments : []).some((a) => a && typeof a === "object" && a.type === "doc");
199
+ if (attachedDocs && !result.talk && !result.transform && !result.style && !result.variants && result.spec?.slides) {
200
+ const rewritten = Array.isArray(result.changed) ? result.changed.length : 0;
201
+ if (rewritten >= Math.max(5, result.spec.slides.length / 2)) {
202
+ const covered = await coverDocumentVisuals(result.spec, { materials, briefing: prompt, say: (s) => emit({ phase: "step", text: s }),
203
+ edit: (o) => editDeck({ signal, images: true, imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
204
+ signal.throwIfAborted();
205
+ if (covered.spec !== result.spec) { result.spec = covered.spec; result.actions = [...(result.actions || []), ...covered.actions]; }
206
+ if (covered.coverage?.missing.length) result.actions = [...(result.actions || []), `Sem slide (figuras/tabelas do material): ${covered.coverage.missing.join("; ")}`];
207
+ const checked = await checkNumbers(result.spec, { materials, say: (s) => emit({ phase: "step", text: s }),
208
+ edit: (o) => editDeck({ signal, images: true, imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
209
+ signal.throwIfAborted();
210
+ if (checked.spec !== result.spec) { result.spec = checked.spec; result.actions = [...(result.actions || []), ...checked.actions]; }
211
+ if (checked.facts?.unsupported.length) result.actions = [...(result.actions || []), `Números sem base no material: ${checked.facts.unsupported.map((b) => `${b.number} (slide ${b.slide})`).join(", ")}`];
212
+ }
213
+ }
99
214
  if (linkActions.length) result.actions = [...linkActions, ...(result.actions || [])];
100
215
  // a IA decidiu transformar a apresentação inteira (transform:): o trabalho em etapas, com o andamento aqui
101
216
  if (result.transform) {
@@ -0,0 +1,35 @@
1
+ // Impressão do código do sagadeck (caminho, data e tamanho de cada arquivo). O Studio guarda a de quando subiu e a
2
+ // página consulta de tempos em tempos: se o código mudou no disco (git pull, npm update, merge), o servidor que está
3
+ // rodando ainda é o velho e a página avisa "reinicie". Antes, um Studio aberto antes de uma atualização recebia
4
+ // arquivos novos do disco com o servidor velho e quebrava em silêncio (nenhum slide abria).
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import crypto from "node:crypto";
8
+
9
+ const CODE = /\.(m?js|html|css|json)$/i;
10
+
11
+ export function codeFingerprint(root) {
12
+ const h = crypto.createHash("sha1");
13
+ const walk = (dir) => {
14
+ let entries;
15
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
16
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
17
+ if (e.name === "node_modules" || e.name.startsWith(".")) continue;
18
+ const p = path.join(dir, e.name);
19
+ if (e.isDirectory()) walk(p);
20
+ else if (CODE.test(e.name)) { try { const st = fs.statSync(p); h.update(`${path.relative(root, p)}|${st.mtimeMs}|${st.size}\n`); } catch {} }
21
+ }
22
+ };
23
+ walk(root);
24
+ return h.digest("hex").slice(0, 16);
25
+ }
26
+
27
+ // a que vale para o servidor: a de quando ele subiu × a de agora (recalculada no máximo a cada poucos segundos)
28
+ export function codeWatch(root, { every = 3000 } = {}) {
29
+ const started = codeFingerprint(root);
30
+ let now = started, at = Date.now();
31
+ return () => {
32
+ if (Date.now() - at > every) { now = codeFingerprint(root); at = Date.now(); }
33
+ return { started, now, changed: now !== started };
34
+ };
35
+ }
@@ -0,0 +1,53 @@
1
+ // Gerar uma apresentação pelo caminho do Studio ("Criar com IA" e chat de deck novo): Preferências, anexos guardados
2
+ // na pasta do deck (com o inventário visual), imagens, revisão pelos slides renderizados e pesquisa. Fica fora do
3
+ // servidor para a avaliação ao vivo (test/bench-live.test.js) medir exatamente o que a pessoa recebe.
4
+ import path from "node:path";
5
+ import { generateDeck } from "../ai/deck-ai.js";
6
+ import { prepareDocumentMaterials } from "../ai/document-materials.js";
7
+ import { reviewExperience } from "../ai/quality.js";
8
+ import { stageBoard } from "../ai/progress.js";
9
+ import { loadPreferences } from "../preferences.js";
10
+ import { slideSnapshots, diagramCheck } from "./snapshot.js";
11
+
12
+ const DOCUMENT = /\.(pdf|docx?)$/i;
13
+
14
+ // b: o corpo do pedido (briefing, answer, theme, style, slides, duration, direction); materials: [{ name, text, detail, bytes }]
15
+ // O inventário do documento (a visão lendo página a página: minutos num paper) corre JUNTO com o resto: entender o
16
+ // pedido, pesquisar e a leitura crítica usam só o texto; a escrita espera o inventário. Parou antes (a IA perguntou,
17
+ // deu erro): o inventário é cancelado e a pasta fica livre.
18
+ export async function generateForStudio(b, { dir, materials = [], emit, prefs = loadPreferences().ia, prepare = prepareDocumentMaterials } = {}) {
19
+ const board = stageBoard((ev) => emit?.(ev));
20
+ const stop = new AbortController();
21
+ const withBytes = materials.some((m) => m.bytes);
22
+ const pending = withBytes ? board.run("Lendo o documento", (onProgress) => prepare(materials, dir, { onProgress, signal: stop.signal })) : null;
23
+ pending?.catch(() => {}); // quem espera é a geração; se ela parou antes, o erro do cancelamento não importa
24
+ // enquanto isso, o texto do documento (o binário fica com o inventário); `document` marca que é um documento
25
+ const text = materials.map(({ bytes, ...m }) => (bytes && DOCUMENT.test(m.name || "") ? { ...m, document: true } : m));
26
+ try {
27
+ const gen = await generateDeck(String(b.briefing || ""), {
28
+ ask: prefs.perguntar !== false && !b.answer, answer: b.answer ? String(b.answer).slice(0, 500) : "",
29
+ author: prefs.autor || "", language: prefs.idioma || "auto",
30
+ theme: b.theme || undefined,
31
+ style: b.style || undefined,
32
+ slides: Number(b.slides) || undefined,
33
+ duration: Number(b.duration) || undefined,
34
+ direction: b.direction || undefined,
35
+ materials: text,
36
+ pendingMaterials: pending,
37
+ board,
38
+ images: prefs.imagens !== false, // o briefing diz se quer imagens (e onde); Preferências podem desligar
39
+ imageOptions: { baseDir: dir, assetsDir: path.join(dir, "imagens") },
40
+ onEvent: emit,
41
+ drawCheck: diagramCheck,
42
+ reviewCheck: (deck, indices, o = {}) => reviewExperience({ ...deck, _dir: dir }, indices, { snapshot: slideSnapshots, onProgress: o.onProgress || emit, briefing: b.briefing }),
43
+ // pesquisa na web quando a IA decidir que precisa (Preferências › IA pode desligar; SAGADECK_WEB=0 no banco);
44
+ // as fontes lidas ficam em contexto/pesquisa/ do deck
45
+ research: prefs.pesquisa === false ? false : "auto",
46
+ researchDir: dir,
47
+ });
48
+ return gen;
49
+ } finally {
50
+ stop.abort();
51
+ await pending?.catch(() => {});
52
+ }
53
+ }