sagadeck 1.5.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 +57 -65
- package/SKILL.md +18 -11
- package/bin/sagadeck.js +3 -10
- package/docs/ANDAMENTO.md +72 -2
- package/docs/REFERENCIA.md +2 -2
- package/package.json +2 -3
- package/src/ai/deck-ai.js +1 -1
- package/src/ai/ia-config.js +101 -0
- package/src/ai/llm.js +62 -26
- package/src/library.js +8 -4
- package/src/motion.js +2 -2
- package/src/preferences.js +3 -0
- package/src/research/research.js +6 -5
- package/src/runtime/motion.js +5 -1
- package/src/studio/ai-chat-route.js +1 -1
- package/src/studio/banner.js +18 -0
- package/src/studio/ia-routes.js +53 -0
- package/src/studio/public/ai-settings.js +76 -0
- package/src/studio/public/app.js +4 -23
- package/src/studio/public/index.html +1 -0
- package/src/studio/public/library.html +2 -1
- package/src/studio/public/library.js +5 -4
- package/src/studio/public/preferences-ui.js +3 -0
- package/src/studio/server.js +6 -18
- 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) {
|
package/src/library.js
CHANGED
|
@@ -22,8 +22,9 @@ import YAML from "yaml";
|
|
|
22
22
|
import * as MASTER from "./master.js";
|
|
23
23
|
import { unpackDeck } from "./package.js";
|
|
24
24
|
import { writeDeckFile } from "./deck-file.js";
|
|
25
|
+
import { loadPreferences } from "./preferences.js";
|
|
25
26
|
|
|
26
|
-
export const TRASH_DAYS =
|
|
27
|
+
export const TRASH_DAYS = 30; // padrão; cada máquina escolhe em Preferências › Biblioteca (lixeiraDias)
|
|
27
28
|
const TRASH = ".lixeira";
|
|
28
29
|
const TOPIC_META = ".topico.json";
|
|
29
30
|
const COLORS = ["#d33a2c", "#0f6cbd", "#e5a50a", "#8b5cf6", "#0e9f6e", "#e8590c", "#d6336c", "#495057"];
|
|
@@ -128,7 +129,9 @@ function uniquePath(dir, name, ext = "") {
|
|
|
128
129
|
return p;
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
|
|
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;
|
|
132
135
|
root = path.resolve(root);
|
|
133
136
|
fs.mkdirSync(root, { recursive: true });
|
|
134
137
|
|
|
@@ -191,7 +194,7 @@ export function openLibrary(root) {
|
|
|
191
194
|
topics.push({ id: e.name, name: e.name, color: meta.color || COLORS[topics.length % COLORS.length], count: mine.length, created: meta.created || 0 });
|
|
192
195
|
}
|
|
193
196
|
topics.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"));
|
|
194
|
-
return { root, topics, decks, trash: listTrash() };
|
|
197
|
+
return { root, topics, decks, trash: listTrash(), trashDays: keepDays() };
|
|
195
198
|
}
|
|
196
199
|
|
|
197
200
|
// ---- tópicos ----
|
|
@@ -414,7 +417,8 @@ export function openLibrary(root) {
|
|
|
414
417
|
return all.length;
|
|
415
418
|
}
|
|
416
419
|
function purgeOld(now = Date.now()) {
|
|
417
|
-
|
|
420
|
+
const days = keepDays();
|
|
421
|
+
for (const t of listTrash()) if (now - t.deleted > days * 864e5) purgeDeck(t.id);
|
|
418
422
|
}
|
|
419
423
|
|
|
420
424
|
// ---- importar .sagadeck / .zip ----
|
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;}
|
package/src/preferences.js
CHANGED
|
@@ -22,6 +22,9 @@ export const PREF_SCHEMA = {
|
|
|
22
22
|
exportacao: {
|
|
23
23
|
pdfClaro: { def: false }, // PDF e impressão na versão clara do tema (quando o tema tem par claro/escuro)
|
|
24
24
|
},
|
|
25
|
+
biblioteca: {
|
|
26
|
+
lixeiraDias: { def: 30, min: 1, max: 365 }, // excluída fica restaurável por tantos dias, depois é apagada de vez
|
|
27
|
+
},
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
export function preferencesFile(env = process.env, home = os.homedir()) {
|
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/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
|
}
|
|
@@ -119,7 +119,7 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
|
|
|
119
119
|
// Sem modelo, ninguém decide nada: nem "o que é X?" nem "resuma" viram edição por palavra-chave
|
|
120
120
|
// (as regras antigas trocavam o layout e enfiavam texto genérico no slide). O deck fica como está.
|
|
121
121
|
return {
|
|
122
|
-
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.`,
|
|
123
123
|
spec, actions: [], targetSlide: body.targetSlide, talk: true, mode: "off",
|
|
124
124
|
};
|
|
125
125
|
}
|
|
@@ -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,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
|
+
}
|
|
@@ -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
|
@@ -3032,27 +3032,8 @@
|
|
|
3032
3032
|
// ==========================================================================
|
|
3033
3033
|
// LLM: STATUS E GERAÇÃO DE DECK
|
|
3034
3034
|
// ==========================================================================
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
const setupWindow = window.open("about:blank", "sagadeck-ai-setup");
|
|
3038
|
-
let dialog = document.getElementById("ai-settings-dialog");
|
|
3039
|
-
if (!dialog) {
|
|
3040
|
-
dialog = document.createElement("dialog"); dialog.id = "ai-settings-dialog"; dialog.className = "ai-settings-dialog";
|
|
3041
|
-
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>';
|
|
3042
|
-
document.body.append(dialog);dialog.querySelector('[data-close]').onclick=()=>dialog.close();
|
|
3043
|
-
dialog.addEventListener('close',()=>refreshAIStatus(true));
|
|
3044
|
-
dialog.querySelector('[data-ai-refresh]').onclick=()=>openAISettings();
|
|
3045
|
-
}
|
|
3046
|
-
if (!dialog.open) dialog.showModal();
|
|
3047
|
-
await refreshAIStatus(true);
|
|
3048
|
-
const info=dialog.querySelector('[data-ai-info]'), link=dialog.querySelector('[data-ai-link]');
|
|
3049
|
-
try {
|
|
3050
|
-
const setup=await (await fetch('api/ai/setup')).json();
|
|
3051
|
-
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.';
|
|
3052
|
-
if(setup.url){link.href=setup.url;link.hidden=false;if(setupWindow){setupWindow.opener=null;setupWindow.location.href=setup.url;dialog.close();}}
|
|
3053
|
-
else {setupWindow?.close();link.hidden=true;}
|
|
3054
|
-
} catch { setupWindow?.close();info.textContent='Não foi possível consultar a configuração. Verifique a conexão e tente novamente.'; }
|
|
3055
|
-
}
|
|
3035
|
+
// Configurar IA: provedor, chave e modelos desta máquina (ai-settings.js)
|
|
3036
|
+
function openAISettings() { window.SagaAISettings.open(() => refreshAIStatus(true)); }
|
|
3056
3037
|
|
|
3057
3038
|
async function refreshAIStatus(force = false) {
|
|
3058
3039
|
try {
|
|
@@ -3065,7 +3046,7 @@
|
|
|
3065
3046
|
dom.aiStatus.textContent = on ? "IA ligada" : "IA desligada";
|
|
3066
3047
|
dom.aiStatus.title = on
|
|
3067
3048
|
? `LLM em ${state.ai.url} · texto: ${state.ai.textModel} · imagem: ${state.ai.imageModel}`
|
|
3068
|
-
: `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.";
|
|
3069
3050
|
dom.aiStatus.classList.toggle("on", on);
|
|
3070
3051
|
dom.aiStatus.classList.toggle("off", !on);
|
|
3071
3052
|
}
|
|
@@ -3073,7 +3054,7 @@
|
|
|
3073
3054
|
function openAiDeckModal() {
|
|
3074
3055
|
dom.aiDeckTheme.innerHTML = '<option value="">A IA escolhe</option>' +
|
|
3075
3056
|
(state.themes || []).map((t) => `<option value="${t}">${t}</option>`).join("");
|
|
3076
|
-
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.";
|
|
3077
3058
|
// cada abertura recomeça do sensato: 15 min viram 10 slides
|
|
3078
3059
|
dom.aiDeckMinutes.value = "15";
|
|
3079
3060
|
dom.aiDeckSlides.value = "10";
|
|
@@ -842,6 +842,7 @@
|
|
|
842
842
|
<script src="fit.js"></script>
|
|
843
843
|
<script src="ui-icons.js"></script>
|
|
844
844
|
<script src="update-notice.js"></script>
|
|
845
|
+
<script src="ai-settings.js"></script>
|
|
845
846
|
<script src="calc-fields.js"></script>
|
|
846
847
|
<script src="art-preview.js"></script>
|
|
847
848
|
<script src="api-collections.js"></script>
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
<div class="lib-body">
|
|
32
32
|
<aside class="lib-side">
|
|
33
33
|
<nav id="side" aria-label="Biblioteca"></nav>
|
|
34
|
-
<
|
|
34
|
+
<button type="button" class="lib-btn ghost ai-settings" id="btn-ai" hidden title="Provedores, chaves e modelos de IA"><i class="ic" data-ic="sliders-horizontal"></i><span class="lbl">Configurar IA</span></button>
|
|
35
35
|
</aside>
|
|
36
36
|
<main class="lib-main" id="main" tabindex="-1" aria-live="polite"><div class="initial-loading"><span class="loading-orbit"></span>Preparando seu estúdio…</div></main>
|
|
37
37
|
</div>
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
<div class="ltoast" id="toast" role="status"></div>
|
|
52
52
|
<script src="ui-icons.js"></script>
|
|
53
53
|
<script src="update-notice.js"></script>
|
|
54
|
+
<script src="ai-settings.js"></script>
|
|
54
55
|
<script src="library.js"></script>
|
|
55
56
|
</body>
|
|
56
57
|
</html>
|
|
@@ -58,8 +58,9 @@
|
|
|
58
58
|
if (view !== "recentes" && view !== "todas" && view !== "lixeira" && !topicOf(view)) view = "recentes";
|
|
59
59
|
render();
|
|
60
60
|
}
|
|
61
|
-
// botão IA:
|
|
62
|
-
api("api/
|
|
61
|
+
// botão IA: provedor, chave e modelos desta máquina (ai-settings.js); no multiusuário quem configura é quem administra
|
|
62
|
+
api("api/ia").then(({ editable }) => { $("#btn-ai").hidden = !editable; }).catch(() => {});
|
|
63
|
+
$("#btn-ai").onclick = () => window.SagaAISettings.open();
|
|
63
64
|
const openEditor = (id, present) => { location.href = `editor?deck=${encodeURIComponent(id)}${present ? "&present=1" : ""}`; };
|
|
64
65
|
|
|
65
66
|
// ---------------------------------------------------------------- barra lateral
|
|
@@ -111,7 +112,7 @@
|
|
|
111
112
|
const match = (d) => !q || d.title.toLowerCase().includes(q);
|
|
112
113
|
const main = $("#main");
|
|
113
114
|
if (view === "lixeira") {
|
|
114
|
-
main.innerHTML = `<div class="head"><div><h1>Lixeira</h1><div class="sub">Fica aqui por um dia; depois é apagado de vez.</div></div>${data.trash.length ? `<button class="lib-btn ghost danger" data-empty-trash>${ic("trash-2")}Esvaziar lixeira</button>` : ""}</div>` +
|
|
115
|
+
main.innerHTML = `<div class="head"><div><h1>Lixeira</h1><div class="sub">Fica aqui por ${data.trashDays === 1 ? "um dia" : `${data.trashDays || 30} dias`}; depois é apagado de vez (o prazo se muda em Preferências).</div></div>${data.trash.length ? `<button class="lib-btn ghost danger" data-empty-trash>${ic("trash-2")}Esvaziar lixeira</button>` : ""}</div>` +
|
|
115
116
|
(data.trash.length ? data.trash.map((t) => `<div class="trash-row"><div class="t"><b>${esc(t.title)}</b><div class="sub">Era de ${esc(t.topic || NO_TOPIC.name)} · ${t.slides || 0} slides · excluída ${ago(t.deleted)}</div></div>
|
|
116
117
|
<button class="lib-btn ghost" data-restore="${esc(t.id)}">${ic("rotate-ccw")}Restaurar</button><button class="lib-btn ghost danger" data-purge="${esc(t.id)}">Excluir de vez</button></div>`).join("")
|
|
117
118
|
: `<div class="empty"><div class="big">${ic("trash-2")}</div><h2>Lixeira vazia</h2></div>`);
|
|
@@ -211,7 +212,7 @@
|
|
|
211
212
|
if (a === "open" || a === "present") return openEditor(id, a === "present");
|
|
212
213
|
if (a === "rename") return nameDialog("Renomear apresentação", d.title, (title) => act(() => api("api/library/decks/rename", { id, title }), "Renomeada"));
|
|
213
214
|
if (a === "dup") return act(() => api("api/library/decks/duplicate", { id }), "Cópia criada");
|
|
214
|
-
if (a === "trash") return act(() => api("api/library/decks/trash", { id }),
|
|
215
|
+
if (a === "trash") return act(() => api("api/library/decks/trash", { id }), `Na lixeira — dá para restaurar por ${data.trashDays === 1 ? "um dia" : `${data.trashDays || 30} dias`}`);
|
|
215
216
|
});
|
|
216
217
|
m.querySelectorAll("[data-move]").forEach((b) => b.onclick = () => { closeMenus(); act(() => api("api/library/decks/move", { id, topic: b.dataset.move }), `Movida para ${topicOf(b.dataset.move).name}`); });
|
|
217
218
|
m.querySelectorAll("[data-dl]").forEach((b) => b.onclick = () => {
|
|
@@ -15,6 +15,9 @@ window.SagaPrefs = function ({ dom, store, escHtml, escAttr, fold, setAppTheme,
|
|
|
15
15
|
{ k: "ia.autor", type: "text", label: "Seu nome", hint: "Vai na capa e no rodapé das apresentações novas (no lugar de \"Seu Nome\")." },
|
|
16
16
|
{ 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" },
|
|
17
17
|
] },
|
|
18
|
+
{ id: "biblioteca", title: "Biblioteca", items: [
|
|
19
|
+
{ k: "biblioteca.lixeiraDias", type: "select", options: [["1", "1 dia"], ["7", "7 dias"], ["30", "30 dias"], ["90", "90 dias"]], label: "Quanto tempo a lixeira guarda", hint: "Apresentação excluída fica restaurável por esse tempo; depois é apagada de vez." },
|
|
20
|
+
] },
|
|
18
21
|
{ id: "exportacao", title: "Exportação", items: [
|
|
19
22
|
{ 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." },
|
|
20
23
|
] },
|