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.
- package/README.md +65 -67
- package/SKILL.md +19 -11
- package/bin/sagadeck.js +14 -11
- package/docs/ANDAMENTO.md +117 -2
- package/docs/REFERENCIA.md +8 -5
- package/package.json +5 -6
- package/src/ai/deck-ai.js +1 -1
- package/src/ai/ia-config.js +107 -0
- package/src/ai/llm.js +62 -26
- package/src/api-client.js +106 -5
- 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/api-routes.js +11 -0
- package/src/studio/banner.js +18 -0
- package/src/studio/ia-routes.js +53 -0
- package/src/studio/open-browser.js +17 -0
- package/src/studio/public/ai-settings.js +86 -0
- package/src/studio/public/api-envs-form.js +148 -0
- package/src/studio/public/app.js +6 -24
- package/src/studio/public/index.html +6 -0
- package/src/studio/public/library.css +4 -0
- package/src/studio/public/library.html +3 -1
- package/src/studio/public/library.js +8 -4
- package/src/studio/public/preferences-ui.js +3 -0
- package/src/studio/public/style.css +23 -0
- package/src/studio/server.js +6 -18
- package/src/studio/slide-copy.js +3 -1
- package/src/ai/relay.js +0 -45
|
@@ -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
|
-
//
|
|
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/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 =
|
|
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
|
}
|
package/src/studio/api-routes.js
CHANGED
|
@@ -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
|
+
}
|