sagadeck 1.4.0 → 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 (48) hide show
  1. package/README.md +4 -4
  2. package/bin/sagadeck.js +9 -1
  3. package/docs/ANDAMENTO.md +285 -0
  4. package/docs/REFERENCIA.md +2 -2
  5. package/package.json +1 -1
  6. package/src/ai/bench.js +132 -0
  7. package/src/ai/context.js +4 -2
  8. package/src/ai/critique.js +114 -0
  9. package/src/ai/deck-ai.js +141 -31
  10. package/src/ai/document-visuals.js +7 -3
  11. package/src/ai/progress.js +23 -0
  12. package/src/ai/quality.js +30 -2
  13. package/src/api-client.js +2 -1
  14. package/src/build.js +3 -0
  15. package/src/decision-lab.js +2 -1
  16. package/src/deck-file.js +2 -1
  17. package/src/elements.js +10 -3
  18. package/src/figures/charts.js +7 -4
  19. package/src/figures/diagrams.js +15 -1
  20. package/src/figures/ufmap.js +2 -1
  21. package/src/fs-retry.js +19 -0
  22. package/src/layouts.js +1 -1
  23. package/src/library.js +68 -16
  24. package/src/locale.js +18 -0
  25. package/src/mcp/server.js +2 -2
  26. package/src/motion.js +4 -2
  27. package/src/preferences.js +2 -1
  28. package/src/runtime/calc.js +3 -3
  29. package/src/runtime/decision-lab.js +1 -1
  30. package/src/runtime/runtime.js +2 -1
  31. package/src/studio/ai-chat-route.js +99 -3
  32. package/src/studio/code-version.js +35 -0
  33. package/src/studio/generate.js +53 -0
  34. package/src/studio/project.js +8 -7
  35. package/src/studio/public/app.js +7 -216
  36. package/src/studio/public/chat.js +71 -1
  37. package/src/studio/public/header-footer.js +140 -0
  38. package/src/studio/public/index.html +5 -0
  39. package/src/studio/public/library.html +1 -0
  40. package/src/studio/public/library.js +4 -3
  41. package/src/studio/public/preferences-ui.js +83 -0
  42. package/src/studio/public/provenance.js +13 -0
  43. package/src/studio/public/style.css +17 -0
  44. package/src/studio/public/ui-icons.js +1 -1
  45. package/src/studio/public/update-notice.js +25 -0
  46. package/src/studio/public-files.js +11 -1
  47. package/src/studio/server.js +20 -29
  48. package/src/studio/share-routes.js +2 -1
package/src/deck-file.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // 3. o texto final é relido e comparado com o deck: se não bater, grava o deck inteiro (correção primeiro);
9
9
  // 4. a troca é atômica: escreve num temporário ao lado e renomeia (nunca fica um arquivo pela metade).
10
10
  import fs from "node:fs";
11
+ import { renameRetry } from "./fs-retry.js";
11
12
  import path from "node:path";
12
13
  import { isDeepStrictEqual } from "node:util";
13
14
  import YAML from "yaml";
@@ -114,6 +115,6 @@ export function writeDeckFile(file, spec) {
114
115
  if (text === current) return false;
115
116
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
116
117
  fs.writeFileSync(tmp, text, "utf8");
117
- try { fs.renameSync(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
118
+ try { renameRetry(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
118
119
  return true;
119
120
  }
package/src/elements.js CHANGED
@@ -6,6 +6,7 @@ import { videoPlayer } from './video-player.js';
6
6
  import { tableHTML } from "./table.js";
7
7
  import { qrSVG } from "./figures/qr.js";
8
8
  import katex from "katex";
9
+ import { formatNumber, decimalSeparator } from "./locale.js";
9
10
  import fs from "node:fs";
10
11
  import path from "node:path";
11
12
  import { md, esc } from "./markup.js";
@@ -267,7 +268,7 @@ export function el(e, ctx, w, h) {
267
268
  if (e.badge) return `<div${attrs(e, "badge f-label")}>${md(e.badge)}</div>`;
268
269
  if (e.aviso) return aviso(e);
269
270
  if (e.video) return video(e, ctx);
270
- if (e.motion) return `<div${attrs(e, "motion")}>${motionHTML(e.motion,ctx?.theme?.colors)}</div>`;
271
+ if (e.motion) return `<div${attrs(e, "motion")}>${motionHTML(e.motion,ctx?.theme?.colors,{still:ctx?.spec?.motion==='none'})}</div>`;
271
272
  if (e.widget) return `<div${attrs(e, "widget")} data-widget="${esc(e.widget)}" data-opts="${esc(JSON.stringify(e))}"></div>`;
272
273
  if (e.html) return `<div${attrs(e, "raw")}>${e.html}</div>`;
273
274
  if (e.spacer != null) return `<div class="spacer" style="flex:${e.spacer === true ? 1 : 0} 0 ${px(e.spacer === true ? 0 : e.spacer)}"></div>`;
@@ -394,7 +395,13 @@ export function parseCounterValue(raw) {
394
395
  const m = /^(\D*?)(-?\d[\d.,]*)(.*)$/s.exec(String(raw ?? "").trim());
395
396
  if (!m) return null;
396
397
  let num = m[2].replace(/[.,]$/, ""), dec = 0;
397
- if (num.includes(",")) { dec = num.split(",").pop().length; num = num.replace(/\./g, "").replace(",", "."); }
398
+ if (decimalSeparator() === ".") {
399
+ // deck em inglês: vírgula é milhar ("1,403"), ponto é decimal ("37.67")
400
+ num = num.replace(/,(?=\d{3}(?:\D|$))/g, "");
401
+ if (num.includes(".")) dec = num.split(".").pop().length;
402
+ else if (num.includes(",")) { dec = num.split(",").pop().length; num = num.replace(",", "."); }
403
+ }
404
+ else if (num.includes(",")) { dec = num.split(",").pop().length; num = num.replace(/\./g, "").replace(",", "."); }
398
405
  else if (/^-?\d{1,3}(\.\d{3})+$/.test(num)) num = num.replace(/\./g, "");
399
406
  else if (num.includes(".")) dec = num.split(".").pop().length;
400
407
  const value = Number(num);
@@ -407,7 +414,7 @@ export function counter(e) {
407
414
  e = { ...e, prefix: unicodeScripts(`${e.prefix ?? ""}${parsed.prefix ?? ""}`), suffix: unicodeScripts(`${parsed.suffix ?? ""}${e.suffix ?? ""}`), decimals: e.decimals ?? (typeof e.counter === "number" ? undefined : parsed.dec) };
408
415
  const v = parsed.value;
409
416
  const dec = e.decimals ?? (v % 1 ? 1 : 0);
410
- const shown = v.toLocaleString("pt-BR", { minimumFractionDigits: dec, maximumFractionDigits: dec });
417
+ const shown = formatNumber(v, { minimumFractionDigits: dec, maximumFractionDigits: dec }); // no idioma do deck
411
418
  const size = e.size || SIZES[e.as || "number"];
412
419
  return `<div${attrs(e, `t f-display r-number counter pl`, `font-size:${size}px;`)}${e.fit ? " data-fit" : ""} data-to="${v}" data-from="${e.from ?? 0}" data-dec="${dec}" data-prefix="${esc(e.prefix || "")}" data-suffix="${esc(e.suffix || "")}"><span class="cv">${esc(e.prefix || "")}${shown}${esc(e.suffix || "")}</span></div>`;
413
420
  }
@@ -12,6 +12,7 @@
12
12
  // Cores: por padrão usam o tom do slide (--cb base, --ca destaque, --s1..--s5 séries).
13
13
  // Qualquer item aceita color: hi | em | fg | muted | "FF0000".
14
14
  import { esc } from "../markup.js";
15
+ import { formatNumber } from "../locale.js";
15
16
  import { iconSVG } from "./icons.js";
16
17
  import { humanBody } from "./pictos.js";
17
18
 
@@ -24,8 +25,10 @@ const cvar = (c, d) => {
24
25
  };
25
26
  const fmt = (v, o) => {
26
27
  if (v == null || isNaN(v)) return "";
27
- const d = o.decimals ?? (Math.abs(v) < 10 && v % 1 ? 1 : 0);
28
- const s = Number(v).toLocaleString("pt-BR", { minimumFractionDigits: d, maximumFractionDigits: d });
28
+ // sem decimals pedido: as casas do próprio valor, até 2 (0.93 aparecia "0.9" com a regra antiga de 1 casa
29
+ // abaixo de 10, e 37.67 aparecia "38": num gráfico de paper, a precisão relatada é dado)
30
+ const d = o.decimals ?? Math.min(2, (String(Number(v)).split(".")[1] || "").length);
31
+ const s = formatNumber(v, { minimumFractionDigits: d, maximumFractionDigits: d }); // no idioma do deck (lang)
29
32
  return `${o.prefix || ""}${s}${o.suffix || ""}`;
30
33
  };
31
34
  const norm = (o) => {
@@ -198,8 +201,8 @@ export function niceTicks(min, max, want = 5) {
198
201
  }
199
202
  function line(o, W, H) {
200
203
  const series = o.series || [{ values: o.values }];
201
- // rótulo que veio como número (0.5): com vírgula, como o resto do gráfico
202
- const labels = (o.labels || series[0].values.map((_, i) => String(i + 1))).map((l) => (typeof l === "number" ? l.toLocaleString("pt-BR", { useGrouping: false }) : l));
204
+ // rótulo que veio como número (0.5): no formato do resto do gráfico (vírgula em pt-BR, ponto em inglês)
205
+ const labels = (o.labels || series[0].values.map((_, i) => String(i + 1))).map((l) => (typeof l === "number" ? formatNumber(l, { useGrouping: false }) : l));
203
206
  const all = series.flatMap((s) => s.values).filter((v) => v != null);
204
207
  const min = o.min ?? Math.min(0, ...all), max = o.max ?? Math.max(...all) * 1.1;
205
208
  // nomes no fim das linhas, a não ser que se encostem (as linhas terminam juntas) ou o slide peça legenda: aí, legenda em cima
@@ -82,6 +82,20 @@ export function spectrum(o = {}) {
82
82
  return wrap([0, 0, W, 190], g, "spectrum");
83
83
  }
84
84
 
85
+ // Tamanho da letra que cabe na caixa (largura × altura): simula a quebra por palavras e desce de 36 até 18 px.
86
+ // Com 36 px fixos, "Low-lying areas near the drainage network" pedia 4 linhas e vazava por baixo da caixa.
87
+ export function boxFontSize(text, w, h, { max = 36, min = 18, lh = 1.15, em = 0.56 } = {}) {
88
+ const words = String(text || "").split(/\s+/).filter(Boolean);
89
+ for (let fs = max; fs > min; fs -= 2) {
90
+ const cpl = Math.max(1, Math.floor(w / (fs * em)));
91
+ if (words.some((wd) => wd.length > cpl)) continue;
92
+ let lines = 1, len = 0;
93
+ for (const wd of words) { if (len && len + 1 + wd.length > cpl) { lines++; len = wd.length; } else len += (len ? 1 : 0) + wd.length; }
94
+ if (lines * fs * lh <= h) return fs;
95
+ }
96
+ return min;
97
+ }
98
+
85
99
  export function flow(o = {}) {
86
100
  const steps = o.steps || [];
87
101
  const n = steps.length, bw = 300, gap = 90, bh = 150, vgap = 130;
@@ -90,7 +104,7 @@ export function flow(o = {}) {
90
104
  const id = `ar${++uid}`;
91
105
  const box = (x, y, s, hl) =>
92
106
  `<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>`;
107
+ `<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;line-height:1.15;font-size:${boxFontSize(s, bw - 32, bh - 20)}px;color:var(${hl ? "--on-hi" : "--fg"})">${esc(s)}</div></foreignObject>`;
94
108
  const arrow = (x1, y1, x2, y2) =>
95
109
  `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" style="stroke:var(--fg)" stroke-width="8" marker-end="url(#${id})"/>`;
96
110
  let g = arrowDefs(id);
@@ -1,6 +1,7 @@
1
1
  // Mapa do Brasil por UF em grade (cartograma de ladrilhos): cada estado é um quadrado na posição aproximada dele.
2
2
  // Sem geodados de fora (nada pede coisa à internet) e legível em slide: a cor diz o valor, o rótulo diz a UF.
3
3
  import { esc } from "../markup.js";
4
+ import { formatNumber } from "../locale.js";
4
5
 
5
6
  // [coluna, linha] de cada UF, do norte ao sul, do oeste ao leste
6
7
  export const UF_GRID = {
@@ -14,7 +15,7 @@ export const UF_GRID = {
14
15
  RS: [1, 7],
15
16
  };
16
17
 
17
- const fmt = (v) => (Number.isFinite(v) ? v.toLocaleString("pt-BR", { maximumFractionDigits: 1 }) : "");
18
+ const fmt = (v) => (Number.isFinite(v) ? formatNumber(v, { maximumFractionDigits: 1 }) : "");
18
19
 
19
20
  // o: { ufmap: { SP: 120, RJ: 80, … }, suffix, prefix, highlight: [SP], legend: "texto", showValues }
20
21
  export function ufmap(o, warnings) {
@@ -0,0 +1,19 @@
1
+ // Renomear tolerante a arquivo travado. A biblioteca mora em Documentos, que no Windows costuma estar no OneDrive:
2
+ // enquanto sincroniza (e também o antivírus ou o indexador), o arquivo fica preso por um instante e o rename falha
3
+ // com EPERM/EBUSY/EACCES. Em vez de perder o salvamento da pessoa, tenta de novo com espera curta e crescente
4
+ // (~1 s no total). Qualquer outro erro (destino inexistente, disco cheio…) sobe na hora.
5
+ import fs from "node:fs";
6
+
7
+ const LOCKED = new Set(["EPERM", "EBUSY", "EACCES"]);
8
+ export const RENAME_DELAYS = [20, 40, 80, 150, 250, 450];
9
+
10
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
+
12
+ export function renameRetry(from, to, { delays = RENAME_DELAYS } = {}) {
13
+ for (let i = 0; ; i++) {
14
+ try { return fs.renameSync(from, to); } catch (e) {
15
+ if (!LOCKED.has(e?.code) || i >= delays.length) throw e;
16
+ pause(delays[i]);
17
+ }
18
+ }
19
+ }
package/src/layouts.js CHANGED
@@ -415,7 +415,7 @@ export const LAYOUTS = {
415
415
  ${c.title ? text(c.title, "h3", { class: "cp-title" }) : ""}
416
416
  ${c.text ? text(c.text, "body", { class: "cp-text" }) : ""}
417
417
  ${c.items ? list({ list: c.items, size: 32 }) : ""}</div>`;
418
- return `<div class="L-compare">${head(s)}<div class="cp-row">${col(s.left, "l", 0)}<div${attrs({ step: s.right?.step ?? build(s, 1) }, "cp-vs t f-display")}>${esc(s.vs ?? "×")}</div>${col(s.right, "r", 1)}</div>
418
+ return `<div class="L-compare">${head(s)}<div class="cp-row">${col(s.left, "l", 0)}<div${attrs({ step: s.right?.step ?? build(s, 1) }, "cp-vs t f-display")}>${md(String(s.vs ?? "×"))}</div>${col(s.right, "r", 1)}</div>
419
419
  ${s.after ? text(s.after, "h3", { class: "cp-after", step: s.afterStep ?? (s.build ? 3 : 1), face: "quote", size: 50 }) : ""}</div>${src(s)}${add(s, ctx)}`;
420
420
  },
421
421
 
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
  }
@@ -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;
@@ -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;
@@ -11,11 +11,31 @@ 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,34 @@ 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.)` };
99
195
  // documento anexado NESTA mensagem e a resposta montou/reescreveu a maior parte do deck (criar a apresentação
100
196
  // do paper pelo chat): a mesma conferência de cobertura da geração. Pedido pontual ("use a figura 3 no slide
101
197
  // 5") não dispara: mexe em poucos slides.