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.
- package/README.md +4 -4
- package/bin/sagadeck.js +9 -1
- package/docs/ANDAMENTO.md +285 -0
- package/docs/REFERENCIA.md +2 -2
- package/package.json +1 -1
- package/src/ai/bench.js +132 -0
- package/src/ai/context.js +4 -2
- package/src/ai/critique.js +114 -0
- package/src/ai/deck-ai.js +141 -31
- package/src/ai/document-visuals.js +7 -3
- package/src/ai/progress.js +23 -0
- package/src/ai/quality.js +30 -2
- package/src/api-client.js +2 -1
- package/src/build.js +3 -0
- package/src/decision-lab.js +2 -1
- package/src/deck-file.js +2 -1
- package/src/elements.js +10 -3
- package/src/figures/charts.js +7 -4
- package/src/figures/diagrams.js +15 -1
- package/src/figures/ufmap.js +2 -1
- package/src/fs-retry.js +19 -0
- package/src/layouts.js +1 -1
- package/src/library.js +68 -16
- package/src/locale.js +18 -0
- package/src/mcp/server.js +2 -2
- package/src/motion.js +4 -2
- package/src/preferences.js +2 -1
- package/src/runtime/calc.js +3 -3
- package/src/runtime/decision-lab.js +1 -1
- package/src/runtime/runtime.js +2 -1
- package/src/studio/ai-chat-route.js +99 -3
- package/src/studio/code-version.js +35 -0
- package/src/studio/generate.js +53 -0
- package/src/studio/project.js +8 -7
- package/src/studio/public/app.js +7 -216
- package/src/studio/public/chat.js +71 -1
- package/src/studio/public/header-footer.js +140 -0
- package/src/studio/public/index.html +5 -0
- package/src/studio/public/library.html +1 -0
- package/src/studio/public/library.js +4 -3
- package/src/studio/public/preferences-ui.js +83 -0
- package/src/studio/public/provenance.js +13 -0
- package/src/studio/public/style.css +17 -0
- package/src/studio/public/ui-icons.js +1 -1
- package/src/studio/public/update-notice.js +25 -0
- package/src/studio/public-files.js +11 -1
- package/src/studio/server.js +20 -29
- 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 {
|
|
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 (
|
|
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
|
|
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
|
}
|
package/src/figures/charts.js
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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):
|
|
202
|
-
const labels = (o.labels || series[0].values.map((_, i) => String(i + 1))).map((l) => (typeof l === "number" ? 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
|
package/src/figures/diagrams.js
CHANGED
|
@@ -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
|
|
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);
|
package/src/figures/ufmap.js
CHANGED
|
@@ -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
|
|
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) {
|
package/src/fs-retry.js
ADDED
|
@@ -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")}>${
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
69
|
-
.replace(/[. ]+$/, "")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
+
renameRetry(abs, dest);
|
|
287
339
|
const final = path.join(dest, path.basename(dest) + ".yaml");
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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>`;}
|
package/src/preferences.js
CHANGED
|
@@ -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
|
-
|
|
60
|
+
renameRetry(tmp, file);
|
|
60
61
|
return out;
|
|
61
62
|
}
|
package/src/runtime/calc.js
CHANGED
|
@@ -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(
|
|
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;
|
package/src/runtime/runtime.js
CHANGED
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// ---------- contador ----------
|
|
68
|
-
|
|
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
|
-
|
|
149
|
+
const firstInstruction = chatResearch.instruction ? `${chatResearch.instruction}\n${prompt}` : prompt;
|
|
150
|
+
const editOpts = (extra = {}) => ({
|
|
81
151
|
spec: withBase(taskWorkspace, spec), signal,
|
|
82
|
-
instruction:
|
|
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.
|