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/library.js
CHANGED
|
@@ -9,25 +9,75 @@
|
|
|
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";
|
|
25
|
+
import { loadPreferences } from "./preferences.js";
|
|
23
26
|
|
|
24
|
-
export const TRASH_DAYS = 30;
|
|
27
|
+
export const TRASH_DAYS = 30; // padrão; cada máquina escolhe em Preferências › Biblioteca (lixeiraDias)
|
|
25
28
|
const TRASH = ".lixeira";
|
|
26
29
|
const TOPIC_META = ".topico.json";
|
|
27
30
|
const COLORS = ["#d33a2c", "#0f6cbd", "#e5a50a", "#8b5cf6", "#0e9f6e", "#e8590c", "#d6336c", "#495057"];
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
// Pasta Documentos do Windows como o Explorer a mostra: a pasta conhecida "Personal" do registro, que pode estar
|
|
33
|
+
// redirecionada (OneDrive\Documents, outro disco, "Documentos"). Sem ela, %USERPROFILE%\Documents. Fora do Windows, null.
|
|
34
|
+
let documentsCache;
|
|
35
|
+
export function documentsDir({ platform = process.platform, home = os.homedir() } = {}) {
|
|
36
|
+
if (platform !== "win32") return null;
|
|
37
|
+
if (documentsCache !== undefined && home === os.homedir()) return documentsCache;
|
|
38
|
+
let found = null;
|
|
39
|
+
try {
|
|
40
|
+
const out = execFileSync("reg", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders", "/v", "Personal"], { encoding: "utf8", windowsHide: true, timeout: 5000 });
|
|
41
|
+
found = out.match(/Personal\s+REG_\w+\s+(.+)/)?.[1]?.trim() || null;
|
|
42
|
+
} catch { /* sem registro: o padrão abaixo */ }
|
|
43
|
+
const dir = found && path.isAbsolute(found) ? found : path.join(home, "Documents");
|
|
44
|
+
if (home === os.homedir()) documentsCache = dir;
|
|
45
|
+
return dir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A biblioteca: SAGADECK_HOME, senão <Documentos>\sagadeck no Windows e ~/sagadeck fora dele. Enquanto a biblioteca
|
|
49
|
+
// antiga (~/sagadeck, o padrão até a 1.4) não foi movida para Documentos, ela continua valendo: nunca abre vazia.
|
|
50
|
+
export function defaultLibraryRoot(env = process.env, { platform = process.platform, home = os.homedir(), documents } = {}) {
|
|
51
|
+
if (env.SAGADECK_HOME) return path.resolve(env.SAGADECK_HOME);
|
|
52
|
+
const legacy = path.join(home, "sagadeck");
|
|
53
|
+
const docs = documents !== undefined ? documents : documentsDir({ platform, home });
|
|
54
|
+
if (!docs) return path.resolve(legacy);
|
|
55
|
+
const target = path.join(docs, "sagadeck");
|
|
56
|
+
if (!fs.existsSync(target) && hasContent(legacy) && !isLink(legacy)) return path.resolve(legacy);
|
|
57
|
+
return path.resolve(target);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isLink = (p) => { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } };
|
|
61
|
+
const hasContent = (p) => { try { return fs.readdirSync(p).length > 0; } catch { return false; } };
|
|
62
|
+
|
|
63
|
+
// Leva a biblioteca antiga (~/sagadeck) para <Documentos>\sagadeck, uma vez: move a pasta inteira (mesma unidade:
|
|
64
|
+
// instantâneo) e deixa no lugar antigo uma junção para a nova, para atalhos e configurações antigas continuarem
|
|
65
|
+
// valendo. Não mexe se SAGADECK_HOME manda, se a nova já tem conteúdo (não mistura bibliotecas) ou se a antiga já
|
|
66
|
+
// é a junção. Falhou (arquivo aberto, outra unidade): a antiga continua sendo a biblioteca e tenta de novo depois.
|
|
67
|
+
export function migrateLegacyLibrary(env = process.env, { platform = process.platform, home = os.homedir(), documents } = {}) {
|
|
68
|
+
if (env.SAGADECK_HOME) return { skipped: "SAGADECK_HOME" };
|
|
69
|
+
const docs = documents !== undefined ? documents : documentsDir({ platform, home });
|
|
70
|
+
if (!docs) return { skipped: "fora do Windows" };
|
|
71
|
+
const legacy = path.join(home, "sagadeck"), target = path.join(docs, "sagadeck");
|
|
72
|
+
if (path.resolve(legacy) === path.resolve(target) || isLink(legacy) || !hasContent(legacy)) return { skipped: "nada a mover" };
|
|
73
|
+
if (hasContent(target)) return { skipped: "as duas têm conteúdo", legacy, target };
|
|
74
|
+
try {
|
|
75
|
+
fs.mkdirSync(docs, { recursive: true });
|
|
76
|
+
if (fs.existsSync(target)) fs.rmdirSync(target); // pasta vazia criada antes
|
|
77
|
+
renameRetry(legacy, target);
|
|
78
|
+
} catch (e) { return { error: e.message, legacy, target }; }
|
|
79
|
+
try { fs.symlinkSync(target, legacy, "junction"); } catch { /* sem a junção, a nova pasta já é a biblioteca */ }
|
|
80
|
+
return { moved: true, legacy, target };
|
|
31
81
|
}
|
|
32
82
|
|
|
33
83
|
// Onde uma apresentação NOVA é gravada: sempre na biblioteca. Um caminho dentro dela é respeitado; qualquer
|
|
@@ -65,8 +115,11 @@ const isYaml = (name) => /\.ya?ml$/i.test(name) && !hidden(name);
|
|
|
65
115
|
|
|
66
116
|
// nome de pasta/arquivo válido no Windows (e no Linux): sem <>:"/\|?*, sem ponto/espaço no fim
|
|
67
117
|
export function safeName(s, fallback = "Sem título") {
|
|
68
|
-
|
|
69
|
-
.replace(/[. ]+$/, "")
|
|
118
|
+
let clean = String(s || "").normalize("NFC").replace(/[<>:"/\\|?*\u0000-\u001f]/g, " ").replace(/\s+/g, " ").trim()
|
|
119
|
+
.replace(/[. ]+$/, "");
|
|
120
|
+
// nome longo corta no último espaço antes de 80 (a pasta saía "…and Urban Flood Predic")
|
|
121
|
+
if (clean.length > 80) { const cut = clean.slice(0, 81).lastIndexOf(" "); clean = clean.slice(0, cut >= 40 ? cut : 80); }
|
|
122
|
+
clean = clean.replace(/[\s.,;:—–-]+$/, "").trim();
|
|
70
123
|
return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(clean) || !clean ? fallback : clean;
|
|
71
124
|
}
|
|
72
125
|
|
|
@@ -76,7 +129,9 @@ function uniquePath(dir, name, ext = "") {
|
|
|
76
129
|
return p;
|
|
77
130
|
}
|
|
78
131
|
|
|
79
|
-
|
|
132
|
+
// trashDays: quantos dias a lixeira guarda (padrão: o das Preferências desta máquina, lido a cada limpeza)
|
|
133
|
+
export function openLibrary(root, { trashDays } = {}) {
|
|
134
|
+
const keepDays = () => trashDays ?? loadPreferences().biblioteca?.lixeiraDias ?? TRASH_DAYS;
|
|
80
135
|
root = path.resolve(root);
|
|
81
136
|
fs.mkdirSync(root, { recursive: true });
|
|
82
137
|
|
|
@@ -139,7 +194,7 @@ export function openLibrary(root) {
|
|
|
139
194
|
topics.push({ id: e.name, name: e.name, color: meta.color || COLORS[topics.length % COLORS.length], count: mine.length, created: meta.created || 0 });
|
|
140
195
|
}
|
|
141
196
|
topics.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"));
|
|
142
|
-
return { root, topics, decks, trash: listTrash() };
|
|
197
|
+
return { root, topics, decks, trash: listTrash(), trashDays: keepDays() };
|
|
143
198
|
}
|
|
144
199
|
|
|
145
200
|
// ---- tópicos ----
|
|
@@ -164,7 +219,7 @@ export function openLibrary(root) {
|
|
|
164
219
|
if (color) fs.writeFileSync(path.join(dir, TOPIC_META), JSON.stringify({ ...topicMeta(dir), color }));
|
|
165
220
|
if (name && safeName(name) !== path.basename(dir)) {
|
|
166
221
|
const to = uniquePath(root, safeName(name));
|
|
167
|
-
|
|
222
|
+
renameRetry(dir, to);
|
|
168
223
|
dir = to;
|
|
169
224
|
}
|
|
170
225
|
return path.basename(dir);
|
|
@@ -208,7 +263,7 @@ export function openLibrary(root) {
|
|
|
208
263
|
if (path.dirname(unit) === dest) return id;
|
|
209
264
|
const ext = unit === file ? path.extname(file) : "";
|
|
210
265
|
const to = uniquePath(dest, path.basename(unit, ext), ext);
|
|
211
|
-
|
|
266
|
+
renameRetry(unit, to);
|
|
212
267
|
return idOf(unit === file ? to : path.join(to, path.basename(file)));
|
|
213
268
|
}
|
|
214
269
|
|
|
@@ -220,12 +275,12 @@ export function openLibrary(root) {
|
|
|
220
275
|
if (unit === file) return id; // .yaml solto: só o título
|
|
221
276
|
const to = uniquePath(path.dirname(unit), safeName(title));
|
|
222
277
|
if (path.basename(to) === path.basename(unit)) return id;
|
|
223
|
-
|
|
278
|
+
renameRetry(unit, to);
|
|
224
279
|
// o .yaml com o nome da pasta acompanha a pasta
|
|
225
280
|
let yaml = path.join(to, path.basename(file));
|
|
226
281
|
if (path.basename(file).replace(/\.ya?ml$/i, "") === path.basename(unit)) {
|
|
227
282
|
const renamed = path.join(to, path.basename(to) + path.extname(file));
|
|
228
|
-
|
|
283
|
+
renameRetry(yaml, renamed);
|
|
229
284
|
yaml = renamed;
|
|
230
285
|
}
|
|
231
286
|
return idOf(yaml);
|
|
@@ -240,7 +295,7 @@ export function openLibrary(root) {
|
|
|
240
295
|
let yaml = unit === file ? to : path.join(to, path.basename(file));
|
|
241
296
|
if (unit !== file && path.basename(file).replace(/\.ya?ml$/i, "") === path.basename(unit)) {
|
|
242
297
|
const renamed = path.join(to, path.basename(to) + path.extname(file));
|
|
243
|
-
|
|
298
|
+
renameRetry(yaml, renamed);
|
|
244
299
|
yaml = renamed;
|
|
245
300
|
}
|
|
246
301
|
const spec = YAML.parse(fs.readFileSync(yaml, "utf8")) || {};
|
|
@@ -283,9 +338,9 @@ export function openLibrary(root) {
|
|
|
283
338
|
if (!Array.isArray(spec.slides) || !spec.slides.length) throw new Error("a geração não produziu slides: nada foi publicado");
|
|
284
339
|
const dest = uniquePath(base, safeName(title || spec.title || "Nova apresentação"));
|
|
285
340
|
spec.title = title || spec.title || path.basename(dest);
|
|
286
|
-
|
|
341
|
+
renameRetry(abs, dest);
|
|
287
342
|
const final = path.join(dest, path.basename(dest) + ".yaml");
|
|
288
|
-
|
|
343
|
+
renameRetry(path.join(dest, "deck.yaml"), final);
|
|
289
344
|
writeDeckFile(final, spec); // só o título muda no arquivo
|
|
290
345
|
return idOf(final);
|
|
291
346
|
}
|
|
@@ -296,7 +351,7 @@ export function openLibrary(root) {
|
|
|
296
351
|
fs.mkdirSync(tdir, { recursive: true });
|
|
297
352
|
const slot = path.join(tdir, `${Date.now()}-${STAGING}`);
|
|
298
353
|
fs.mkdirSync(slot);
|
|
299
|
-
|
|
354
|
+
renameRetry(abs, path.join(slot, path.basename(abs)));
|
|
300
355
|
const yaml = path.join(slot, path.basename(abs), "deck.yaml");
|
|
301
356
|
let slides = 0, title = reason;
|
|
302
357
|
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 +382,7 @@ export function openLibrary(root) {
|
|
|
327
382
|
fs.mkdirSync(tdir, { recursive: true });
|
|
328
383
|
const slot = path.join(tdir, `${Date.now()}-${path.basename(unit)}`);
|
|
329
384
|
fs.mkdirSync(slot);
|
|
330
|
-
|
|
385
|
+
renameRetry(unit, path.join(slot, path.basename(unit)));
|
|
331
386
|
fs.writeFileSync(path.join(slot, ".origem.json"), JSON.stringify({
|
|
332
387
|
topic: info.topic, title: info.title, deleted: Date.now(), yaml: path.relative(unit === file ? path.dirname(file) : unit, file),
|
|
333
388
|
unit: path.basename(unit), slides: info.slides,
|
|
@@ -348,7 +403,7 @@ export function openLibrary(root) {
|
|
|
348
403
|
const isFile = /\.ya?ml$/i.test(o.unit);
|
|
349
404
|
const ext = isFile ? path.extname(o.unit) : "";
|
|
350
405
|
const to = uniquePath(dest, path.basename(o.unit, ext), ext);
|
|
351
|
-
|
|
406
|
+
renameRetry(path.join(slot, o.unit), to);
|
|
352
407
|
fs.rmSync(slot, { recursive: true, force: true });
|
|
353
408
|
return idOf(isFile ? to : path.join(to, o.yaml));
|
|
354
409
|
}
|
|
@@ -362,7 +417,8 @@ export function openLibrary(root) {
|
|
|
362
417
|
return all.length;
|
|
363
418
|
}
|
|
364
419
|
function purgeOld(now = Date.now()) {
|
|
365
|
-
|
|
420
|
+
const days = keepDays();
|
|
421
|
+
for (const t of listTrash()) if (now - t.deleted > days * 864e5) purgeDeck(t.id);
|
|
366
422
|
}
|
|
367
423
|
|
|
368
424
|
// ---- importar .sagadeck / .zip ----
|
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,11 +22,11 @@ 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;
|
|
25
|
+
let active=o.type!=='chat',started=false,epoch=performance.now(),serial=0,phase='idle',elapsed=0,previous=0;const reduceQuery=matchMedia('(prefers-reduced-motion:reduce)');let still=false,reduced=!!o.still||reduceQuery.matches;const recompute=()=>{reduced=!!o.still||still||reduceQuery.matches;};reduceQuery.addEventListener?.('change',recompute);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='';}
|
|
29
|
-
message('ready');window.addEventListener('message',e=>{if(e.source!==parent)return;if(e.data?.sagaMotion==='poster'){
|
|
29
|
+
window.__sagaMotion=()=>({active,reduced,started,serial,phase});message('ready');window.addEventListener('message',e=>{if(e.source!==parent)return;if(e.data?.sagaMotion==='poster'){still=e.data.active!==false;recompute();if(reduced&&o.type==='terminal')document.querySelector('.feed').textContent=o.lines.join('\n');}if(e.data?.sagaMotion==='active')active=!!e.data.active;if(e.data?.sagaMotion==='reset')reset();if(e.data?.sagaMotion==='start'){reset();started=true;active=true;phase='recognize';chat.hidden=false;}});
|
|
30
30
|
if(o.type==='chat'&&o.manual){const request=e=>{if(!active)return;if(e.type==='keydown'&&![' ','Enter','ArrowRight','PageDown'].includes(e.key))return;e.preventDefault();message('start-request');};document.addEventListener('click',request);document.addEventListener('keydown',request);}
|
|
31
31
|
if(o.type==='chat'&&!o.manual){started=true;phase='recognize';chat.hidden=false;}
|
|
32
32
|
function bubble(text,kind){const el=document.createElement('div');el.className='bubble '+kind;el.textContent=text;thread.append(el);return el;}
|
|
@@ -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
|
|
|
@@ -21,6 +22,9 @@ export const PREF_SCHEMA = {
|
|
|
21
22
|
exportacao: {
|
|
22
23
|
pdfClaro: { def: false }, // PDF e impressão na versão clara do tema (quando o tema tem par claro/escuro)
|
|
23
24
|
},
|
|
25
|
+
biblioteca: {
|
|
26
|
+
lixeiraDias: { def: 30, min: 1, max: 365 }, // excluída fica restaurável por tantos dias, depois é apagada de vez
|
|
27
|
+
},
|
|
24
28
|
};
|
|
25
29
|
|
|
26
30
|
export function preferencesFile(env = process.env, home = os.homedir()) {
|
|
@@ -56,6 +60,6 @@ export function savePreferences(patch, file = preferencesFile()) {
|
|
|
56
60
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
57
61
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
58
62
|
fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", "utf8");
|
|
59
|
-
|
|
63
|
+
renameRetry(tmp, file);
|
|
60
64
|
return out;
|
|
61
65
|
}
|
package/src/research/research.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// (fontes.json, notas.md e o texto de cada fonte). Sem internet (a rede do banco): avisa e segue sem inventar.
|
|
12
12
|
import fs from "node:fs";
|
|
13
13
|
import path from "node:path";
|
|
14
|
-
import { chat } from "../ai/llm.js";
|
|
14
|
+
import { chat, llmConfig } from "../ai/llm.js";
|
|
15
15
|
import { fetchUrlDoc } from "../ai/context.js";
|
|
16
16
|
import { searchDuckDuckGo } from "./duckduckgo.js";
|
|
17
17
|
import { searchWikipedia } from "./wikipedia.js";
|
|
@@ -66,11 +66,12 @@ export async function arxivSearch(q, { limit = 5 } = {}) {
|
|
|
66
66
|
return { title: tag("title"), url: abs, pdf: abs.replace("/abs/", "/pdf/"), snippet: clip(tag("summary"), 300), date: tag("published").slice(0, 10), arxiv: true };
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
|
-
// Busca pelo modelo
|
|
70
|
-
// oficial). O DuckDuckGo direto passou a responder com desafio anti-robô; ele fica como segunda
|
|
71
|
-
//
|
|
69
|
+
// Busca pelo modelo de busca configurado (Configurar IA › busca; no OpenRouter, um modelo com ":online": a busca na
|
|
70
|
+
// web dele, por API oficial). O DuckDuckGo direto passou a responder com desafio anti-robô; ele fica como segunda
|
|
71
|
+
// tentativa. Sem modelo de busca (a rede do banco), não há buscador: a pesquisa avisa e segue sem inventar.
|
|
72
72
|
export async function llmSearch(q, { limit = 8 } = {}) {
|
|
73
|
-
const model =
|
|
73
|
+
const model = llmConfig().searchModel;
|
|
74
|
+
if (!model) throw new Error("sem modelo de busca configurado");
|
|
74
75
|
const r = await chat([{ role: "user", content: `Hoje é ${today()}. Pesquise na web e liste as ${limit} melhores fontes para: ${q}\nPrefira fontes primárias (site oficial, documentação, artigo, imprensa séria); para humor, memes e opiniões sobre cultura, blogs e fóruns também podem ser pertinentes. Para cada uma, o título, a URL completa e exata (a página, não o site), uma frase do que ela traz e a data, se houver.\nResponda só JSON: [{"title": "…", "url": "https://…", "snippet": "…", "date": "…"}]` }], { model, maxTokens: 3000, temperature: 0, reasoningOff: true });
|
|
75
76
|
const t = r.text || "", m = t.match(/```(?:json)?\s*([\s\S]*?)```/), raw = m ? m[1] : t.slice(t.indexOf("["), t.lastIndexOf("]") + 1);
|
|
76
77
|
return (JSON.parse(raw) || []).filter((x) => /^https?:\/\//.test(x?.url || "")).slice(0, limit).map((x) => ({ title: String(x.title || x.url), url: String(x.url), snippet: String(x.snippet || ""), date: String(x.date || "") }));
|
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/motion.js
CHANGED
|
@@ -10,6 +10,10 @@ for(const frame of document.querySelectorAll('iframe[data-motion]')){
|
|
|
10
10
|
document.addEventListener('keydown',begin,true);slide.addEventListener('click',begin,true);
|
|
11
11
|
}
|
|
12
12
|
window.addEventListener('message',e=>{if(e.source!==frame.contentWindow)return;if(e.data?.sagaMotion==='done')finish();if(e.data?.sagaMotion==='ready')activate();if(e.data?.sagaMotion==='start-request'&&slide?.classList.contains('current')&&!done){if(started)finish();else{started=true;send('start');}}});
|
|
13
|
-
const activate=()=>{const active=slide?.classList.contains('current');send('active',active);
|
|
13
|
+
const activate=()=>{const active=slide?.classList.contains('current');send('active',active);send('poster',document.documentElement.classList.contains('export')||document.documentElement.dataset.motion==='none'||matchMedia('(prefers-reduced-motion: reduce)').matches);if(!active){started=false;done=false;frame.style.visibility='';send('reset');}};
|
|
14
14
|
frame.addEventListener('load',activate);if(slide)new MutationObserver(activate).observe(slide,{attributes:true,attributeFilter:['class']});
|
|
15
|
+
// a cena pode ter carregado e dito 'ready' antes deste script escutar: fala com ela já (senão ela não sabe do slide
|
|
16
|
+
// atual nem do movimento reduzido e anima sem parar)
|
|
17
|
+
activate();
|
|
18
|
+
matchMedia('(prefers-reduced-motion: reduce)').addEventListener?.('change',activate);
|
|
15
19
|
}
|
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;
|
|
@@ -53,7 +119,7 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
|
|
|
53
119
|
// Sem modelo, ninguém decide nada: nem "o que é X?" nem "resuma" viram edição por palavra-chave
|
|
54
120
|
// (as regras antigas trocavam o layout e enfiavam texto genérico no slide). O deck fica como está.
|
|
55
121
|
return {
|
|
56
|
-
reply: `A IA está desligada (nenhum LLM respondendo em ${llmConfig().url}), então não mexi em nada.
|
|
122
|
+
reply: `A IA está desligada (${llmConfig().url ? `nenhum LLM respondendo em ${llmConfig().url}` : "não configurada"}), então não mexi em nada. Configure em Configurar IA (provedor e chave) e mande de novo.`,
|
|
57
123
|
spec, actions: [], targetSlide: body.targetSlide, talk: true, mode: "off",
|
|
58
124
|
};
|
|
59
125
|
}
|
|
@@ -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.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// O que o `sagadeck studio` escreve ao subir. A primeira linha é o endereço do Studio, a página que a pessoa abre: antes
|
|
2
|
+
// vinha primeiro o endereço de um serviço de IA à parte, e um agente pedido para "deixar pronto para usar" abria a
|
|
3
|
+
// página dele ou, sem achar o Studio, saía criando uma interface do zero. A IA se configura dentro do Studio.
|
|
4
|
+
import { PROVIDERS } from "../ai/ia-config.js";
|
|
5
|
+
|
|
6
|
+
const providerName = (ai) => Object.values(PROVIDERS).find((p) => p.url === ai.url)?.label || ai.url;
|
|
7
|
+
|
|
8
|
+
export function studioBanner({ version, url, library, ai = null, multiuser = false, usersDir = "", pid = process.pid, isLibrary = true }) {
|
|
9
|
+
const iaLine = !ai || !(ai.url || ai.adaptador)
|
|
10
|
+
? " IA: não configurada. No Studio, clique em IA desligada (ou Configurar IA na biblioteca) e informe provedor e chave."
|
|
11
|
+
: ` IA: ${providerName(ai)} · texto ${ai.textModel}${ai.source === "variável" ? " (pelas variáveis SAGADECK_LLM_*)" : ""}`;
|
|
12
|
+
return [
|
|
13
|
+
`✓ SagaDeck Studio ${version} pronto. Abra no navegador: ${url}${isLibrary ? " (biblioteca)" : ""}`,
|
|
14
|
+
multiuser ? ` multiusuário: bibliotecas em ${usersDir}` : ` biblioteca: ${library}`,
|
|
15
|
+
iaLine,
|
|
16
|
+
` processo ${pid}`,
|
|
17
|
+
];
|
|
18
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Impressão do código do sagadeck (caminho, data e tamanho de cada arquivo). O Studio guarda a de quando subiu e a
|
|
2
|
+
// página consulta de tempos em tempos: se o código mudou no disco (git pull, npm update, merge), o servidor que está
|
|
3
|
+
// rodando ainda é o velho e a página avisa "reinicie". Antes, um Studio aberto antes de uma atualização recebia
|
|
4
|
+
// arquivos novos do disco com o servidor velho e quebrava em silêncio (nenhum slide abria).
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
|
|
9
|
+
const CODE = /\.(m?js|html|css|json)$/i;
|
|
10
|
+
|
|
11
|
+
export function codeFingerprint(root) {
|
|
12
|
+
const h = crypto.createHash("sha1");
|
|
13
|
+
const walk = (dir) => {
|
|
14
|
+
let entries;
|
|
15
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
16
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
17
|
+
if (e.name === "node_modules" || e.name.startsWith(".")) continue;
|
|
18
|
+
const p = path.join(dir, e.name);
|
|
19
|
+
if (e.isDirectory()) walk(p);
|
|
20
|
+
else if (CODE.test(e.name)) { try { const st = fs.statSync(p); h.update(`${path.relative(root, p)}|${st.mtimeMs}|${st.size}\n`); } catch {} }
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
walk(root);
|
|
24
|
+
return h.digest("hex").slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// a que vale para o servidor: a de quando ele subiu × a de agora (recalculada no máximo a cada poucos segundos)
|
|
28
|
+
export function codeWatch(root, { every = 3000 } = {}) {
|
|
29
|
+
const started = codeFingerprint(root);
|
|
30
|
+
let now = started, at = Date.now();
|
|
31
|
+
return () => {
|
|
32
|
+
if (Date.now() - at > every) { now = codeFingerprint(root); at = Date.now(); }
|
|
33
|
+
return { started, now, changed: now !== started };
|
|
34
|
+
};
|
|
35
|
+
}
|