sagadeck 1.5.0 → 1.7.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.
@@ -0,0 +1,107 @@
1
+ // A IA desta máquina: provedor, chave e modelos, num arquivo fora do código e fora da biblioteca
2
+ // (~/.sagadeck/ia.json, ou SAGADECK_IA). Qualquer API compatível com a da OpenAI (OpenRouter, OpenAI, proxy da empresa);
3
+ // a pessoa configura pela tela do Studio (IA › Configurar) ou à mão, sem mexer no código.
4
+ //
5
+ // Antes isso morava num programa à parte (o modelrelay, em Python, rodando na porta 8765). Na primeira vez, a
6
+ // configuração dele (~/.modelrelay/config.toml) é trazida para cá, para ninguém perder a chave.
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { renameRetry } from "../fs-retry.js";
11
+
12
+ export const PROVIDERS = {
13
+ openrouter: { label: "OpenRouter", url: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY" },
14
+ openai: { label: "OpenAI", url: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY" },
15
+ };
16
+ // Recomendação para começar, mostrada na tela Configurar IA. Serve também de teste da instalação: se nem com ela a IA
17
+ // responde, o problema é o ambiente (rede, chave), não o modelo escolhido. Depois, cada um põe o modelo que quiser.
18
+ export const RECOMMENDED = {
19
+ provider: "openrouter",
20
+ models: { text: "deepseek/deepseek-v4.1-flash", vision: "deepseek/deepseek-v4.1-flash", image: "google/gemini-3.1-flash-image", search: "deepseek/deepseek-v4.1-flash:online" },
21
+ };
22
+ // os papéis que o sagadeck usa; cada um aponta para um modelo do provedor
23
+ export const ROLES = ["text", "vision", "image", "search"];
24
+
25
+ export function iaFile(env = process.env, home = os.homedir()) {
26
+ return env.SAGADECK_IA || path.join(home, ".sagadeck", "ia.json");
27
+ }
28
+
29
+ const str = (v, max = 500) => (typeof v === "string" ? v.trim().slice(0, max) : "");
30
+
31
+ // só os campos conhecidos, cada um no tipo certo (arquivo editado à mão não quebra o Studio)
32
+ export function cleanIA(raw) {
33
+ if (!raw || typeof raw !== "object") return null;
34
+ const models = Object.fromEntries(ROLES.map((r) => [r, str(raw.models?.[r], 200)]).filter(([, v]) => v));
35
+ const headers = raw.headers && typeof raw.headers === "object"
36
+ ? Object.fromEntries(Object.entries(raw.headers).filter(([k, v]) => /^[\w-]+$/.test(k) && typeof v === "string").map(([k, v]) => [k, v.slice(0, 500)])) : {};
37
+ const out = { provider: str(raw.provider, 40), url: str(raw.url).replace(/\/+$/, ""), key: str(raw.key, 1000), keyEnv: str(raw.keyEnv, 100), models };
38
+ if (Object.keys(headers).length) out.headers = headers;
39
+ if (str(raw.adaptador)) out.adaptador = str(raw.adaptador);
40
+ return out.url || out.adaptador ? out : null;
41
+ }
42
+
43
+ export function loadIA(file = iaFile()) {
44
+ try { return cleanIA(JSON.parse(fs.readFileSync(file, "utf8"))); } catch { return null; }
45
+ }
46
+
47
+ // grava de forma atômica, só para quem é dono da conta (a chave mora aqui)
48
+ export function saveIA(cfg, file = iaFile()) {
49
+ const clean = cleanIA(cfg);
50
+ if (!clean) throw new Error("Falta o endereço do provedor.");
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ const tmp = `${file}.tmp-${process.pid}`;
53
+ fs.writeFileSync(tmp, JSON.stringify(clean, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
54
+ renameRetry(tmp, file);
55
+ return clean;
56
+ }
57
+
58
+ // a chave que vale: a do arquivo ou a da variável de ambiente indicada
59
+ export function keyOf(cfg, env = process.env) {
60
+ return cfg?.key || (cfg?.keyEnv ? env[cfg.keyEnv] || "" : "");
61
+ }
62
+
63
+ // para mostrar na tela: a chave nunca sai inteira
64
+ export function maskedIA(cfg, env = process.env) {
65
+ if (!cfg) return null;
66
+ const key = keyOf(cfg, env);
67
+ const { key: _k, ...rest } = cfg;
68
+ return { ...rest, keySet: !!key, keyHint: key ? `••••${key.slice(-4)}` : "", keyFrom: cfg.key ? "arquivo" : cfg.keyEnv && key ? "variável" : "" };
69
+ }
70
+
71
+ // ---- migração do modelrelay -------------------------------------------------------------------------------------
72
+ // Só o pedaço de TOML que a tela do modelrelay escrevia: [seções], chave = "texto" e comentários.
73
+ export function parseSimpleToml(text) {
74
+ const out = { "": {} };
75
+ let section = "";
76
+ for (const raw of String(text).split(/\r?\n/)) {
77
+ const line = raw.replace(/^\s+|\s+$/g, "");
78
+ if (!line || line.startsWith("#")) continue;
79
+ const sec = /^\[([^\]]+)\]$/.exec(line);
80
+ if (sec) { section = sec[1].trim(); out[section] ||= {}; continue; }
81
+ const kv = /^("?)([^"=]+?)\1\s*=\s*"((?:[^"\\]|\\.)*)"\s*(#.*)?$/.exec(line);
82
+ if (kv) out[section][kv[2].trim()] = kv[3].replace(/\\(["\\])/g, "$1");
83
+ }
84
+ return out;
85
+ }
86
+
87
+ // configuração do modelrelay → a desta máquina (provedor padrão, chave, modelos do sagadeck por cima dos gerais)
88
+ export function fromModelrelay(toml) {
89
+ const t = parseSimpleToml(toml);
90
+ const name = t[""].provider || Object.keys(t).find((k) => k.startsWith("providers."))?.slice(10);
91
+ const p = (name && t[`providers.${name}`]) || {};
92
+ if (!p.base_url) return null;
93
+ const all = { ...(t.models || {}), ...(t["apps.sagadeck.models"] || {}) };
94
+ const models = Object.fromEntries(ROLES.filter((r) => all[r]).map((r) => [r, all[r]]));
95
+ return cleanIA({ provider: PROVIDERS[name] ? name : "outro", url: p.base_url, key: p.api_key || "", keyEnv: p.api_key_env || "", models });
96
+ }
97
+
98
+ // na primeira vez: sem ia.json e com o config.toml do modelrelay, traz a configuração (não sobrescreve nada)
99
+ export function migrateModelrelay({ file = iaFile(), home = os.homedir(), env = process.env } = {}) {
100
+ if (fs.existsSync(file)) return null;
101
+ const toml = env.MODELRELAY_CONFIG || path.join(home, ".modelrelay", "config.toml");
102
+ let cfg;
103
+ try { cfg = fromModelrelay(fs.readFileSync(toml, "utf8")); } catch { return null; }
104
+ if (!cfg) return null;
105
+ saveIA(cfg, file);
106
+ return { from: toml, to: file };
107
+ }
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/api-client.js CHANGED
@@ -25,6 +25,7 @@ import { connectWs } from "./ws.js";
25
25
  import { protect, unprotect, unprotectAll, PROTECTION } from "./protect.js";
26
26
 
27
27
  const C = globalThis.SagadeckApiCore;
28
+ const BEARER = "Bearer {{secret.token}}"; // o "token fixo" do formulário: cabeçalho em todo pedido do ambiente
28
29
 
29
30
  // As variáveis e segredos dos slides de API moram FORA das apresentações: ~/.sagadeck/ambientes.yaml (no Windows,
30
31
  // C:\Users\<você>\.sagadeck\ambientes.yaml) ou SAGADECK_AMBIENTES. Mandar um deck para alguém nunca leva segredo
@@ -128,7 +129,7 @@ export class ApiEnvironments {
128
129
  catch (e) { throw new ApiError(`${this.file}: YAML inválido: ${e.message}`, "config"); }
129
130
  const envs = data.environments && typeof data.environments === "object" ? data.environments : {};
130
131
  // segredos cifrados (dpapi:…): decifra todos de uma vez (um PowerShell só)
131
- try { unprotectAll(Object.values(envs).flatMap((e) => Object.values((e && e.secrets) || {}))); }
132
+ try { unprotectAll(Object.values(envs).flatMap((e) => [...Object.values((e && e.secrets) || {}), e?.token?.client_secret].filter((v) => typeof v === "string"))); }
132
133
  catch (e) { throw new ApiError(`${this.file}: ${e.message}`, "config"); }
133
134
  return { current: data.current || null, environments: envs };
134
135
  }
@@ -236,6 +237,106 @@ export class ApiEnvironments {
236
237
  return this.state();
237
238
  }
238
239
 
240
+ // ---- tela Ambientes por campos (src/studio/public/api-envs-form.js) ----------------------------------------------
241
+ // O mesmo arquivo, visto como formulário: endereço base, variáveis, segredos (só o nome e de onde vêm; o valor nunca
242
+ // sai) e a autenticação (nenhuma, token fixo ou token que expira). O que o formulário não conhece (ca, insecure,
243
+ // outros cabeçalhos) fica no arquivo como estava.
244
+ formState() {
245
+ const raw = fs.existsSync(this.file) ? (YAML.parse(fs.readFileSync(this.file, "utf8")) || {}) : {};
246
+ const envs = raw.environments && typeof raw.environments === "object" ? raw.environments : {};
247
+ return {
248
+ file: this.file, exists: fs.existsSync(this.file), protection: PROTECTION, current: raw.current || null,
249
+ builtin: Object.keys(this.builtin),
250
+ envs: Object.entries(envs).map(([name, e = {}]) => {
251
+ const bearer = e.headers?.Authorization === BEARER;
252
+ const auth = e.token?.url ? { type: "token", url: e.token.url, client_id: e.token.client_id || "", field: e.token.field || "", secretEnv: e.token.client_secret_env || "", secretSet: e.token.client_secret != null }
253
+ : bearer ? { type: "bearer", secretEnv: typeof e.secrets?.token === "object" ? e.secrets.token.env || "" : "", secretSet: typeof e.secrets?.token === "string" }
254
+ : { type: "nenhuma" };
255
+ const otherHeaders = Object.keys(e.headers || {}).filter((h) => !(bearer && h === "Authorization"));
256
+ return {
257
+ name, base: e.vars?.base != null ? String(e.vars.base) : "",
258
+ vars: Object.entries(e.vars || {}).filter(([k]) => k !== "base").map(([k, v]) => ({ name: k, value: String(v) })),
259
+ secrets: Object.entries(e.secrets || {}).filter(([k]) => !(bearer && k === "token")).map(([k, v]) => ({ name: k, env: typeof v === "object" && v ? v.env || "" : "", set: typeof v === "string" })),
260
+ auth, advanced: !!(e.ca || e.insecure || otherHeaders.length),
261
+ };
262
+ }),
263
+ };
264
+ }
265
+
266
+ saveForm(list, { current } = {}) {
267
+ if (!Array.isArray(list)) throw new ApiError("Mande a lista de ambientes.", "config");
268
+ const NAME = /^[A-Za-z0-9_-]{1,40}$/, VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
269
+ const seen = new Set();
270
+ for (const f of list) {
271
+ if (!NAME.test(String(f?.name || ""))) throw new ApiError(`Nome de ambiente inválido: "${f?.name || ""}". Use letras, números, - e _ (ex.: dev, hom, prod).`, "config");
272
+ if (seen.has(f.name)) throw new ApiError(`O ambiente "${f.name}" aparece duas vezes.`, "config");
273
+ seen.add(f.name);
274
+ if (f.base && !/^https?:\/\//i.test(f.base)) throw new ApiError(`Ambiente "${f.name}": o endereço base começa com http:// ou https://.`, "config");
275
+ for (const v of [...(f.vars || []), ...(f.secrets || [])]) if (!VAR.test(String(v?.name || ""))) throw new ApiError(`Ambiente "${f.name}": nome inválido "${v?.name || ""}". Use letras, números e _ (ex.: path_id).`, "config");
276
+ if (f.auth?.type === "token" && !/^https?:\/\//i.test(f.auth.url || "")) throw new ApiError(`Ambiente "${f.name}": falta o endereço do token (https://…).`, "config");
277
+ }
278
+ this.editFile((doc) => {
279
+ const old = doc.toJS()?.environments || {};
280
+ for (const name of Object.keys(old)) if (!seen.has(name)) doc.deleteIn(["environments", name]);
281
+ for (const f of list) {
282
+ const at = (...k) => ["environments", f.name, ...k];
283
+ const prev = old[f.name] || {};
284
+ if (!doc.hasIn(at())) doc.setIn(at(), doc.createNode({}));
285
+ // variáveis: base + as da tabela; a que saiu da tabela sai do arquivo
286
+ const vars = { ...(f.base ? { base: f.base } : {}), ...Object.fromEntries((f.vars || []).map((v) => [v.name, String(v.value ?? "")])) };
287
+ for (const k of Object.keys(prev.vars || {})) if (!(k in vars)) doc.deleteIn(at("vars", k));
288
+ for (const [k, v] of Object.entries(vars)) doc.setIn(at("vars", k), v);
289
+ // segredos: valor novo (cifrado), variável de ambiente, ou fica o que já estava
290
+ const keepToken = f.auth?.type === "bearer";
291
+ const wanted = new Set([...(f.secrets || []).map((x) => x.name), ...(keepToken ? ["token"] : [])]);
292
+ for (const k of Object.keys(prev.secrets || {})) if (!wanted.has(k)) doc.deleteIn(at("secrets", k));
293
+ const putSecret = (name, { value, env } = {}) => {
294
+ if (env) doc.setIn(at("secrets", name), doc.createNode({ env }));
295
+ else if (value) doc.setIn(at("secrets", name), protect(String(value)));
296
+ else if (!doc.hasIn(at("secrets", name))) throw new ApiError(`Ambiente "${f.name}": falta o valor do segredo "${name}".`, "config");
297
+ };
298
+ for (const x of f.secrets || []) putSecret(x.name, x);
299
+ // autenticação
300
+ const ourBearer = doc.getIn(at("headers", "Authorization")) === BEARER;
301
+ if (f.auth?.type === "bearer") {
302
+ putSecret("token", { value: f.auth.value, env: f.auth.secretEnv });
303
+ doc.setIn(at("headers", "Authorization"), BEARER);
304
+ } else if (ourBearer) doc.deleteIn(at("headers", "Authorization"));
305
+ if (doc.getIn(at("headers")) && !Object.keys(doc.getIn(at("headers")).toJSON?.() || {}).length) doc.deleteIn(at("headers"));
306
+ if (f.auth?.type === "token") {
307
+ const t = { url: f.auth.url, ...(f.auth.client_id ? { client_id: f.auth.client_id } : {}), ...(f.auth.field ? { field: f.auth.field } : {}) };
308
+ for (const k of ["url", "client_id", "field"]) if (!(k in t)) doc.deleteIn(at("token", k)); else doc.setIn(at("token", k), t[k]);
309
+ if (f.auth.secretEnv) { doc.setIn(at("token", "client_secret_env"), f.auth.secretEnv); doc.deleteIn(at("token", "client_secret")); }
310
+ else if (f.auth.value) { doc.setIn(at("token", "client_secret"), protect(String(f.auth.value))); doc.deleteIn(at("token", "client_secret_env")); }
311
+ else if (!doc.hasIn(at("token", "client_secret")) && !doc.hasIn(at("token", "client_secret_env"))) throw new ApiError(`Ambiente "${f.name}": falta o segredo do token (client secret).`, "config");
312
+ } else if (doc.hasIn(at("token"))) doc.deleteIn(at("token"));
313
+ }
314
+ const names = list.map((f) => f.name);
315
+ doc.set("current", names.includes(current) ? current : names[0] || null);
316
+ });
317
+ this.tokens.clear();
318
+ return this.formState();
319
+ }
320
+
321
+ // Testar um ambiente salvo: o endereço base responde? o token sai? (só diz o que aconteceu; nada vai para a tela)
322
+ async check(name) {
323
+ const env = this.env(name);
324
+ const out = { name: env.name };
325
+ const base = env.vars?.base;
326
+ if (base) {
327
+ try {
328
+ const res = await open({ method: "GET", url: String(base), headers: { Accept: "*/*", ...C.render(env.headers || {}, this.secretVars(env)) }, ca: this.ca(env), insecure: !!env.insecure, timeout: 10000 });
329
+ res.resume();
330
+ out.base = { ok: true, status: res.statusCode };
331
+ } catch (e) { out.base = { ok: false, error: this.maskText(env, e.message) }; }
332
+ }
333
+ if (env.token?.url) {
334
+ try { const v = await this.token(env, { renew: true }); out.token = { ok: true, last4: v.slice(-4) }; }
335
+ catch (e) { out.token = { ok: false, error: this.maskText(env, e.message) }; }
336
+ }
337
+ return out;
338
+ }
339
+
239
340
  env(name) {
240
341
  const data = this.load();
241
342
  const n = name || this.currentName(data);
@@ -257,7 +358,7 @@ export class ApiEnvironments {
257
358
 
258
359
  secrets(env) {
259
360
  const t = env.token || {};
260
- const out = [this.tokens.get(env.name)?.value, t.client_secret, t.client_secret_env && process.env[t.client_secret_env], ...Object.values(env.headers || {}), ...Object.values(this.secretVars(env))];
361
+ const out = [this.tokens.get(env.name)?.value, t.client_secret != null ? unprotect(t.client_secret) : null, t.client_secret_env && process.env[t.client_secret_env], ...Object.values(env.headers || {}), ...Object.values(this.secretVars(env))];
261
362
  return out.filter((s) => typeof s === "string" && s.length >= 6);
262
363
  }
263
364
 
@@ -273,7 +374,7 @@ export class ApiEnvironments {
273
374
  const cached = this.tokens.get(env.name);
274
375
  if (!renew && cached && Date.now() < cached.renewAt) return cached.value;
275
376
  const id = t.client_id ?? (t.client_id_env && process.env[t.client_id_env]);
276
- const secret = t.client_secret ?? (t.client_secret_env && process.env[t.client_secret_env]);
377
+ const secret = (t.client_secret != null ? unprotect(t.client_secret) : null) ?? (t.client_secret_env && process.env[t.client_secret_env]);
277
378
  if (!id || !secret) throw new ApiError(`Ambiente "${env.name}": falta client_id/client_secret do token (ou a variável ${t.client_secret_env || t.client_id_env || "…"})`, "config");
278
379
  const fields = { [t.id_field || "client_id"]: id, [t.secret_field || "client_secret"]: secret, ...(t.extra || {}) };
279
380
  const form = t.format === "form";
@@ -320,7 +421,7 @@ export class ApiEnvironments {
320
421
  throw new ApiError(`Ambiente "${env.name}" não tem ${names.join(", ")} configurado em secrets:`, "config");
321
422
  }
322
423
  req = { ...req, url: C.render(req.url, sv), headers: C.render(req.headers || {}, sv), body: C.render(req.body, sv) };
323
- const headers = { Accept: "application/json, text/event-stream, */*", ...(env.headers || {}), ...(req.headers || {}) };
424
+ const headers = { Accept: "application/json, text/event-stream, */*", ...C.render(env.headers || {}, sv), ...(req.headers || {}) };
324
425
  let body = req.body;
325
426
  const file = req.file || null; // { name, type, data: Buffer } — o arquivo do slide (upload ou base64)
326
427
  const needsFile = (req.form && Object.values(req.form).includes("@file")) || /\{\{\s*file\./.test(JSON.stringify(body ?? ""));
@@ -488,7 +589,7 @@ export class ApiEnvironments {
488
589
  const sv = this.secretVars(env);
489
590
  let url = C.render(String(rt.url || ""), { ...(env.vars || {}), ...sv });
490
591
  const t = env.token || {};
491
- const headers = { ...(env.headers || {}) };
592
+ const headers = { ...C.render(env.headers || {}, sv) };
492
593
  const auth = String(rt.auth || "header");
493
594
  if (auth !== "none") {
494
595
  const tok = await this.token(env);
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
  }
@@ -54,10 +54,21 @@ export async function apiRoutes({ req, res, pathname, url, W, apiEnv, rtSessions
54
54
  const exists = fs.existsSync(apiEnv.file);
55
55
  return reply(200, { file: apiEnv.file, exists, text: exists ? fs.readFileSync(apiEnv.file, "utf8") : AMBIENTES_MODELO, builtin: Object.keys(apiEnv.builtin) });
56
56
  }
57
+ // tela Ambientes por campos: o mesmo arquivo como formulário (o valor de segredo nunca vem)
58
+ if (pathname === "/api/http/ambientes/form" && req.method === "GET") {
59
+ try { return reply(200, apiEnv.formState()); } catch (e) { return reply(400, { error: `${apiEnv.file}: ${e.message}` }); }
60
+ }
57
61
  if (req.method !== "POST") return reply(405, { error: "use POST" });
58
62
  if (!/^application\/json/i.test(req.headers["content-type"] || "")) return reply(415, { error: "envie JSON" });
59
63
  let body;
60
64
  try { body = await readJSON(req); } catch (e) { return reply(400, { error: e.message }); }
65
+ if (pathname === "/api/http/ambientes/form") {
66
+ if (fs.existsSync(apiEnv.file)) { fs.copyFileSync(apiEnv.file, `${apiEnv.file}.bak`); fs.chmodSync(`${apiEnv.file}.bak`, 0o600); }
67
+ try { return reply(200, apiEnv.saveForm(body.envs, { current: body.current })); } catch (e) { return reply(400, { error: e.message }); }
68
+ }
69
+ if (pathname === "/api/http/ambientes/testar") {
70
+ try { return reply(200, await apiEnv.check(String(body.name || ""))); } catch (e) { return reply(400, { error: e.message }); }
71
+ }
61
72
  if (pathname === "/api/http/ambientes" || pathname === "/api/http/ambientes/validar") {
62
73
  const text = String(body.text ?? "");
63
74
  let data;
@@ -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
+ }