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/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
- // Por padrão fala com o `modelrelay serve` local (http://127.0.0.1:8765/v1), que decide pela
5
- // configuração dele para onde a chamada vai (OpenRouter, OpenAI, gateway corporativo...).
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 (padrão: http://127.0.0.1:8765/v1)
9
- // SAGADECK_LLM_KEY bearer token opcional (ex.: chave do OpenRouter se apontar direto)
10
- // SAGADECK_TEXT_MODEL modelo de texto (padrão: "text" — apelido no [models] do modelrelay)
11
- // SAGADECK_IMAGE_MODEL modelo de imagem (padrão: "image" — idem)
12
- // SAGADECK_VISION_MODEL modelo que vê imagens (padrão: "vision" — quando o de texto não enxerga, a chamada com
13
- // imagem vai para ele em vez de perder a imagem; quem configura o relay decide qual é)
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 || "http://127.0.0.1:8765/v1").replace(/\/+$/, ""),
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", "X-Modelrelay-App": cfg.app || "sagadeck" };
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 fetch(`${cfg.url}/models`, { headers: headers(cfg), signal: AbortSignal.timeout(2500) });
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}|${cfg.app}|${opts.model || cfg.textModel}`;
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}|${cfg.app}|${vision}`)) return null;
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}|${cfg.app}|${vision}`); return null; } throw e; }
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
- const body = { model: model || cfg.textModel, messages };
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 fetch(`${cfg.url}/chat/completions`, {
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
- "Rode `modelrelay serve` ou ajuste SAGADECK_LLM_URL.", { cause: e });
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 fetch(`${cfg.url}/chat/completions`, {
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
- "Rode `modelrelay serve` ou ajuste SAGADECK_LLM_URL.", { cause: e });
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 = 1;
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
- export function openLibrary(root) {
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
- for (const t of listTrash()) if (now - t.deleted > TRASH_DAYS * 864e5) purgeDeck(t.id);
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;let reduced=!!o.still||matchMedia('(prefers-reduced-motion:reduce)').matches;const main=document.querySelector('main'),canvas=document.querySelector('canvas'),ctx=canvas.getContext('2d');
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'){reduced=true;if(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;}});
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;}
@@ -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()) {
@@ -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 "search" do modelrelay (no OpenRouter, um modelo com ":online": a busca na web dele, por API
70
- // oficial). O DuckDuckGo direto passou a responder com desafio anti-robô; ele fica como segunda tentativa. Sem o
71
- // apelido "search" no modelrelay (a rede do banco), não há buscador: a pesquisa avisa e segue sem inventar.
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 = process.env.SAGADECK_SEARCH_MODEL || "search";
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 || "") }));
@@ -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);if(document.documentElement.classList.contains('export')||document.documentElement.dataset.motion==='none'||matchMedia('(prefers-reduced-motion: reduce)').matches)send('poster');if(!active){started=false;done=false;frame.style.visibility='';send('reset');}};
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. Rode "modelrelay serve" ou defina SAGADECK_LLM_URL e mande de novo.`,
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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
+ })();
@@ -3032,27 +3032,8 @@
3032
3032
  // ==========================================================================
3033
3033
  // LLM: STATUS E GERAÇÃO DE DECK
3034
3034
  // ==========================================================================
3035
- async function openAISettings() {
3036
- // O console protege-se com frame-ancestors self: abrir numa aba, nunca dentro de iframe.
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 || "?"}. Rode "modelrelay serve" ou defina SAGADECK_LLM_URL. Clique para configurar a IA.`;
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 ? "" : 'Nenhum LLM disponível — rode "modelrelay serve" antes de gerar.';
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
- <a class="lib-btn ghost ai-settings" id="btn-ai" hidden target="_blank" rel="noopener" title="Provedores, chaves e modelos de IA"><i class="ic" data-ic="sliders-horizontal"></i><span class="lbl">modelrelay</span></a>
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: a tela de configuração do modelrelay local, quando existe
62
- api("api/ai/setup").then(({ url }) => { if (url) { $("#btn-ai").href = url; $("#btn-ai").hidden = false; } }).catch(() => {});
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 }), "Na lixeira — dá para restaurar por um dia");
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
  ] },