sagadeck 1.4.0 → 1.6.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 +59 -67
- package/SKILL.md +18 -11
- package/bin/sagadeck.js +12 -11
- package/docs/ANDAMENTO.md +355 -0
- package/docs/REFERENCIA.md +4 -4
- package/package.json +2 -3
- 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 +142 -32
- package/src/ai/document-visuals.js +7 -3
- package/src/ai/ia-config.js +101 -0
- package/src/ai/llm.js +62 -26
- 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 +75 -19
- package/src/locale.js +18 -0
- package/src/mcp/server.js +2 -2
- package/src/motion.js +5 -3
- package/src/preferences.js +5 -1
- package/src/research/research.js +6 -5
- package/src/runtime/calc.js +3 -3
- package/src/runtime/decision-lab.js +1 -1
- package/src/runtime/motion.js +5 -1
- package/src/runtime/runtime.js +2 -1
- package/src/studio/ai-chat-route.js +100 -4
- package/src/studio/banner.js +18 -0
- package/src/studio/code-version.js +35 -0
- package/src/studio/generate.js +53 -0
- package/src/studio/ia-routes.js +53 -0
- package/src/studio/project.js +8 -7
- package/src/studio/public/ai-settings.js +76 -0
- package/src/studio/public/app.js +11 -239
- package/src/studio/public/chat.js +71 -1
- package/src/studio/public/header-footer.js +140 -0
- package/src/studio/public/index.html +6 -0
- package/src/studio/public/library.html +3 -1
- package/src/studio/public/library.js +7 -5
- package/src/studio/public/preferences-ui.js +86 -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 +26 -47
- package/src/studio/share-routes.js +2 -1
- package/src/studio/slide-copy.js +3 -1
- package/src/ai/relay.js +0 -45
package/src/ai/llm.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { mediaPrompt } from './art-direction.js';
|
|
2
2
|
// sagadeck · Cliente de LLM (qualquer endpoint compatível com OpenAI: /v1/chat/completions)
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Também funciona apontando direto para um provedor.
|
|
4
|
+
// Fala direto com o provedor configurado nesta máquina (~/.sagadeck/ia.json, tela Configurar IA do Studio:
|
|
5
|
+
// src/ai/ia-config.js). As variáveis abaixo valem por cima da configuração:
|
|
7
6
|
//
|
|
8
|
-
// SAGADECK_LLM_URL base da API (
|
|
9
|
-
// SAGADECK_LLM_KEY bearer token
|
|
10
|
-
// SAGADECK_TEXT_MODEL modelo de texto (padrão: "text"
|
|
11
|
-
// SAGADECK_IMAGE_MODEL modelo de imagem (padrão: "image"
|
|
12
|
-
// SAGADECK_VISION_MODEL modelo que vê imagens (padrão: "vision"
|
|
13
|
-
// imagem vai para ele em vez de perder a imagem
|
|
7
|
+
// SAGADECK_LLM_URL base da API (ex.: https://openrouter.ai/api/v1)
|
|
8
|
+
// SAGADECK_LLM_KEY bearer token (ex.: a chave do OpenRouter)
|
|
9
|
+
// SAGADECK_TEXT_MODEL modelo de texto (padrão: o "text" da configuração)
|
|
10
|
+
// SAGADECK_IMAGE_MODEL modelo de imagem (padrão: o "image" da configuração)
|
|
11
|
+
// SAGADECK_VISION_MODEL modelo que vê imagens (padrão: o "vision" da configuração; quando o de texto não enxerga,
|
|
12
|
+
// a chamada com imagem vai para ele em vez de perder a imagem)
|
|
13
|
+
// SAGADECK_SEARCH_MODEL modelo que busca na web (padrão: o "search" da configuração)
|
|
14
14
|
// SAGADECK_LLM_TIMEOUT segundos por chamada; em streaming, segundos sem chegar nada (padrão: 180)
|
|
15
15
|
// SAGADECK_LLM_FIRST_TIMEOUT em streaming, segundos até a 1ª palavra (o modelo pensa antes) (padrão: 600)
|
|
16
16
|
|
|
17
17
|
import { currentUsage, recordUsage } from './usage.js';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { pathToFileURL } from 'node:url';
|
|
20
|
+
import { iaFile, loadIA, keyOf, migrateModelrelay } from './ia-config.js';
|
|
18
21
|
import {setTimeout as delay} from 'node:timers/promises';
|
|
19
22
|
|
|
20
23
|
export class LLMError extends Error {
|
|
@@ -28,35 +31,67 @@ export class LLMError extends Error {
|
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
// De onde vem a IA: variáveis de ambiente (SAGADECK_LLM_URL…, valem por cima; é como os testes apontam para o LLM
|
|
35
|
+
// falso) ou a configuração desta máquina (~/.sagadeck/ia.json, src/ai/ia-config.js). Os papéis text, vision, image e
|
|
36
|
+
// search viram os modelos configurados; sem configuração nenhuma, a IA fica desligada (o Studio usa as regras locais).
|
|
37
|
+
let migrated = false;
|
|
31
38
|
export function llmConfig(env = process.env) {
|
|
39
|
+
const fromEnv = !!env.SAGADECK_LLM_URL;
|
|
40
|
+
if (!fromEnv && !migrated) { migrated = true; try { migrateModelrelay({ env, file: iaFile(env) }); } catch { /* segue sem */ } }
|
|
41
|
+
const ia = fromEnv ? null : loadIA(iaFile(env));
|
|
42
|
+
const m = ia?.models || {};
|
|
32
43
|
return {
|
|
33
|
-
url: (env.SAGADECK_LLM_URL || "
|
|
34
|
-
key: env.SAGADECK_LLM_KEY || "",
|
|
35
|
-
textModel: env.SAGADECK_TEXT_MODEL || "text",
|
|
36
|
-
imageModel: env.SAGADECK_IMAGE_MODEL || "image",
|
|
37
|
-
visionModel: env.SAGADECK_VISION_MODEL || "vision",
|
|
44
|
+
url: (env.SAGADECK_LLM_URL || ia?.url || "").replace(/\/+$/, ""),
|
|
45
|
+
key: env.SAGADECK_LLM_KEY || (ia ? keyOf(ia, env) : ""),
|
|
46
|
+
textModel: env.SAGADECK_TEXT_MODEL || m.text || "text",
|
|
47
|
+
imageModel: env.SAGADECK_IMAGE_MODEL || m.image || "image",
|
|
48
|
+
visionModel: env.SAGADECK_VISION_MODEL || m.vision || m.text || "vision",
|
|
49
|
+
// busca na web pelo modelo (no OpenRouter, um modelo ":online"); sem ele, a pesquisa usa os buscadores diretos
|
|
50
|
+
searchModel: env.SAGADECK_SEARCH_MODEL || m.search || (fromEnv ? "search" : ""),
|
|
51
|
+
headers: ia?.headers || {},
|
|
52
|
+
adaptador: ia?.adaptador || "",
|
|
53
|
+
source: fromEnv ? "variável" : ia ? "arquivo" : "nenhuma",
|
|
38
54
|
timeoutMs: Number(env.SAGADECK_LLM_TIMEOUT || 180) * 1000,
|
|
39
55
|
firstTimeoutMs: Number(env.SAGADECK_LLM_FIRST_TIMEOUT || 600) * 1000,
|
|
40
|
-
// o modelrelay escolhe os modelos deste app em [apps.sagadeck.models] (o resto vem de [models])
|
|
41
|
-
app: env.SAGADECK_APP || "sagadeck",
|
|
42
56
|
};
|
|
43
57
|
}
|
|
58
|
+
export const llmConfigured = (cfg = llmConfig()) => !!(cfg.url || cfg.adaptador);
|
|
59
|
+
|
|
60
|
+
// o papel (text, vision, image, search) vira o modelo configurado; outro nome passa como veio
|
|
61
|
+
export function resolveModel(cfg, name) {
|
|
62
|
+
const roles = { text: cfg.textModel, vision: cfg.visionModel, image: cfg.imageModel, search: cfg.searchModel };
|
|
63
|
+
return roles[name] || name;
|
|
64
|
+
}
|
|
44
65
|
|
|
45
66
|
function headers(cfg) {
|
|
46
|
-
const h = { "Content-Type": "application/json",
|
|
67
|
+
const h = { "Content-Type": "application/json", ...(cfg.headers || {}) };
|
|
47
68
|
if (cfg.key) h.Authorization = `Bearer ${cfg.key}`;
|
|
48
69
|
return h;
|
|
49
70
|
}
|
|
50
71
|
|
|
72
|
+
// Adaptador (opcional, da pessoa): um .mjs cujo default é (url, init) => Response. Serve para o que não é compatível
|
|
73
|
+
// com a API da OpenAI (outro jeito de autenticar, outro endereço) sem mexer no sagadeck. Sem ele, o fetch normal.
|
|
74
|
+
const adapters = new Map();
|
|
75
|
+
async function send(cfg, url, init) {
|
|
76
|
+
if (!cfg.adaptador) return fetch(url, init);
|
|
77
|
+
if (!adapters.has(cfg.adaptador)) adapters.set(cfg.adaptador, import(pathToFileURL(path.resolve(cfg.adaptador)).href).then((mod) => mod.default));
|
|
78
|
+
const fn = await adapters.get(cfg.adaptador);
|
|
79
|
+
if (typeof fn !== "function") throw new LLMError(`O adaptador ${cfg.adaptador} não exporta uma função padrão (url, init) => Response.`);
|
|
80
|
+
return fn(url, init);
|
|
81
|
+
}
|
|
82
|
+
const notConfigured = () => new LLMError("A IA não está configurada: no Studio, abra Configurar IA (ou crie ~/.sagadeck/ia.json).", { code: "AI_NOT_CONFIGURED" });
|
|
83
|
+
const howToFix = "Confira o provedor e a chave em Configurar IA, no Studio.";
|
|
84
|
+
|
|
51
85
|
// Disponibilidade com cache curto, para o Studio decidir entre LLM e as regras determinísticas.
|
|
52
86
|
let availability = { at: 0, ok: false, url: "" };
|
|
53
87
|
|
|
54
88
|
export async function llmAvailable({ force = false } = {}) {
|
|
55
89
|
const cfg = llmConfig();
|
|
90
|
+
if (!llmConfigured(cfg)) return false;
|
|
56
91
|
if (!force && availability.url === cfg.url && Date.now() - availability.at < 30_000) return availability.ok;
|
|
57
92
|
let ok = false;
|
|
58
93
|
try {
|
|
59
|
-
const res = await
|
|
94
|
+
const res = await send(cfg, `${cfg.url}/models`, { headers: headers(cfg), signal: AbortSignal.timeout(2500) });
|
|
60
95
|
ok = res.ok;
|
|
61
96
|
} catch {
|
|
62
97
|
ok = false;
|
|
@@ -106,13 +141,13 @@ export async function chat(messages, opts = {}) {
|
|
|
106
141
|
|
|
107
142
|
async function chatUnmetered(messages, opts = {}) {
|
|
108
143
|
const cfg = opts.cfg || llmConfig();
|
|
109
|
-
const key = `${cfg.url}|${
|
|
144
|
+
const key = `${cfg.url}|${opts.model || cfg.textModel}`;
|
|
110
145
|
// o modelo de texto não enxerga: a chamada com imagem vai para o modelo de visão (se o relay tiver um)
|
|
111
146
|
const viaVision = async () => {
|
|
112
147
|
const vision = cfg.visionModel;
|
|
113
|
-
if (!vision || vision === (opts.model || cfg.textModel) || noVision.has(`${cfg.url}|${
|
|
148
|
+
if (!vision || vision === (opts.model || cfg.textModel) || noVision.has(`${cfg.url}|${vision}`)) return null;
|
|
114
149
|
try { return { ...(await chatOnce(messages, { ...opts, model: vision, cfg })), visionRouted: vision }; }
|
|
115
|
-
catch (e) { if (refusesImages(e) || e.status === 400 || e.status === 404) { noVision.add(`${cfg.url}|${
|
|
150
|
+
catch (e) { if (refusesImages(e) || e.status === 400 || e.status === 404) { noVision.add(`${cfg.url}|${vision}`); return null; } throw e; }
|
|
116
151
|
};
|
|
117
152
|
if (hasImageParts(messages) && noVision.has(key)) {
|
|
118
153
|
return (await viaVision()) || { ...(await chatOnce(withoutImages(messages), { ...opts, cfg })), imagesDropped: true };
|
|
@@ -147,7 +182,8 @@ async function chatOnce(messages, opts = {}) {
|
|
|
147
182
|
}
|
|
148
183
|
|
|
149
184
|
async function chatOnceRaw(messages, { model, temperature, maxTokens, onDelta, signal, reasoningOff, cfg = llmConfig() } = {}) {
|
|
150
|
-
|
|
185
|
+
if (!llmConfigured(cfg)) throw notConfigured();
|
|
186
|
+
const body = { model: resolveModel(cfg, model || cfg.textModel), messages };
|
|
151
187
|
if (reasoningOff) body.reasoning = { enabled: false };
|
|
152
188
|
if (temperature !== undefined) body.temperature = temperature;
|
|
153
189
|
if (maxTokens) body.max_tokens = maxTokens;
|
|
@@ -155,7 +191,7 @@ async function chatOnceRaw(messages, { model, temperature, maxTokens, onDelta, s
|
|
|
155
191
|
|
|
156
192
|
let res;
|
|
157
193
|
try {
|
|
158
|
-
res = await
|
|
194
|
+
res = await send(cfg, `${cfg.url}/chat/completions`, {
|
|
159
195
|
method: "POST",
|
|
160
196
|
headers: headers(cfg),
|
|
161
197
|
body: JSON.stringify(body),
|
|
@@ -168,7 +204,7 @@ async function chatOnceRaw(messages, { model, temperature, maxTokens, onDelta, s
|
|
|
168
204
|
if (e.cause?.code === "UND_ERR_HEADERS_TIMEOUT") return chatStream(body, () => {}, cfg, signal);
|
|
169
205
|
const why = e.name === "TimeoutError" ? `sem resposta em ${cfg.timeoutMs / 1000}s` : e.message;
|
|
170
206
|
throw new LLMError(`Não consegui falar com o LLM em ${cfg.url} (${why}). ` +
|
|
171
|
-
|
|
207
|
+
howToFix, { cause: e });
|
|
172
208
|
}
|
|
173
209
|
|
|
174
210
|
const raw = await res.text();
|
|
@@ -213,7 +249,7 @@ async function chatStream(body, onDelta, cfg, signal) {
|
|
|
213
249
|
async function readStream(body, onDelta, cfg, signal, sig, alive, wrote = () => {}, noTextFor = () => 0) {
|
|
214
250
|
let res;
|
|
215
251
|
try {
|
|
216
|
-
res = await
|
|
252
|
+
res = await send(cfg, `${cfg.url}/chat/completions`, {
|
|
217
253
|
method: "POST",
|
|
218
254
|
headers: headers(cfg),
|
|
219
255
|
body: JSON.stringify({ ...body, stream: true, stream_options: { include_usage: true } }),
|
|
@@ -223,7 +259,7 @@ async function readStream(body, onDelta, cfg, signal, sig, alive, wrote = () =>
|
|
|
223
259
|
if (signal?.aborted) throw new LLMError("Parado a pedido.", { cause: e, aborted: true });
|
|
224
260
|
const why = e.name === "TimeoutError" ? `não começou a responder em ${(cfg.firstTimeoutMs ?? cfg.timeoutMs) / 1000} s` : e.message;
|
|
225
261
|
throw new LLMError(`Não consegui falar com o LLM em ${cfg.url} (${why}). ` +
|
|
226
|
-
|
|
262
|
+
howToFix, { cause: e });
|
|
227
263
|
}
|
|
228
264
|
alive(); // começou: daqui em diante, o limite é ficar sem chegar nada
|
|
229
265
|
if (!res.ok) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Quadro de etapas da geração: cada aviso de progresso diz em que etapa está ("Leitura crítica: …") e, quando duas
|
|
2
|
+
// etapas correm juntas (o inventário do documento enquanto a crítica lê o texto), mostra as duas. Antes a tela
|
|
3
|
+
// ficava minutos em "Pensando…" sem dizer pensando em quê.
|
|
4
|
+
export function stageBoard(onEvent = () => {}) {
|
|
5
|
+
const active = new Map(), done = [];
|
|
6
|
+
const send = (extra = {}) => onEvent({ ...extra, stages: [...active.keys()], done: [...done],
|
|
7
|
+
text: [...active].map(([label, text]) => (text ? `${label}: ${text}` : label)).join(' · ') });
|
|
8
|
+
return {
|
|
9
|
+
// roda fn(emit) como a etapa `label`; emit aceita texto ou o evento { phase, text, chars, preview }
|
|
10
|
+
async run(label, fn) {
|
|
11
|
+
active.set(label, '');
|
|
12
|
+
send({ phase: 'stage' });
|
|
13
|
+
const emit = (ev) => {
|
|
14
|
+
if (!active.has(label)) return;
|
|
15
|
+
const obj = ev && typeof ev === 'object' ? ev : { phase: 'step', text: ev };
|
|
16
|
+
active.set(label, String(obj.text || ''));
|
|
17
|
+
send({ ...obj, phase: obj.phase || 'step' });
|
|
18
|
+
};
|
|
19
|
+
try { return await fn(emit); }
|
|
20
|
+
finally { active.delete(label); done.push(label); if (active.size) send({ phase: 'stage' }); }
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
package/src/ai/quality.js
CHANGED
|
@@ -11,9 +11,36 @@ const oneLinerText = s => s.text || s.quote || (Array.isArray(s.lines) ? s.lines
|
|
|
11
11
|
|
|
12
12
|
// Conferência de texto sem visão (determinística, roda sempre): frase longa em layout de impacto vira tela de uma
|
|
13
13
|
// frase gigante; uma apresentação feita de frases soltas não explica nada. Os achados voltam para a correção da IA.
|
|
14
|
-
|
|
14
|
+
// Interação de plateia (enquete, votação, quiz, cronômetro): em apresentação acadêmica não entra sem pedido.
|
|
15
|
+
export const votingSlide = (s) => s.layout === 'poll' || (s.layout === 'question' && Array.isArray(s.options) && s.options.length) || s.poll != null || s.timer != null;
|
|
16
|
+
const ASKED_INTERACTION = /vota|votação|enquete|quiz|interativ|dinâmica|gamific|perguntas? (?:para|à) plateia/i;
|
|
17
|
+
// marcação que sobrou crua no texto visível (campo que não passa pela marcação): "^^≠^^", "==x==", "**x**"
|
|
18
|
+
const RAW_MARKUP = /\^\^[^^\n]{1,40}\^\^|==[^=\n]{1,60}==|\*\*[^*\n]{1,60}\*\*/;
|
|
19
|
+
const CODE_LAYOUTS = new Set(['code', 'codewalk', 'codelab', 'api', 'algo', 'terminals', 'duel', 'turns', 'playground']);
|
|
20
|
+
|
|
21
|
+
export function auditText(spec, indices, { briefing = '', render = null } = {}) {
|
|
15
22
|
const issues = [];
|
|
16
23
|
const slides = spec.slides || [];
|
|
24
|
+
// deck feito de um artigo (autoria decidida) é acadêmico: votação só se o pedido quis interação
|
|
25
|
+
const academic = !!spec.context?.autoria || /congresso|simp[óo]sio|confer[êe]ncia|defesa|banca|journal club|semin[áa]rio/i.test(`${briefing} ${JSON.stringify(spec.context || {})}`);
|
|
26
|
+
if (academic && !ASKED_INTERACTION.test(briefing)) for (const index of indices) {
|
|
27
|
+
const s = slides[index];
|
|
28
|
+
if (s && votingSlide(s)) issues.push({ slide: index + 1, text: 'Votação/enquete numa apresentação acadêmica que não pediu interação. Tire o mecanismo de voto (options, timer, poll): a pergunta para discussão vira slide de texto (question sem options, statement ou split) ou vai nas notes.' });
|
|
29
|
+
}
|
|
30
|
+
// slide repetido (mesmo layout e mesmo título de outro): duas capas, o mesmo resultado em dois slides seguidos
|
|
31
|
+
const sig = (s) => `${s?.layout}|${String(s?.title || s?.text || s?.question || '').replace(/[=*^_~`]/g, '').trim().toLowerCase()}`;
|
|
32
|
+
for (const index of indices) {
|
|
33
|
+
const s = slides[index]; if (!s || !(s.title || s.text || s.question)) continue;
|
|
34
|
+
const first = slides.findIndex((o) => sig(o) === sig(s));
|
|
35
|
+
if (first >= 0 && first < index) issues.push({ slide: index + 1, text: `Slide repetido: mesmo layout e mesmo título do slide ${first + 1}. Tire a cópia ou faça este slide dizer outra coisa.` });
|
|
36
|
+
}
|
|
37
|
+
if (render) for (const index of indices) {
|
|
38
|
+
const s = slides[index]; if (!s || CODE_LAYOUTS.has(s.layout)) continue;
|
|
39
|
+
let visible = '';
|
|
40
|
+
try { visible = render(s, index).replace(/<(code|pre|script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' '); } catch { continue; }
|
|
41
|
+
const m = RAW_MARKUP.exec(visible);
|
|
42
|
+
if (m) issues.push({ slide: index + 1, text: `Marcação aparecendo crua na tela ("${m[0]}"): esse campo não aceita marcação. Escreva o texto sem ^^, == ou ** nesse campo.` });
|
|
43
|
+
}
|
|
17
44
|
for (const index of indices) {
|
|
18
45
|
const s = slides[index]; if (!s || !ONE_LINER.has(s.layout)) continue;
|
|
19
46
|
const n = words(oneLinerText(s));
|
|
@@ -38,7 +65,8 @@ export function auditText(spec, indices) {
|
|
|
38
65
|
export async function reviewExperience(spec, indices, { snapshot, complete = chat, onProgress, signal, briefing = '' } = {}) {
|
|
39
66
|
const issues = [], unchecked = [], failures = [];
|
|
40
67
|
const selected = [...new Set(indices)].filter(index => spec.slides[index]);
|
|
41
|
-
|
|
68
|
+
const { renderSlide } = await import('../build.js');
|
|
69
|
+
issues.push(...auditText(spec, selected, { briefing, render: (s, i) => renderSlide(s, i, spec).html }));
|
|
42
70
|
for (const [position, index] of selected.entries()) {
|
|
43
71
|
signal?.throwIfAborted();
|
|
44
72
|
const slide = spec.slides[index];
|
package/src/api-client.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// ttl_minutes: 30
|
|
15
15
|
// ca: "C:/certs/empresa.pem" # certificado da empresa (proxy/inspeção TLS)
|
|
16
16
|
import fs from "node:fs";
|
|
17
|
+
import { renameRetry } from "./fs-retry.js";
|
|
17
18
|
import os from "node:os";
|
|
18
19
|
import path from "node:path";
|
|
19
20
|
import http from "node:http";
|
|
@@ -187,7 +188,7 @@ export class ApiEnvironments {
|
|
|
187
188
|
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
188
189
|
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
189
190
|
fs.writeFileSync(tmp, String(doc), { encoding: "utf8", mode: 0o600 });
|
|
190
|
-
|
|
191
|
+
renameRetry(tmp, this.file);
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
// Ambiente de exemplo (embutido) editado pela pessoa: vira um ambiente dela, cópia completa no arquivo (o do arquivo
|
package/src/build.js
CHANGED
|
@@ -11,6 +11,7 @@ import YAML from "yaml";
|
|
|
11
11
|
import { resolveTheme, themeCSS, scopedThemeCSS } from "./themes.js";
|
|
12
12
|
import { identityFor, identitiesFile } from "./identity.js";
|
|
13
13
|
import { LAYOUTS, SCENES, fullAsFigure } from "./layouts.js";
|
|
14
|
+
import { setDeckLocale } from "./locale.js";
|
|
14
15
|
import { applyVisualEdits } from "./visual-edits.js";
|
|
15
16
|
import { el, imageSrc } from "./elements.js";
|
|
16
17
|
import { iconSVG } from "./figures/icons.js";
|
|
@@ -127,6 +128,7 @@ export function navWarnings(slides = []) {
|
|
|
127
128
|
|
|
128
129
|
export function buildHTML(rawSpec, opts = {}) {
|
|
129
130
|
const spec = normalizeSpec(meetingSpec(rawSpec,opts.audience));
|
|
131
|
+
setDeckLocale(spec.lang); // números no idioma do deck (src/locale.js)
|
|
130
132
|
const theme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
|
|
131
133
|
const warnings = [];
|
|
132
134
|
if (spec.identity && !theme.identity) warnings.push(`identidade "${spec.identity}" não está em ${identitiesFile()} nesta máquina: a apresentação saiu com as fontes do tema`);
|
|
@@ -307,6 +309,7 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
|
|
|
307
309
|
}
|
|
308
310
|
|
|
309
311
|
export function renderSlide(raw, i = 0, spec = {}) {
|
|
312
|
+
setDeckLocale(spec.lang);
|
|
310
313
|
const deckTheme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
|
|
311
314
|
const s = { ...(spec.defaults || {}), ...raw };
|
|
312
315
|
const theme = slideTheme(s, deckTheme, spec);
|
package/src/decision-lab.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import './runtime/decision-lab.js';
|
|
2
2
|
import { esc } from './markup.js';
|
|
3
|
+
import { formatNumber } from './locale.js';
|
|
3
4
|
export const calculateDecisionLab = globalThis.SagaDecisionLab.calculate;
|
|
4
5
|
export function decisionLabHTML(input = {}) {
|
|
5
6
|
const v = globalThis.SagaDecisionLab.normalize(input), r = calculateDecisionLab(v);
|
|
6
|
-
const fmt = n => n
|
|
7
|
+
const fmt = n => formatNumber(n, { maximumFractionDigits: 1 });
|
|
7
8
|
const controls = [
|
|
8
9
|
['errorRate', 'Erros da automação (%)', 100, .1],
|
|
9
10
|
['reviewRate', 'Casos revisados (%)', 100, 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
|
|