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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Gerar uma apresentação pelo caminho do Studio ("Criar com IA" e chat de deck novo): Preferências, anexos guardados
|
|
2
|
+
// na pasta do deck (com o inventário visual), imagens, revisão pelos slides renderizados e pesquisa. Fica fora do
|
|
3
|
+
// servidor para a avaliação ao vivo (test/bench-live.test.js) medir exatamente o que a pessoa recebe.
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { generateDeck } from "../ai/deck-ai.js";
|
|
6
|
+
import { prepareDocumentMaterials } from "../ai/document-materials.js";
|
|
7
|
+
import { reviewExperience } from "../ai/quality.js";
|
|
8
|
+
import { stageBoard } from "../ai/progress.js";
|
|
9
|
+
import { loadPreferences } from "../preferences.js";
|
|
10
|
+
import { slideSnapshots, diagramCheck } from "./snapshot.js";
|
|
11
|
+
|
|
12
|
+
const DOCUMENT = /\.(pdf|docx?)$/i;
|
|
13
|
+
|
|
14
|
+
// b: o corpo do pedido (briefing, answer, theme, style, slides, duration, direction); materials: [{ name, text, detail, bytes }]
|
|
15
|
+
// O inventário do documento (a visão lendo página a página: minutos num paper) corre JUNTO com o resto: entender o
|
|
16
|
+
// pedido, pesquisar e a leitura crítica usam só o texto; a escrita espera o inventário. Parou antes (a IA perguntou,
|
|
17
|
+
// deu erro): o inventário é cancelado e a pasta fica livre.
|
|
18
|
+
export async function generateForStudio(b, { dir, materials = [], emit, prefs = loadPreferences().ia, prepare = prepareDocumentMaterials } = {}) {
|
|
19
|
+
const board = stageBoard((ev) => emit?.(ev));
|
|
20
|
+
const stop = new AbortController();
|
|
21
|
+
const withBytes = materials.some((m) => m.bytes);
|
|
22
|
+
const pending = withBytes ? board.run("Lendo o documento", (onProgress) => prepare(materials, dir, { onProgress, signal: stop.signal })) : null;
|
|
23
|
+
pending?.catch(() => {}); // quem espera é a geração; se ela parou antes, o erro do cancelamento não importa
|
|
24
|
+
// enquanto isso, o texto do documento (o binário fica com o inventário); `document` marca que é um documento
|
|
25
|
+
const text = materials.map(({ bytes, ...m }) => (bytes && DOCUMENT.test(m.name || "") ? { ...m, document: true } : m));
|
|
26
|
+
try {
|
|
27
|
+
const gen = await generateDeck(String(b.briefing || ""), {
|
|
28
|
+
ask: prefs.perguntar !== false && !b.answer, answer: b.answer ? String(b.answer).slice(0, 500) : "",
|
|
29
|
+
author: prefs.autor || "", language: prefs.idioma || "auto",
|
|
30
|
+
theme: b.theme || undefined,
|
|
31
|
+
style: b.style || undefined,
|
|
32
|
+
slides: Number(b.slides) || undefined,
|
|
33
|
+
duration: Number(b.duration) || undefined,
|
|
34
|
+
direction: b.direction || undefined,
|
|
35
|
+
materials: text,
|
|
36
|
+
pendingMaterials: pending,
|
|
37
|
+
board,
|
|
38
|
+
images: prefs.imagens !== false, // o briefing diz se quer imagens (e onde); Preferências podem desligar
|
|
39
|
+
imageOptions: { baseDir: dir, assetsDir: path.join(dir, "imagens") },
|
|
40
|
+
onEvent: emit,
|
|
41
|
+
drawCheck: diagramCheck,
|
|
42
|
+
reviewCheck: (deck, indices, o = {}) => reviewExperience({ ...deck, _dir: dir }, indices, { snapshot: slideSnapshots, onProgress: o.onProgress || emit, briefing: b.briefing }),
|
|
43
|
+
// pesquisa na web quando a IA decidir que precisa (Preferências › IA pode desligar; SAGADECK_WEB=0 no banco);
|
|
44
|
+
// as fontes lidas ficam em contexto/pesquisa/ do deck
|
|
45
|
+
research: prefs.pesquisa === false ? false : "auto",
|
|
46
|
+
researchDir: dir,
|
|
47
|
+
});
|
|
48
|
+
return gen;
|
|
49
|
+
} finally {
|
|
50
|
+
stop.abort();
|
|
51
|
+
await pending?.catch(() => {});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Tela "Configurar IA" do Studio: ler (a chave nunca sai inteira), salvar e testar a configuração da IA desta máquina
|
|
2
|
+
// (src/ai/ia-config.js). No multiusuário quem configura é quem administra o servidor (arquivo ou variáveis), não a tela.
|
|
3
|
+
import { PROVIDERS, ROLES, iaFile, loadIA, saveIA, maskedIA, keyOf } from "../ai/ia-config.js";
|
|
4
|
+
import { chat, llmConfig, llmAvailable } from "../ai/llm.js";
|
|
5
|
+
|
|
6
|
+
const json = (res, status, data) => { res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify(data)); };
|
|
7
|
+
|
|
8
|
+
// o que veio da tela; a chave em branco mantém a que já está gravada
|
|
9
|
+
function draft(body, current) {
|
|
10
|
+
const provider = String(body.provider || "");
|
|
11
|
+
const preset = PROVIDERS[provider];
|
|
12
|
+
return {
|
|
13
|
+
provider: preset ? provider : "outro",
|
|
14
|
+
url: String(body.url || preset?.url || "").trim(),
|
|
15
|
+
key: String(body.key || "").trim() || current?.key || "",
|
|
16
|
+
keyEnv: body.key ? "" : String(body.keyEnv ?? current?.keyEnv ?? preset?.keyEnv ?? ""),
|
|
17
|
+
models: Object.fromEntries(ROLES.map((r) => [r, String(body.models?.[r] || "").trim()])),
|
|
18
|
+
...(current?.headers ? { headers: current.headers } : {}),
|
|
19
|
+
...(current?.adaptador ? { adaptador: current.adaptador } : {}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function iaRoutes({ req, res, pathname, opts, readJSON }) {
|
|
24
|
+
if (pathname === "/api/ia" && req.method === "GET") {
|
|
25
|
+
const cfg = llmConfig();
|
|
26
|
+
json(res, 200, { config: maskedIA(loadIA()), file: iaFile(), source: cfg.source, providers: PROVIDERS, roles: ROLES, editable: !opts.multiuser });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if ((pathname === "/api/ia" || pathname === "/api/ia/test") && req.method === "POST") {
|
|
30
|
+
if (opts.multiuser) { json(res, 403, { error: "No servidor multiusuário, a IA é configurada por quem administra." }); return true; }
|
|
31
|
+
const body = await readJSON(req);
|
|
32
|
+
const next = draft(body, loadIA());
|
|
33
|
+
if (!next.url) { json(res, 400, { error: "Falta o endereço do provedor." }); return true; }
|
|
34
|
+
if (!next.models.text) { json(res, 400, { error: "Falta o modelo de texto." }); return true; }
|
|
35
|
+
if (pathname === "/api/ia") {
|
|
36
|
+
try { const saved = saveIA(next); await llmAvailable({ force: true }); json(res, 200, { ok: true, config: maskedIA(saved) }); }
|
|
37
|
+
catch (e) { json(res, 400, { error: e.message }); }
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
// testar: uma pergunta curta com o que está na tela (antes de salvar)
|
|
41
|
+
const base = llmConfig();
|
|
42
|
+
const cfg = { ...base, url: next.url.replace(/\/+$/, ""), key: keyOf(next), headers: next.headers || {}, adaptador: next.adaptador || "",
|
|
43
|
+
textModel: next.models.text, visionModel: next.models.vision || next.models.text, imageModel: next.models.image || "image", searchModel: next.models.search || "",
|
|
44
|
+
timeoutMs: 60_000, firstTimeoutMs: 60_000 };
|
|
45
|
+
const t0 = Date.now();
|
|
46
|
+
try {
|
|
47
|
+
const r = await chat([{ role: "user", content: "Responda só com a palavra: ok" }], { cfg, maxTokens: 200, think: false, allowTruncated: true, retries: 0 });
|
|
48
|
+
json(res, 200, { ok: true, text: String(r.text || "").trim().slice(0, 80), model: r.model || cfg.textModel, ms: Date.now() - t0 });
|
|
49
|
+
} catch (e) { json(res, 200, { ok: false, error: e.message, ms: Date.now() - t0 }); }
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
package/src/studio/project.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Protegida: só some junto com o projeto inteiro. Nada disso vai no pacote .sagadeck
|
|
7
7
|
// (src/package.js leva só o que o deck usa).
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
+
import { renameRetry } from "../fs-retry.js";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import crypto from "node:crypto";
|
|
11
12
|
import JSZip from "jszip";
|
|
@@ -82,7 +83,7 @@ export function ensureMeta(P) {
|
|
|
82
83
|
fs.mkdirSync(path.join(P.dir, CONTEXT), { recursive: true }); // à vista: é aqui que moram anexos, prints e planilhas
|
|
83
84
|
const conv = path.join(P.dir, META, "conversa.json"), legacy = P.file.replace(/\.ya?ml$/i, ".conversa.json");
|
|
84
85
|
// conversa antiga (ao lado do deck) passa para dentro do projeto
|
|
85
|
-
if (fs.existsSync(legacy) && !fs.existsSync(conv)) { try {
|
|
86
|
+
if (fs.existsSync(legacy) && !fs.existsSync(conv)) { try { renameRetry(legacy, conv); } catch {} }
|
|
86
87
|
return conv;
|
|
87
88
|
}
|
|
88
89
|
export const conversationFile = (P) => ensureMeta(P);
|
|
@@ -101,7 +102,7 @@ export function writeText(P, rel, text) {
|
|
|
101
102
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
102
103
|
const tmp = `${abs}.tmp-${process.pid}`;
|
|
103
104
|
fs.writeFileSync(tmp, String(text ?? ""), "utf8");
|
|
104
|
-
|
|
105
|
+
renameRetry(tmp, abs);
|
|
105
106
|
return { path: r };
|
|
106
107
|
}
|
|
107
108
|
export function createFile(P, dirRel, name, text = "") {
|
|
@@ -136,7 +137,7 @@ export function rename(P, fromRel, toName) {
|
|
|
136
137
|
const to = resolveIn(P, path.posix.join(path.posix.dirname(r), n));
|
|
137
138
|
if (fs.existsSync(to)) throw new Error(`Já existe "${n}" nessa pasta.`);
|
|
138
139
|
if (isMeta(relOf(P, to))) throw new Error(`A pasta ${META} é do Studio.`);
|
|
139
|
-
|
|
140
|
+
renameRetry(abs, to);
|
|
140
141
|
return { path: relOf(P, to) };
|
|
141
142
|
}
|
|
142
143
|
export function move(P, fromRel, toDirRel) {
|
|
@@ -146,7 +147,7 @@ export function move(P, fromRel, toDirRel) {
|
|
|
146
147
|
if (isMeta(d)) throw new Error(`A pasta ${META} é do Studio.`);
|
|
147
148
|
if (dest === abs || dest.startsWith(abs + path.sep)) throw new Error("Não dá para mover uma pasta para dentro dela mesma.");
|
|
148
149
|
const to = unique(path.join(dest, path.basename(abs)));
|
|
149
|
-
|
|
150
|
+
renameRetry(abs, to);
|
|
150
151
|
return { path: relOf(P, to) };
|
|
151
152
|
}
|
|
152
153
|
// apagar = mandar para a lixeira do projeto (.sagadeck/lixeira), de onde dá para restaurar
|
|
@@ -158,7 +159,7 @@ export function remove(P, rel) {
|
|
|
158
159
|
fs.mkdirSync(bin, { recursive: true });
|
|
159
160
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
160
161
|
const to = path.join(bin, `${stamp}__${r.replace(/\//g, "__")}`);
|
|
161
|
-
|
|
162
|
+
renameRetry(abs, to);
|
|
162
163
|
return { trashed: relOf(P, to) };
|
|
163
164
|
}
|
|
164
165
|
export function restore(P, trashedRel) {
|
|
@@ -167,7 +168,7 @@ export function restore(P, trashedRel) {
|
|
|
167
168
|
const orig = path.basename(abs).split("__").slice(1).join("/");
|
|
168
169
|
const to = unique(resolveIn(P, orig));
|
|
169
170
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
170
|
-
|
|
171
|
+
renameRetry(abs, to);
|
|
171
172
|
return { path: relOf(P, to) };
|
|
172
173
|
}
|
|
173
174
|
|
|
@@ -228,7 +229,7 @@ export function writeSheet(P, rel, rows) {
|
|
|
228
229
|
const clean = rows.map((r) => r.map((c) => String(c ?? "")));
|
|
229
230
|
while (clean.length > 1 && clean.at(-1).every((c) => c === "")) clean.pop(); // linha vazia no fim (o cursor passou dela) não vai para o arquivo
|
|
230
231
|
fs.writeFileSync(tmp, toCSV(clean, old));
|
|
231
|
-
|
|
232
|
+
renameRetry(tmp, abs);
|
|
232
233
|
return { path: relOf(P, abs), delimiter: old.delimiter };
|
|
233
234
|
}
|
|
234
235
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Tela "Configurar IA" (editor e biblioteca): provedor, chave e modelos desta máquina, com Testar antes de salvar.
|
|
2
|
+
// A chave fica só nesta máquina (~/.sagadeck/ia.json) e nunca volta inteira para a página.
|
|
3
|
+
window.SagaAISettings = (() => {
|
|
4
|
+
const css = `.ai-cfg{max-width:min(560px,calc(100vw - 32px));width:100%;border:1px solid var(--border,#c9ced6);border-radius:12px;padding:18px 20px;background:var(--panel,var(--bg,#fff));color:var(--text,#1d2433);font:14px/1.45 system-ui,sans-serif}
|
|
5
|
+
.ai-cfg::backdrop{background:rgba(0,0,0,.35)}.ai-cfg h2{margin:0 0 6px;font-size:18px}.ai-cfg p{margin:4px 0 10px;color:var(--text-2,#555)}
|
|
6
|
+
.ai-cfg label{display:block;margin:10px 0 3px;font-weight:600;font-size:12px}.ai-cfg input,.ai-cfg select{width:100%;box-sizing:border-box;padding:7px 9px;border:1px solid var(--border,#c9ced6);border-radius:8px;background:var(--bg,#fff);color:inherit;font:inherit}
|
|
7
|
+
.ai-cfg .grid{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}.ai-cfg small{color:var(--text-3,#777);font-size:12px}.ai-cfg .warn{background:#fef3c7;color:#78350f;border-radius:8px;padding:8px 10px}
|
|
8
|
+
.ai-cfg .row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px;flex-wrap:wrap}.ai-cfg button{padding:7px 14px;border-radius:8px;border:1px solid var(--border,#c9ced6);background:transparent;color:inherit;cursor:pointer;font:inherit}
|
|
9
|
+
.ai-cfg button.primary{background:var(--brand,#5b4bdb);border-color:var(--brand,#5b4bdb);color:#fff}.ai-cfg [data-status]{margin-top:10px;min-height:1.4em}.ai-cfg [data-status].ok{color:#15803d}.ai-cfg [data-status].err{color:#b91c1c}`;
|
|
10
|
+
const LABEL = { text: "Modelo de texto", vision: "Modelo que vê imagens", image: "Modelo de imagem", search: "Modelo de busca na web" };
|
|
11
|
+
const HINT = { text: "obrigatório", vision: "opcional; vazio = o de texto", image: "opcional; para gerar ilustrações", search: "opcional; no OpenRouter, um modelo :online" };
|
|
12
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
13
|
+
let dialog, info, onSaved = () => {};
|
|
14
|
+
|
|
15
|
+
function build() {
|
|
16
|
+
if (!document.getElementById("ai-cfg-style")) { const st = document.createElement("style"); st.id = "ai-cfg-style"; st.textContent = css; document.head.append(st); }
|
|
17
|
+
dialog = document.createElement("dialog");
|
|
18
|
+
dialog.className = "ai-cfg";
|
|
19
|
+
dialog.id = "ai-settings-dialog";
|
|
20
|
+
document.body.append(dialog);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function form() {
|
|
24
|
+
const c = info.config || {}, providers = info.providers || {};
|
|
25
|
+
const provider = c.provider && (providers[c.provider] || c.provider === "outro") ? c.provider : (c.url ? "outro" : "openrouter");
|
|
26
|
+
dialog.innerHTML = `<h2>Configurar IA</h2>
|
|
27
|
+
<p>O sagadeck fala direto com o provedor que você escolher. A chave fica só nesta máquina, em <code>${esc(info.file)}</code>.</p>
|
|
28
|
+
${info.source === "variável" ? '<p class="warn">As variáveis SAGADECK_LLM_URL / SAGADECK_LLM_KEY estão definidas e valem por cima desta tela.</p>' : ""}
|
|
29
|
+
${info.editable ? "" : '<p class="warn">Neste servidor, quem configura a IA é quem administra.</p>'}
|
|
30
|
+
<label for="ai-cfg-provider">Provedor</label>
|
|
31
|
+
<select id="ai-cfg-provider" data-f="provider">${Object.entries(providers).map(([k, p]) => `<option value="${k}">${esc(p.label)}</option>`).join("")}<option value="outro">Outro compatível com a API da OpenAI</option></select>
|
|
32
|
+
<label for="ai-cfg-url">Endereço da API</label>
|
|
33
|
+
<input id="ai-cfg-url" data-f="url" placeholder="https://…/v1" spellcheck="false">
|
|
34
|
+
<label for="ai-cfg-key">Chave</label>
|
|
35
|
+
<input id="ai-cfg-key" data-f="key" type="password" autocomplete="off" spellcheck="false" placeholder="${c.keySet ? `configurada (${esc(c.keyHint)}${c.keyFrom === "variável" ? `, da variável ${esc(c.keyEnv)}` : ""}); em branco mantém` : "cole a chave do provedor"}">
|
|
36
|
+
<div class="grid">${(info.roles || []).map((r) => `<div><label for="ai-cfg-${r}">${LABEL[r] || r}</label><input id="ai-cfg-${r}" data-model="${r}" value="${esc(c.models?.[r])}" spellcheck="false"><small>${HINT[r] || ""}</small></div>`).join("")}</div>
|
|
37
|
+
<div data-status role="status"></div>
|
|
38
|
+
<div class="row"><button type="button" data-close>Fechar</button><button type="button" data-test>Testar</button><button type="button" class="primary" data-save>Salvar</button></div>`;
|
|
39
|
+
const sel = dialog.querySelector('[data-f="provider"]'), url = dialog.querySelector('[data-f="url"]');
|
|
40
|
+
sel.value = provider;
|
|
41
|
+
url.value = c.url || providers[provider]?.url || "";
|
|
42
|
+
const sync = () => { const p = providers[sel.value]; if (p) url.value = p.url; url.readOnly = !!p; };
|
|
43
|
+
sync();
|
|
44
|
+
sel.onchange = sync;
|
|
45
|
+
if (!info.editable) dialog.querySelectorAll("input,select,[data-save],[data-test]").forEach((el) => (el.disabled = true));
|
|
46
|
+
dialog.querySelector("[data-close]").onclick = () => dialog.close();
|
|
47
|
+
dialog.querySelector("[data-test]").onclick = () => submit("api/ia/test");
|
|
48
|
+
dialog.querySelector("[data-save]").onclick = () => submit("api/ia");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function submit(endpoint) {
|
|
52
|
+
const status = dialog.querySelector("[data-status]");
|
|
53
|
+
const body = { provider: dialog.querySelector('[data-f="provider"]').value, url: dialog.querySelector('[data-f="url"]').value, key: dialog.querySelector('[data-f="key"]').value,
|
|
54
|
+
models: Object.fromEntries([...dialog.querySelectorAll("[data-model]")].map((el) => [el.dataset.model, el.value])) };
|
|
55
|
+
status.className = ""; status.textContent = endpoint.endsWith("test") ? "Testando…" : "Salvando…";
|
|
56
|
+
try {
|
|
57
|
+
const r = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
58
|
+
const j = await r.json();
|
|
59
|
+
if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`);
|
|
60
|
+
if (endpoint.endsWith("test")) { status.className = "ok"; status.textContent = `Funcionou: o modelo ${j.model} respondeu "${j.text}" em ${(j.ms / 1000).toFixed(1)} s.`; return; }
|
|
61
|
+
info.config = j.config;
|
|
62
|
+
status.className = "ok"; status.textContent = "Salvo.";
|
|
63
|
+
onSaved(j.config);
|
|
64
|
+
} catch (e) { status.className = "err"; status.textContent = (endpoint.endsWith("test") ? "Não funcionou: " : "Não salvou: ") + e.message; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
async open(saved) {
|
|
69
|
+
onSaved = typeof saved === "function" ? saved : () => {};
|
|
70
|
+
if (!dialog) build();
|
|
71
|
+
info = await (await fetch("api/ia")).json();
|
|
72
|
+
form();
|
|
73
|
+
if (!dialog.open) dialog.showModal();
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
})();
|
package/src/studio/public/app.js
CHANGED
|
@@ -1907,6 +1907,8 @@
|
|
|
1907
1907
|
rv.title = slide.review.note || "";
|
|
1908
1908
|
card.appendChild(rv);
|
|
1909
1909
|
}
|
|
1910
|
+
const pv = window.SagaProvenance?.(slide, hydrateIcons); // origem do conteúdo (provenance.js)
|
|
1911
|
+
if (pv) card.appendChild(pv);
|
|
1910
1912
|
if (Array.isArray(slide.auto) && slide.auto.length) {
|
|
1911
1913
|
const badge = document.createElement("span");
|
|
1912
1914
|
badge.className = "thumb-auto";
|
|
@@ -3030,27 +3032,8 @@
|
|
|
3030
3032
|
// ==========================================================================
|
|
3031
3033
|
// LLM: STATUS E GERAÇÃO DE DECK
|
|
3032
3034
|
// ==========================================================================
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
const setupWindow = window.open("about:blank", "sagadeck-ai-setup");
|
|
3036
|
-
let dialog = document.getElementById("ai-settings-dialog");
|
|
3037
|
-
if (!dialog) {
|
|
3038
|
-
dialog = document.createElement("dialog"); dialog.id = "ai-settings-dialog"; dialog.className = "ai-settings-dialog";
|
|
3039
|
-
dialog.innerHTML = '<div class="ai-settings-head"><h2>Configurar IA</h2><button type="button" data-close>Fechar</button></div><p data-ai-info>Verificando a conexão...</p><a data-ai-link target="_blank" rel="noopener" hidden>Abrir configuração do modelrelay</a><button type="button" data-ai-refresh>Verificar conexão</button>';
|
|
3040
|
-
document.body.append(dialog);dialog.querySelector('[data-close]').onclick=()=>dialog.close();
|
|
3041
|
-
dialog.addEventListener('close',()=>refreshAIStatus(true));
|
|
3042
|
-
dialog.querySelector('[data-ai-refresh]').onclick=()=>openAISettings();
|
|
3043
|
-
}
|
|
3044
|
-
if (!dialog.open) dialog.showModal();
|
|
3045
|
-
await refreshAIStatus(true);
|
|
3046
|
-
const info=dialog.querySelector('[data-ai-info]'), link=dialog.querySelector('[data-ai-link]');
|
|
3047
|
-
try {
|
|
3048
|
-
const setup=await (await fetch('api/ai/setup')).json();
|
|
3049
|
-
info.textContent=state.ai.available ? `Conectada · modelo de texto: ${state.ai.textModel}. A configuração abre em uma aba própria.` : 'O serviço de IA não está respondendo. Inicie modelrelay serve neste computador e clique em Verificar conexão.';
|
|
3050
|
-
if(setup.url){link.href=setup.url;link.hidden=false;if(setupWindow){setupWindow.opener=null;setupWindow.location.href=setup.url;dialog.close();}}
|
|
3051
|
-
else {setupWindow?.close();link.hidden=true;}
|
|
3052
|
-
} catch { setupWindow?.close();info.textContent='Não foi possível consultar a configuração. Verifique a conexão e tente novamente.'; }
|
|
3053
|
-
}
|
|
3035
|
+
// Configurar IA: provedor, chave e modelos desta máquina (ai-settings.js)
|
|
3036
|
+
function openAISettings() { window.SagaAISettings.open(() => refreshAIStatus(true)); }
|
|
3054
3037
|
|
|
3055
3038
|
async function refreshAIStatus(force = false) {
|
|
3056
3039
|
try {
|
|
@@ -3063,7 +3046,7 @@
|
|
|
3063
3046
|
dom.aiStatus.textContent = on ? "IA ligada" : "IA desligada";
|
|
3064
3047
|
dom.aiStatus.title = on
|
|
3065
3048
|
? `LLM em ${state.ai.url} · texto: ${state.ai.textModel} · imagem: ${state.ai.imageModel}`
|
|
3066
|
-
: `Nenhum LLM em ${state.ai.url
|
|
3049
|
+
: state.ai.configured ? `Nenhum LLM respondendo em ${state.ai.url}. Clique para conferir o provedor e a chave.` : "A IA não está configurada. Clique para informar provedor e chave.";
|
|
3067
3050
|
dom.aiStatus.classList.toggle("on", on);
|
|
3068
3051
|
dom.aiStatus.classList.toggle("off", !on);
|
|
3069
3052
|
}
|
|
@@ -3071,7 +3054,7 @@
|
|
|
3071
3054
|
function openAiDeckModal() {
|
|
3072
3055
|
dom.aiDeckTheme.innerHTML = '<option value="">A IA escolhe</option>' +
|
|
3073
3056
|
(state.themes || []).map((t) => `<option value="${t}">${t}</option>`).join("");
|
|
3074
|
-
dom.aiDeckStatus.textContent = state.ai.available ? "" :
|
|
3057
|
+
dom.aiDeckStatus.textContent = state.ai.available ? "" : "IA desligada: clique em IA desligada, no alto, para configurar antes de gerar.";
|
|
3075
3058
|
// cada abertura recomeça do sensato: 15 min viram 10 slides
|
|
3076
3059
|
dom.aiDeckMinutes.value = "15";
|
|
3077
3060
|
dom.aiDeckSlides.value = "10";
|
|
@@ -3343,142 +3326,8 @@
|
|
|
3343
3326
|
}
|
|
3344
3327
|
}
|
|
3345
3328
|
|
|
3346
|
-
//
|
|
3347
|
-
|
|
3348
|
-
// --------------------------------------------------------------------------
|
|
3349
|
-
const HF_PRESETS = [
|
|
3350
|
-
{ id: "padrao", name: "Padrão", desc: "Título e número", footer: null, header: null },
|
|
3351
|
-
{ id: "autor", name: "Autor e evento", desc: "Autor · evento e 3 / 20", footer: { left: "{autor} · {evento}", right: "{n} / {total}" }, header: null },
|
|
3352
|
-
{ id: "evento", name: "Evento e data", desc: "Evento, data e número", footer: { left: "{evento}", center: "{data:DD MMM AAAA}", right: "{pagina}" }, header: null },
|
|
3353
|
-
{ id: "corp", name: "Corporativo", desc: "Área e Confidencial em cima; título, data e página embaixo",
|
|
3354
|
-
header: { left: "{depto}", right: "Confidencial" }, footer: { left: "{titulo}", center: "{data:MM/AAAA}", right: "{n} / {total}" } },
|
|
3355
|
-
{ id: "numero", name: "Só o número", desc: "Número discreto no canto", footer: { right: "{n}" }, header: null },
|
|
3356
|
-
{ id: "nenhum", name: "Nenhum", desc: "Slides limpos", footer: false, header: null },
|
|
3357
|
-
];
|
|
3358
|
-
const HF_TOKENS = [["{titulo}", "Título"], ["{autor}", "Autor"], ["{evento}", "Evento"], ["{depto}", "Departamento"], ["{data:DD/MM/AAAA}", "Data"],
|
|
3359
|
-
["{data:DD MMM AAAA}", "Data por extenso"], ["{pagina}", "Página (01)"], ["{n}", "Página (1)"], ["{total}", "Total"]];
|
|
3360
|
-
const HF_SLOTS = ["header.left", "header.center", "header.right", "footer.left", "footer.center", "footer.right"];
|
|
3361
|
-
const hfModal = () => document.getElementById("modal-hf");
|
|
3362
|
-
let hfLastInput = null;
|
|
3363
|
-
let hfTimer = null;
|
|
3364
|
-
|
|
3365
|
-
// footer/header do deck -> valores dos 6 campos (o padrão sem footer = título + número)
|
|
3366
|
-
function hfSlotsFrom(v, kind, deck) {
|
|
3367
|
-
if (v === false) return { left: "", center: "", right: "" };
|
|
3368
|
-
if (v == null) return kind === "footer" ? { left: "{titulo}", center: "", right: "{pagina}" } : { left: "", center: "", right: "" };
|
|
3369
|
-
if (typeof v === "string") return { left: v, center: "", right: "{pagina}" };
|
|
3370
|
-
return { left: v.left || "", center: v.center || "", right: v.right || "" };
|
|
3371
|
-
}
|
|
3372
|
-
|
|
3373
|
-
function hfFill(deckLike) {
|
|
3374
|
-
const m = hfModal();
|
|
3375
|
-
for (const k of ["author", "event", "department", "date"]) m.querySelector(`[data-deck="${k}"]`).value = deckLike[k] || "";
|
|
3376
|
-
for (const kind of ["header", "footer"]) {
|
|
3377
|
-
const v = hfSlotsFrom(deckLike[kind], kind, deckLike);
|
|
3378
|
-
for (const pos of ["left", "center", "right"]) m.querySelector(`[data-slot="${kind}.${pos}"]`).value = v[pos];
|
|
3379
|
-
}
|
|
3380
|
-
}
|
|
3381
|
-
|
|
3382
|
-
// o que os campos do modal significam como deck (footer/header "limpos": null = padrão, false = nenhum)
|
|
3383
|
-
function hfRead() {
|
|
3384
|
-
const m = hfModal();
|
|
3385
|
-
const out = {};
|
|
3386
|
-
for (const k of ["author", "event", "department", "date"]) out[k] = m.querySelector(`[data-deck="${k}"]`).value.trim();
|
|
3387
|
-
for (const kind of ["header", "footer"]) {
|
|
3388
|
-
const v = Object.fromEntries(["left", "center", "right"].map((p) => [p, m.querySelector(`[data-slot="${kind}.${p}"]`).value.trim()]));
|
|
3389
|
-
const empty = !v.left && !v.center && !v.right;
|
|
3390
|
-
if (kind === "footer" && v.left === "{titulo}" && !v.center && v.right === "{pagina}") out.footer = null;
|
|
3391
|
-
else if (empty) out[kind] = kind === "footer" ? false : null;
|
|
3392
|
-
else out[kind] = Object.fromEntries(Object.entries(v).filter(([, x]) => x));
|
|
3393
|
-
}
|
|
3394
|
-
return out;
|
|
3395
|
-
}
|
|
3396
|
-
|
|
3397
|
-
function hfApplyTo(deck, vals) {
|
|
3398
|
-
for (const [k, v] of Object.entries(vals)) {
|
|
3399
|
-
if (v == null || v === "") delete deck[k];
|
|
3400
|
-
else deck[k] = v;
|
|
3401
|
-
}
|
|
3402
|
-
return deck;
|
|
3403
|
-
}
|
|
3404
|
-
|
|
3405
|
-
function openHeaderFooter() {
|
|
3406
|
-
const m = hfModal();
|
|
3407
|
-
const presets = m.querySelector(".hf-presets");
|
|
3408
|
-
if (!presets.childElementCount) {
|
|
3409
|
-
for (const p of HF_PRESETS) {
|
|
3410
|
-
const b = document.createElement("button");
|
|
3411
|
-
b.type = "button";
|
|
3412
|
-
b.className = "hf-preset";
|
|
3413
|
-
b.dataset.preset = p.id;
|
|
3414
|
-
b.innerHTML = `<b></b><span></span>`;
|
|
3415
|
-
b.querySelector("b").textContent = p.name;
|
|
3416
|
-
b.querySelector("span").textContent = p.desc;
|
|
3417
|
-
b.onclick = () => {
|
|
3418
|
-
const cur = hfRead();
|
|
3419
|
-
hfFill({ ...cur, footer: p.footer, header: p.header });
|
|
3420
|
-
presets.querySelectorAll(".hf-preset").forEach((x) => x.classList.toggle("active", x === b));
|
|
3421
|
-
hfPreview();
|
|
3422
|
-
};
|
|
3423
|
-
presets.append(b);
|
|
3424
|
-
}
|
|
3425
|
-
const chips = m.querySelector(".hf-tokens");
|
|
3426
|
-
for (const [tok, label] of HF_TOKENS) {
|
|
3427
|
-
const c = document.createElement("button");
|
|
3428
|
-
c.type = "button";
|
|
3429
|
-
c.className = "hf-token";
|
|
3430
|
-
c.dataset.token = tok;
|
|
3431
|
-
c.textContent = label;
|
|
3432
|
-
c.title = tok;
|
|
3433
|
-
c.onmousedown = (e) => e.preventDefault(); // não rouba o foco do campo
|
|
3434
|
-
c.onclick = () => {
|
|
3435
|
-
const inp = hfLastInput || m.querySelector('[data-slot="footer.left"]');
|
|
3436
|
-
const a = inp.selectionStart ?? inp.value.length, z = inp.selectionEnd ?? a;
|
|
3437
|
-
inp.value = inp.value.slice(0, a) + tok + inp.value.slice(z);
|
|
3438
|
-
inp.focus();
|
|
3439
|
-
inp.setSelectionRange(a + tok.length, a + tok.length);
|
|
3440
|
-
hfPreview();
|
|
3441
|
-
};
|
|
3442
|
-
chips.append(c);
|
|
3443
|
-
}
|
|
3444
|
-
m.querySelectorAll("input").forEach((inp) => {
|
|
3445
|
-
inp.addEventListener("focus", () => { if (inp.dataset.slot) hfLastInput = inp; });
|
|
3446
|
-
inp.addEventListener("input", () => { clearTimeout(hfTimer); hfTimer = setTimeout(hfPreview, 250); });
|
|
3447
|
-
});
|
|
3448
|
-
}
|
|
3449
|
-
hfFill(state.deck);
|
|
3450
|
-
m.classList.remove("hidden");
|
|
3451
|
-
hfPreview();
|
|
3452
|
-
}
|
|
3453
|
-
|
|
3454
|
-
function closeHeaderFooter() {
|
|
3455
|
-
hfModal().classList.add("hidden");
|
|
3456
|
-
}
|
|
3457
|
-
|
|
3458
|
-
async function hfPreview() {
|
|
3459
|
-
const stage = hfModal().querySelector(".hf-stage");
|
|
3460
|
-
const spec = hfApplyTo(JSON.parse(JSON.stringify(state.deck)), hfRead());
|
|
3461
|
-
// na prévia, um slide que mostra rodapé (a capa não mostra)
|
|
3462
|
-
let i = state.currentSlideIndex;
|
|
3463
|
-
const NO_BARS = ["cover", "section", "end", "image", "canvas", "full", "headline"];
|
|
3464
|
-
if (NO_BARS.includes(spec.slides[i]?.layout)) i = Math.max(0, spec.slides.findIndex((s) => !NO_BARS.includes(s.layout)));
|
|
3465
|
-
try {
|
|
3466
|
-
const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
|
|
3467
|
-
body: JSON.stringify({ slide: spec.slides[i], index: i, spec }) })).json();
|
|
3468
|
-
stage.innerHTML = `<div class="thumb-render">${r.html}</div>`;
|
|
3469
|
-
requestAnimationFrame(() => fitRendered(stage));
|
|
3470
|
-
stage.style.setProperty("--thumb-scale", String(stage.clientWidth / 1920));
|
|
3471
|
-
} catch {}
|
|
3472
|
-
}
|
|
3473
|
-
|
|
3474
|
-
function applyHeaderFooter() {
|
|
3475
|
-
hfApplyTo(state.deck, hfRead());
|
|
3476
|
-
closeHeaderFooter();
|
|
3477
|
-
syncDeckToServer();
|
|
3478
|
-
renderCurrentSlide();
|
|
3479
|
-
renderThumbnails();
|
|
3480
|
-
showToast("Cabeçalho e rodapé aplicados em todos os slides", 2200);
|
|
3481
|
-
}
|
|
3329
|
+
// Cabeçalho e rodapé: modelos prontos, campos com variáveis e prévia (header-footer.js)
|
|
3330
|
+
const { openHeaderFooter, closeHeaderFooter, applyHeaderFooter } = window.SagaHeaderFooter({ state, fitRendered, syncDeckToServer, renderCurrentSlide, renderThumbnails, showToast });
|
|
3482
3331
|
|
|
3483
3332
|
// ---------------------------------------------------------------- slides de API
|
|
3484
3333
|
// Ambientes: o ~/.sagadeck/ambientes.yaml como texto, validado pelo Studio antes de gravar
|
|
@@ -3758,86 +3607,8 @@ ${ta.value}`;
|
|
|
3758
3607
|
document.querySelectorAll(".split-button.show, .file-menu-wrap.show").forEach((m) => { if (!m.contains(except)) m.classList.remove("show"); });
|
|
3759
3608
|
}
|
|
3760
3609
|
|
|
3761
|
-
//
|
|
3762
|
-
|
|
3763
|
-
// as do editor, neste navegador. Cada linha diz o que faz; gravar é automático.
|
|
3764
|
-
// ==========================================================================
|
|
3765
|
-
const PREFS_UI = [
|
|
3766
|
-
{ id: "texto", title: "Texto e código", items: [
|
|
3767
|
-
{ k: "texto.minCodePt", type: "num", unit: "pt", min: 6, max: 24, label: "Tamanho mínimo do código ao encolher", hint: "Código que não cabe encolhe até aqui (10 pt ≈ 20 px no slide). Se nem assim couber, ele rola na apresentação e o fiscal avisa." },
|
|
3768
|
-
{ k: "texto.minTextPt", type: "num", unit: "pt", min: 6, max: 30, label: "Tamanho mínimo do texto ao encolher", hint: "Títulos e textos que se ajustam para caber não ficam menores que isto." },
|
|
3769
|
-
{ k: "texto.wrapCode", type: "bool", label: "Quebrar linhas longas de código", hint: "Em vez de encolher o bloco todo por causa de uma linha comprida." },
|
|
3770
|
-
] },
|
|
3771
|
-
{ id: "ia", title: "Inteligência artificial", items: [
|
|
3772
|
-
{ k: "ia.pesquisa", type: "bool", label: "Pesquisar na web quando precisar", hint: "Ranking, lançamentos, preços, um artigo científico: a IA pesquisa, lê as fontes confiáveis e cita cada uma. Desligado (ou sem internet), ela usa só o que sabe e avisa." },
|
|
3773
|
-
{ k: "ia.perguntar", type: "bool", label: "Perguntar quando o pedido não disser para que serve o material", hint: "Ex.: um workshop sem dizer se o pessoal vai guardar o material. Desligado, a IA decide sozinha." },
|
|
3774
|
-
{ k: "ia.imagens", type: "bool", label: "Gerar imagens quando o pedido pedir", hint: "Desligado: só ícones, gráficos e diagramas do sagadeck (sem custo de imagem)." },
|
|
3775
|
-
{ k: "ia.autor", type: "text", label: "Seu nome", hint: "Vai na capa e no rodapé das apresentações novas (no lugar de \"Seu Nome\")." },
|
|
3776
|
-
{ k: "ia.idioma", type: "select", options: [["auto", "O do pedido"], ["português do Brasil", "Português (Brasil)"], ["inglês", "Inglês"], ["espanhol", "Espanhol"]], label: "Idioma do conteúdo gerado" },
|
|
3777
|
-
] },
|
|
3778
|
-
{ id: "exportacao", title: "Exportação", items: [
|
|
3779
|
-
{ k: "exportacao.pdfClaro", type: "bool", label: "PDF na versão clara do tema", hint: "Tema escuro com par claro (ex.: Manual noite) sai claro no PDF, melhor para imprimir." },
|
|
3780
|
-
] },
|
|
3781
|
-
{ id: "editor", title: "Editor (este navegador)", local: true, items: [
|
|
3782
|
-
{ k: "editor.theme", type: "select", options: [["system", "Automática (do sistema)"], ["light", "Clara"], ["dark", "Escura"]], label: "Tema da interface", hint: "Só a interface do Studio; o slide mantém o tema dele.",
|
|
3783
|
-
get: () => store.get("appTheme", "system"), set: (v) => setAppTheme(v) },
|
|
3784
|
-
{ k: "editor.guides", type: "bool", label: "Mostrar as guias da área segura", hint: "O retângulo pontilhado onde o conteúdo cabe sem cortar.",
|
|
3785
|
-
get: () => dom.chkGuides.checked, set: (v) => { dom.chkGuides.checked = v; dom.chkGuides.dispatchEvent(new Event("change", { bubbles: true })); } },
|
|
3786
|
-
{ k: "editor.inspect", type: "bool", label: "Marcar no slide os avisos do fiscal", hint: "Caixas em volta do texto fora da margem, código que não coube, sobreposição.",
|
|
3787
|
-
get: () => dom.chkInspectOverlay.checked, set: (v) => { dom.chkInspectOverlay.checked = v; dom.chkInspectOverlay.dispatchEvent(new Event("change", { bubbles: true })); } },
|
|
3788
|
-
] },
|
|
3789
|
-
];
|
|
3790
|
-
let prefsData = null, prefsTimer = null;
|
|
3791
|
-
const prefGet = (key) => { const [sec, k] = key.split("."); return prefsData?.[sec]?.[k]; };
|
|
3792
|
-
async function openPrefs() {
|
|
3793
|
-
const modal = document.getElementById("modal-prefs");
|
|
3794
|
-
modal.classList.remove("hidden");
|
|
3795
|
-
try {
|
|
3796
|
-
const r = await fetch("api/preferences"); const j = await r.json();
|
|
3797
|
-
prefsData = j.prefs; document.getElementById("prefs-file").textContent = j.file || "";
|
|
3798
|
-
} catch { prefsData = {}; }
|
|
3799
|
-
renderPrefs();
|
|
3800
|
-
const q = document.getElementById("prefs-search"); q.value = ""; q.focus();
|
|
3801
|
-
}
|
|
3802
|
-
function renderPrefs() {
|
|
3803
|
-
const nav = document.getElementById("prefs-nav"), list = document.getElementById("prefs-list");
|
|
3804
|
-
nav.innerHTML = PREFS_UI.map((sec) => `<button type="button" data-prefs-sec="${sec.id}">${escHtml(sec.title)}</button>`).join("");
|
|
3805
|
-
list.innerHTML = PREFS_UI.map((sec) => `<section class="prefs-sec" data-prefs-sec="${sec.id}"><h4>${escHtml(sec.title)}</h4>${sec.items.map((it) => {
|
|
3806
|
-
const v = it.get ? it.get() : prefGet(it.k);
|
|
3807
|
-
const ctl = it.type === "bool" ? `<input type="checkbox" data-pref="${it.k}"${v ? " checked" : ""} aria-label="${escAttr(it.label)}">`
|
|
3808
|
-
: it.type === "select" ? `<select class="form-control" data-pref="${it.k}" aria-label="${escAttr(it.label)}">${it.options.map(([ov, ol]) => `<option value="${ov}"${String(v) === ov ? " selected" : ""}>${escHtml(ol)}</option>`).join("")}</select>`
|
|
3809
|
-
: it.type === "num" ? `<span class="pref-num"><input type="number" class="form-control" data-pref="${it.k}" value="${escAttr(v ?? "")}" min="${it.min}" max="${it.max}" step="1" aria-label="${escAttr(it.label)}"><span>${it.unit || ""}</span></span>`
|
|
3810
|
-
: `<input type="text" class="form-control" data-pref="${it.k}" value="${escAttr(v ?? "")}" aria-label="${escAttr(it.label)}">`;
|
|
3811
|
-
return `<div class="pref-row" data-search="${escAttr(fold(`${sec.title} ${it.label} ${it.hint || ""}`))}"><div class="pref-label"><b>${escHtml(it.label)}</b>${it.hint ? `<small>${escHtml(it.hint)}</small>` : ""}</div><div class="pref-ctl">${ctl}</div></div>`;
|
|
3812
|
-
}).join("")}</section>`).join("");
|
|
3813
|
-
nav.querySelectorAll("[data-prefs-sec]").forEach((b) => b.onclick = () => list.querySelector(`section[data-prefs-sec="${b.dataset.prefsSec}"]`)?.scrollIntoView({ block: "start" }));
|
|
3814
|
-
list.querySelectorAll("[data-pref]").forEach((el) => el.addEventListener("change", () => changePref(el)));
|
|
3815
|
-
}
|
|
3816
|
-
function changePref(el) {
|
|
3817
|
-
const key = el.dataset.pref, it = PREFS_UI.flatMap((s) => s.items).find((x) => x.k === key);
|
|
3818
|
-
const value = el.type === "checkbox" ? el.checked : it.type === "num" ? Number(el.value) : el.value;
|
|
3819
|
-
const status = document.getElementById("prefs-status");
|
|
3820
|
-
if (it.set) { it.set(value); status.textContent = "Salvo neste navegador"; return; }
|
|
3821
|
-
const [sec, k] = key.split(".");
|
|
3822
|
-
(prefsData[sec] ||= {})[k] = value;
|
|
3823
|
-
clearTimeout(prefsTimer);
|
|
3824
|
-
status.textContent = "Salvando…";
|
|
3825
|
-
prefsTimer = setTimeout(async () => {
|
|
3826
|
-
try {
|
|
3827
|
-
const r = await fetch("api/preferences", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prefs: { [sec]: prefsData[sec] } }) });
|
|
3828
|
-
const j = await r.json(); if (!r.ok) throw new Error(j.error || r.status);
|
|
3829
|
-
prefsData = j.prefs;
|
|
3830
|
-
const shown = document.querySelector(`#prefs-list [data-pref="${key}"]`); if (shown && shown.type === "number") shown.value = prefGet(key); // voltou para a faixa aceita
|
|
3831
|
-
status.textContent = "Salvo";
|
|
3832
|
-
renderCurrentSlide(); renderThumbnails(); // os mínimos do ajuste para caber mudam o desenho
|
|
3833
|
-
} catch (e) { status.textContent = "Não salvou: " + e.message; }
|
|
3834
|
-
}, 250);
|
|
3835
|
-
}
|
|
3836
|
-
function filterPrefs() {
|
|
3837
|
-
const q = fold(document.getElementById("prefs-search").value.trim());
|
|
3838
|
-
document.querySelectorAll("#prefs-list .pref-row").forEach((r) => { r.hidden = !!q && !r.dataset.search.includes(q); });
|
|
3839
|
-
document.querySelectorAll("#prefs-list .prefs-sec").forEach((sec) => { sec.hidden = !sec.querySelector(".pref-row:not([hidden])"); });
|
|
3840
|
-
}
|
|
3610
|
+
// Preferências: tela única com busca (preferences-ui.js)
|
|
3611
|
+
const { openPrefs, filterPrefs } = window.SagaPrefs({ dom, store, escHtml, escAttr, fold, setAppTheme, renderCurrentSlide, renderThumbnails });
|
|
3841
3612
|
|
|
3842
3613
|
function syncMotionMenu() {
|
|
3843
3614
|
const cur = state.deck?.motion || "subtle";
|
|
@@ -4300,6 +4071,7 @@ ${ta.value}`;
|
|
|
4300
4071
|
if (files.length) { e.preventDefault(); files.forEach(addChatImage); }
|
|
4301
4072
|
});
|
|
4302
4073
|
dom.chatAttach.onclick = () => dom.chatAttachInput.click();
|
|
4074
|
+
document.getElementById("chat-critique").onclick = () => chatApi.runCritique();
|
|
4303
4075
|
dom.chatAttachInput.onchange = () => { [...dom.chatAttachInput.files].forEach(addChatFile); dom.chatAttachInput.value = ""; };
|
|
4304
4076
|
|
|
4305
4077
|
// Chips de Sugestões de Prompt do Chat
|