sagadeck 1.6.0 → 1.7.1

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.
@@ -1,4 +1,4 @@
1
- /* Alumínio (lançamento de iPhone e iPad, 2020-2021): prata metálico com brilho de luz de estúdio, alumínio escovado,
1
+ /* Alumínio (lançamento de aparelho premium): prata metálico com brilho de luz de estúdio, alumínio escovado,
2
2
  títulos cromados (a cor continua sólida em "color": o fiscal mede o contraste por ela), cartões de metal com
3
3
  chanfro de luz; capa com o título embaixo à esquerda e um disco de metal polido à direita; o escuro é grafite */
4
4
  /* o metal sai das cores do tom (--bg, --fg, --surface): com uma paleta por cima, ele acompanha */
@@ -1,4 +1,4 @@
1
- /* Prata (Keynote): nada de enfeite; muito respiro, tudo centralizado, título contido, número da seção discreto */
1
+ /* Prata: nada de enfeite; muito respiro, tudo centralizado, título contido, número da seção discreto */
2
2
  .th-prata .L-cover:not(:has(.cv-fig)) .cv-main,.th-prata .L-end:not(:has(.en-fig)) .en-main{align-items:center;text-align:center;gap:28px}
3
3
  .th-prata :is(.L-cover,.L-end) .ttl{max-width:1300px}
4
4
  .th-prata .kicker{color:var(--muted);letter-spacing:.02em}
@@ -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;
@@ -7,7 +7,7 @@ const providerName = (ai) => Object.values(PROVIDERS).find((p) => p.url === ai.u
7
7
 
8
8
  export function studioBanner({ version, url, library, ai = null, multiuser = false, usersDir = "", pid = process.pid, isLibrary = true }) {
9
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."
10
+ ? (ai?.problem ? ` IA: não configurada. ${ai.problem}` : " IA: não configurada. No Studio, clique em IA desligada (ou Configurar IA na biblioteca) e informe provedor e chave.")
11
11
  : ` IA: ${providerName(ai)} · texto ${ai.textModel}${ai.source === "variável" ? " (pelas variáveis SAGADECK_LLM_*)" : ""}`;
12
12
  return [
13
13
  `✓ SagaDeck Studio ${version} pronto. Abra no navegador: ${url}${isLibrary ? " (biblioteca)" : ""}`,
@@ -1,6 +1,6 @@
1
1
  // Tela "Configurar IA" do Studio: ler (a chave nunca sai inteira), salvar e testar a configuração da IA desta máquina
2
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";
3
+ import { PROVIDERS, ROLES, RECOMMENDED, iaFile, loadIA, saveIA, maskedIA, keyOf } from "../ai/ia-config.js";
4
4
  import { chat, llmConfig, llmAvailable } from "../ai/llm.js";
5
5
 
6
6
  const json = (res, status, data) => { res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify(data)); };
@@ -23,7 +23,10 @@ function draft(body, current) {
23
23
  export async function iaRoutes({ req, res, pathname, opts, readJSON }) {
24
24
  if (pathname === "/api/ia" && req.method === "GET") {
25
25
  const cfg = llmConfig();
26
- json(res, 200, { config: maskedIA(loadIA()), file: iaFile(), source: cfg.source, providers: PROVIDERS, roles: ROLES, editable: !opts.multiuser });
26
+ // no servidor, quem usa o portal não configura: só fica sabendo se a IA está configurada (provedor, endereço,
27
+ // modelos e o final da chave são de quem administra)
28
+ if (opts.multiuser) { json(res, 200, { editable: false, configured: !!(cfg.url || cfg.adaptador) }); return true; }
29
+ json(res, 200, { config: maskedIA(loadIA()), file: iaFile(), source: cfg.source, configured: !!(cfg.url || cfg.adaptador), problem: cfg.problem || "", providers: PROVIDERS, roles: ROLES, recommended: RECOMMENDED, editable: !opts.multiuser });
27
30
  return true;
28
31
  }
29
32
  if ((pathname === "/api/ia" || pathname === "/api/ia/test") && req.method === "POST") {
@@ -0,0 +1,17 @@
1
+ // `sagadeck studio` abre o navegador na página certa: quem acabou de instalar não precisa achar o endereço no meio do
2
+ // que o terminal escreveu. Só quando faz sentido: alguém no terminal (não um serviço, não um teste, não a CI), sem
3
+ // servidor multiusuário e sem --sem-navegador / SAGADECK_NO_BROWSER=1.
4
+ import { spawn } from "node:child_process";
5
+
6
+ export function shouldOpenBrowser({ flags = {}, env = process.env, isTTY = process.stdout.isTTY, multiuser = false, platform = process.platform } = {}) {
7
+ if (flags["sem-navegador"] || flags["no-browser"] || env.SAGADECK_NO_BROWSER === "1") return false;
8
+ if (multiuser || env.CI || !isTTY) return false;
9
+ if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false; // servidor sem tela
10
+ return true;
11
+ }
12
+
13
+ export function openBrowser(url, { platform = process.platform, spawnFn = spawn } = {}) {
14
+ const [cmd, args] = platform === "win32" ? ["cmd", ["/c", "start", "", url]] : platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
15
+ try { const child = spawnFn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true }); child.on?.("error", () => {}); child.unref?.(); return true; }
16
+ catch { return false; }
17
+ }
@@ -6,6 +6,7 @@ window.SagaAISettings = (() => {
6
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
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
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 .rec{background:var(--selected,#eef);border-radius:8px;padding:8px 10px;margin:6px 0 4px}.ai-cfg .rec button{margin-top:6px;display:block}
9
10
  .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
11
  const LABEL = { text: "Modelo de texto", vision: "Modelo que vê imagens", image: "Modelo de imagem", search: "Modelo de busca na web" };
11
12
  const HINT = { text: "obrigatório", vision: "opcional; vazio = o de texto", image: "opcional; para gerar ilustrações", search: "opcional; no OpenRouter, um modelo :online" };
@@ -20,6 +21,15 @@ window.SagaAISettings = (() => {
20
21
  document.body.append(dialog);
21
22
  }
22
23
 
24
+ // no servidor (multiusuário) a configuração é de quem administra e não vem para a página: só se está ligada
25
+ function serverNotice() {
26
+ dialog.innerHTML = `<h2>Configurar IA</h2>
27
+ <p>Neste servidor, quem configura a IA é quem administra.</p>
28
+ <p>${info.configured ? "A IA está configurada." : "A IA não está configurada. Avise quem administra o servidor."}</p>
29
+ <div class="row"><button type="button" data-close>Fechar</button></div>`;
30
+ dialog.querySelector("[data-close]").onclick = () => dialog.close();
31
+ }
32
+
23
33
  function form() {
24
34
  const c = info.config || {}, providers = info.providers || {};
25
35
  const provider = c.provider && (providers[c.provider] || c.provider === "outro") ? c.provider : (c.url ? "outro" : "openrouter");
@@ -27,13 +37,16 @@ window.SagaAISettings = (() => {
27
37
  <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
38
  ${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
39
  ${info.editable ? "" : '<p class="warn">Neste servidor, quem configura a IA é quem administra.</p>'}
40
+ ${info.problem ? `<p class="warn" data-problem>${esc(info.problem)}</p>` : ""}
41
+ ${info.recommended ? `<div class="rec"><b>Recomendado para começar:</b> ${esc(providers[info.recommended.provider]?.label || info.recommended.provider)}, com ${esc(info.recommended.models.text)} (texto e visão), ${esc(info.recommended.models.image)} (imagem) e ${esc(info.recommended.models.search)} (busca). Se a IA não responder nem com isso, o problema é a rede ou a chave, não o modelo. Depois, troque pelo modelo que quiser. <button type="button" data-rec>Usar a recomendação</button></div>` : ""}
30
42
  <label for="ai-cfg-provider">Provedor</label>
31
43
  <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
44
  <label for="ai-cfg-url">Endereço da API</label>
33
45
  <input id="ai-cfg-url" data-f="url" placeholder="https://…/v1" spellcheck="false">
34
46
  <label for="ai-cfg-key">Chave</label>
35
47
  <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>
48
+ <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])}" placeholder="${info.recommended?.models?.[r] ? `ex.: ${esc(info.recommended.models[r])}` : ""}" spellcheck="false"><small>${HINT[r] || ""}</small></div>`).join("")}</div>
49
+ <p><small>Texto e visão funcionam em qualquer provedor compatível com a API da OpenAI. Gerar imagem e buscar na web dependem do provedor: funcionam no OpenRouter com modelos de imagem e modelos <code>:online</code>; em outros, a pesquisa usa buscadores diretos e a imagem pode não sair.</small></p>
37
50
  <div data-status role="status"></div>
38
51
  <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
52
  const sel = dialog.querySelector('[data-f="provider"]'), url = dialog.querySelector('[data-f="url"]');
@@ -44,6 +57,13 @@ window.SagaAISettings = (() => {
44
57
  sel.onchange = sync;
45
58
  if (!info.editable) dialog.querySelectorAll("input,select,[data-save],[data-test]").forEach((el) => (el.disabled = true));
46
59
  dialog.querySelector("[data-close]").onclick = () => dialog.close();
60
+ const rec = dialog.querySelector("[data-rec]");
61
+ if (rec) rec.onclick = () => {
62
+ sel.value = info.recommended.provider; sync();
63
+ for (const [r, m] of Object.entries(info.recommended.models)) { const el = dialog.querySelector(`[data-model="${r}"]`); if (el) el.value = m; }
64
+ const st = dialog.querySelector("[data-status]"); st.className = ""; st.textContent = info.config?.keySet ? "Preenchido. Clique em Testar." : "Preenchido. Cole a chave do OpenRouter e clique em Testar.";
65
+ dialog.querySelector('[data-f="key"]').focus();
66
+ };
47
67
  dialog.querySelector("[data-test]").onclick = () => submit("api/ia/test");
48
68
  dialog.querySelector("[data-save]").onclick = () => submit("api/ia");
49
69
  }
@@ -69,7 +89,7 @@ window.SagaAISettings = (() => {
69
89
  onSaved = typeof saved === "function" ? saved : () => {};
70
90
  if (!dialog) build();
71
91
  info = await (await fetch("api/ia")).json();
72
- form();
92
+ if (info.editable) form(); else serverNotice();
73
93
  if (!dialog.open) dialog.showModal();
74
94
  },
75
95
  };
@@ -0,0 +1,148 @@
1
+ // Ambientes dos slides de API por campos: quem instala do zero preenche endereço, variáveis, segredos e autenticação
2
+ // sem escrever YAML (o arquivo é o mesmo, ~/.sagadeck/ambientes.yaml; a aba "Como texto" continua para quem prefere).
3
+ // Segredo digitado vai cifrado para o arquivo e nunca volta para a página. No servidor multiusuário (ou Studio aberto
4
+ // para a rede) os slides de API não executam: o botão Ambientes nem aparece.
5
+ window.SagaApiEnvsForm = (() => {
6
+ const $ = (id) => document.getElementById(id);
7
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
8
+ const svg = (n) => `<i class="ic" data-ic="${n}"><svg viewBox="0 0 24 24" aria-hidden="true">${(window.UI_ICONS || {})[n] || ""}</svg></i>`;
9
+ let st = null, tab = "form";
10
+ const status = (text, kind = "") => { const el = $("api-envs-status"); if (el) { el.textContent = text; el.className = `sf-hint api-envs-status ${kind}`; } };
11
+
12
+ // sem execução neste Studio (multiusuário, aberto para a rede): o botão some
13
+ fetch("api/http/state").then((r) => r.json()).then((s) => { if (s && s.live === false) $("btn-api-envs")?.setAttribute("hidden", ""); }).catch(() => {});
14
+
15
+ const row = (kind, item = {}) => kind === "var"
16
+ ? `<div class="aef-row" data-row="var"><input data-k="name" placeholder="nome (ex.: modelo)" value="${esc(item.name)}" spellcheck="false" aria-label="Nome da variável"><input data-k="value" placeholder="valor" value="${esc(item.value)}" spellcheck="false" aria-label="Valor da variável"><button type="button" class="icon-btn" data-del title="Tirar">${svg("x")}</button></div>`
17
+ : `<div class="aef-row" data-row="secret"><input data-k="name" placeholder="nome (ex.: senha)" value="${esc(item.name)}" spellcheck="false" aria-label="Nome do segredo"><input data-k="value" type="password" autocomplete="off" placeholder="${item.set ? "guardado; em branco mantém" : "valor (guardado cifrado)"}" aria-label="Valor do segredo"><input data-k="env" placeholder="ou variável de ambiente" value="${esc(item.env)}" spellcheck="false" aria-label="Variável de ambiente do segredo"><button type="button" class="icon-btn" data-del title="Tirar">${svg("x")}</button></div>`;
18
+
19
+ function card(e, current) {
20
+ const a = e.auth || { type: "nenhuma" };
21
+ return `<section class="aef-env" data-env>
22
+ <div class="aef-head"><input data-k="envname" value="${esc(e.name)}" placeholder="nome (dev, hom, prod)" spellcheck="false" aria-label="Nome do ambiente">
23
+ <label class="aef-cur"><input type="radio" name="aef-current" ${e.name === current ? "checked" : ""}> em uso</label>
24
+ <button type="button" class="btn btn-secondary btn-sm" data-test>Testar</button>
25
+ <button type="button" class="icon-btn" data-remove title="Tirar este ambiente">${svg("trash-2")}</button></div>
26
+ <label>Endereço base <small>vira <code>{{base}}</code> nos slides</small></label>
27
+ <input data-k="base" value="${esc(e.base)}" placeholder="https://api.exemplo.com/v1" spellcheck="false">
28
+ <label>Variáveis <small><code>{{nome}}</code> nos slides</small></label>
29
+ <div data-list="var">${(e.vars || []).map((v) => row("var", v)).join("")}</div>
30
+ <button type="button" class="btn-link" data-add="var">+ variável</button>
31
+ <label>Segredos <small><code>{{secret.nome}}</code> nos slides; nunca vão para o deck</small></label>
32
+ <div data-list="secret">${(e.secrets || []).map((v) => row("secret", v)).join("")}</div>
33
+ <button type="button" class="btn-link" data-add="secret">+ segredo</button>
34
+ <label>Autenticação</label>
35
+ <select data-k="auth"><option value="nenhuma">Nenhuma</option><option value="bearer">Token fixo (Authorization: Bearer)</option><option value="token">Token que expira (client credentials)</option></select>
36
+ <div class="aef-auth" data-auth="bearer"><input data-k="bearer" type="password" autocomplete="off" placeholder="${a.type === "bearer" && a.secretSet ? "token guardado; em branco mantém" : "o token"}" aria-label="Token fixo"><input data-k="bearerEnv" placeholder="ou variável de ambiente" value="${esc(a.type === "bearer" ? a.secretEnv : "")}" spellcheck="false" aria-label="Variável do token"></div>
37
+ <div class="aef-auth" data-auth="token"><input data-k="tokenUrl" placeholder="endereço do token (https://…/token)" value="${esc(a.url)}" spellcheck="false" aria-label="Endereço do token"><input data-k="clientId" placeholder="client id" value="${esc(a.client_id)}" spellcheck="false" aria-label="Client id"><input data-k="clientSecret" type="password" autocomplete="off" placeholder="${a.type === "token" && a.secretSet ? "client secret guardado; em branco mantém" : "client secret"}" aria-label="Client secret"><input data-k="clientSecretEnv" placeholder="ou variável de ambiente do secret" value="${esc(a.type === "token" ? a.secretEnv : "")}" spellcheck="false" aria-label="Variável do client secret"><input data-k="field" placeholder="campo do token na resposta (access_token)" value="${esc(a.field)}" spellcheck="false" aria-label="Campo do token"></div>
38
+ ${e.advanced ? '<p class="muted aef-note">Este ambiente tem configurações avançadas (certificado, outros cabeçalhos); elas ficam como estão. Veja na aba Como texto.</p>' : ""}
39
+ <div class="aef-result" data-result></div>
40
+ </section>`;
41
+ }
42
+
43
+ function wire(root) {
44
+ root.querySelectorAll("[data-env]").forEach((sec) => {
45
+ const sel = sec.querySelector('[data-k="auth"]');
46
+ const show = () => sec.querySelectorAll("[data-auth]").forEach((d) => { d.hidden = d.dataset.auth !== sel.value; });
47
+ sel.onchange = show;
48
+ sec.querySelector("[data-remove]").onclick = () => { sec.remove(); empty(); };
49
+ sec.querySelector("[data-test]").onclick = () => test(sec);
50
+ sec.querySelectorAll("[data-add]").forEach((b) => (b.onclick = () => { sec.querySelector(`[data-list="${b.dataset.add}"]`).insertAdjacentHTML("beforeend", row(b.dataset.add)); wireRows(sec); }));
51
+ wireRows(sec);
52
+ show();
53
+ });
54
+ }
55
+ const wireRows = (sec) => sec.querySelectorAll("[data-del]").forEach((b) => (b.onclick = () => b.closest(".aef-row").remove()));
56
+ function empty() {
57
+ const intro = $("api-envs-form").querySelector("[data-intro]");
58
+ if (intro) intro.hidden = !!$("api-envs-form").querySelector("[data-env]");
59
+ }
60
+
61
+ function render() {
62
+ const box = $("api-envs-form");
63
+ box.innerHTML = `<div class="aef-intro" data-intro><p><b>Você ainda não tem ambientes seus.</b> O <b>ENSAIO</b> (uma API de mentira que vem com o sagadeck) já funciona nos slides de API, sem configurar nada.</p><p>Para ligar a sua API, adicione um ambiente: o endereço base, as variáveis, os segredos e como ela autentica. Fica gravado nesta máquina, em <code>${esc(st.file)}</code>.</p></div>
64
+ <div data-envs>${st.envs.map((e) => card(e, st.current)).join("")}</div>
65
+ <button type="button" class="btn btn-secondary" data-new>${svg("plus")} Adicionar ambiente</button>`;
66
+ box.querySelectorAll("[data-env]").forEach((sec, i) => { sec.querySelector('[data-k="auth"]').value = st.envs[i].auth?.type || "nenhuma"; });
67
+ box.querySelector("[data-new]").onclick = () => {
68
+ const names = new Set([...box.querySelectorAll('[data-k="envname"]')].map((i) => i.value));
69
+ const name = ["dev", "hom", "prod"].find((n) => !names.has(n)) || `ambiente${names.size + 1}`;
70
+ box.querySelector("[data-envs]").insertAdjacentHTML("beforeend", card({ name, base: "", vars: [], secrets: [], auth: { type: "nenhuma" } }, names.size ? st.current : name));
71
+ wire(box.querySelector("[data-envs]").lastElementChild.parentElement);
72
+ empty();
73
+ box.querySelector("[data-envs]").lastElementChild.querySelector('[data-k="base"]').focus();
74
+ };
75
+ wire(box);
76
+ empty();
77
+ }
78
+
79
+ function collect() {
80
+ const envs = [], box = $("api-envs-form");
81
+ let current = null;
82
+ for (const sec of box.querySelectorAll("[data-env]")) {
83
+ const v = (k) => sec.querySelector(`[data-k="${k}"]`)?.value.trim() || "";
84
+ const name = v("envname");
85
+ if (sec.querySelector('input[name="aef-current"]').checked) current = name;
86
+ const rows = (kind) => [...sec.querySelectorAll(`[data-row="${kind}"]`)].map((r) => Object.fromEntries([...r.querySelectorAll("[data-k]")].map((i) => [i.dataset.k, i.value.trim()]))).filter((r) => r.name);
87
+ const type = v("auth");
88
+ const auth = type === "bearer" ? { type, value: v("bearer"), secretEnv: v("bearerEnv") }
89
+ : type === "token" ? { type, url: v("tokenUrl"), client_id: v("clientId"), value: v("clientSecret"), secretEnv: v("clientSecretEnv"), field: v("field") }
90
+ : { type: "nenhuma" };
91
+ envs.push({ name, base: v("base"), vars: rows("var"), secrets: rows("secret"), auth });
92
+ }
93
+ return { envs, current };
94
+ }
95
+
96
+ async function save() {
97
+ status("Salvando…");
98
+ const r = await fetch("api/http/ambientes/form", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect()) });
99
+ const j = await r.json().catch(() => ({}));
100
+ if (!r.ok) { status(`Não salvou: ${j.error || r.status}`, "err"); return false; }
101
+ st = j;
102
+ render();
103
+ status(`Salvo em ${st.file}. Os slides de API já usam os ambientes novos.`, "ok");
104
+ return true;
105
+ }
106
+
107
+ async function test(sec) {
108
+ const out = sec.querySelector("[data-result]");
109
+ out.className = "aef-result"; out.textContent = "Salvando e testando…";
110
+ if (!(await save())) { out.textContent = ""; return; }
111
+ const name = sec.querySelector('[data-k="envname"]')?.value.trim();
112
+ const again = [...$("api-envs-form").querySelectorAll("[data-env]")].find((s) => s.querySelector('[data-k="envname"]').value === name);
113
+ const res = again?.querySelector("[data-result]") || out;
114
+ try {
115
+ const j = await (await fetch("api/http/ambientes/testar", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) })).json();
116
+ if (j.error) throw new Error(j.error);
117
+ const parts = [j.base ? (j.base.ok ? `endereço respondeu (HTTP ${j.base.status})` : `endereço não respondeu: ${j.base.error}`) : "sem endereço base",
118
+ ...(j.token ? [j.token.ok ? `token obtido (…${j.token.last4})` : `token falhou: ${j.token.error}`] : [])];
119
+ const ok = (!j.base || j.base.ok) && (!j.token || j.token.ok);
120
+ res.className = `aef-result ${ok ? "ok" : "err"}`;
121
+ res.textContent = (ok ? "Funcionou: " : "Problema: ") + parts.join("; ") + ".";
122
+ } catch (e) { res.className = "aef-result err"; res.textContent = `Não testou: ${e.message}`; }
123
+ }
124
+
125
+ function setTab(t) {
126
+ tab = t;
127
+ document.querySelectorAll("[data-envs-tab]").forEach((b) => b.classList.toggle("active", b.dataset.envsTab === t));
128
+ $("api-envs-form").hidden = t !== "form";
129
+ $("api-envs-textmode").hidden = t !== "text";
130
+ $("btn-api-envs-save").disabled = false;
131
+ status("");
132
+ if (t === "text") $("api-envs-text").dispatchEvent(new Event("input")); // a aba de texto valida antes de liberar o Salvar
133
+ }
134
+
135
+ async function open() {
136
+ document.querySelectorAll("[data-envs-tab]").forEach((b) => (b.onclick = async () => { if (b.dataset.envsTab === "text") { const j = await (await fetch("api/http/ambientes")).json().catch(() => ({})); if (j.text != null) $("api-envs-text").value = j.text; } else await load(); setTab(b.dataset.envsTab); }));
137
+ await load();
138
+ setTab("form");
139
+ }
140
+ async function load() {
141
+ const r = await fetch("api/http/ambientes/form");
142
+ st = await r.json().catch(() => ({ envs: [] }));
143
+ if (!r.ok) { status(st.error || `HTTP ${r.status}`, "err"); st = { envs: [], file: "" }; }
144
+ render();
145
+ }
146
+
147
+ return { open, save, active: () => tab === "form" };
148
+ })();
@@ -3044,9 +3044,11 @@
3044
3044
  }
3045
3045
  const on = !!state.ai.available;
3046
3046
  dom.aiStatus.textContent = on ? "IA ligada" : "IA desligada";
3047
- dom.aiStatus.title = on
3048
- ? `LLM em ${state.ai.url} · texto: ${state.ai.textModel} · imagem: ${state.ai.imageModel}`
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.";
3047
+ dom.aiStatus.title = state.ai.server // no servidor, sem os detalhes (são de quem administra)
3048
+ ? (on ? "A IA deste servidor está ligada." : state.ai.configured ? "A IA deste servidor não está respondendo. Avise quem administra." : "A IA deste servidor não está configurada. Avise quem administra.")
3049
+ : on ? `LLM em ${state.ai.url} · texto: ${state.ai.textModel} · imagem: ${state.ai.imageModel}`
3050
+ : state.ai.configured ? `Nenhum LLM respondendo em ${state.ai.url}. Clique para conferir o provedor e a chave.`
3051
+ : state.ai.problem ? `A IA não está configurada. ${state.ai.problem}` : "A IA não está configurada. Clique para informar provedor e chave.";
3050
3052
  dom.aiStatus.classList.toggle("on", on);
3051
3053
  dom.aiStatus.classList.toggle("off", !on);
3052
3054
  }
@@ -3154,7 +3156,7 @@
3154
3156
  }
3155
3157
  dom.btnRunAiDeck.disabled = true;
3156
3158
  const started = Date.now();
3157
- let phase = `Enviando para ${state.ai.textModel || "o LLM"}…`;
3159
+ let phase = `Enviando para ${state.ai.textModel || "a IA"}…`;
3158
3160
  const show = () => {
3159
3161
  dom.aiDeckStatus.textContent = `${phase} · ${Math.round((Date.now() - started) / 1000)}s`;
3160
3162
  };
@@ -3430,6 +3432,7 @@ ${ta.value}`;
3430
3432
  renderApiEnvChips(st);
3431
3433
  if (!j.exists) apiEnvsStatus("Este arquivo ainda não existe. Validando o modelo antes de habilitar Salvar…");
3432
3434
  void validateApiEnvs();
3435
+ await window.SagaApiEnvsForm.open(); // por campos é o padrão (api-envs-form.js); o texto fica na outra aba
3433
3436
  }
3434
3437
  function closeApiEnvs() { apiEnvsModal().classList.add("hidden"); }
3435
3438
  async function saveApiEnvs() {
@@ -3915,7 +3918,7 @@ ${ta.value}`;
3915
3918
  document.getElementById("btn-api-envs").addEventListener("click", openApiEnvs);
3916
3919
  document.getElementById("btn-close-api-envs").addEventListener("click", closeApiEnvs);
3917
3920
  document.getElementById("btn-api-envs-cancel").addEventListener("click", closeApiEnvs);
3918
- document.getElementById("btn-api-envs-save").addEventListener("click", saveApiEnvs);
3921
+ document.getElementById("btn-api-envs-save").addEventListener("click", async () => { if (!window.SagaApiEnvsForm.active()) return saveApiEnvs(); if (await window.SagaApiEnvsForm.save()) await refreshStudioVars(); });
3919
3922
  document.getElementById("btn-api-vars-studio").addEventListener("click", () => openPane("vars"));
3920
3923
  document.getElementById("api-envs-text").addEventListener("input", scheduleApiEnvValidation);
3921
3924
  // grade de variáveis: grava ao sair da célula (change) ou no Enter; Esc desfaz; a linha em branco do fim cria
@@ -127,7 +127,7 @@ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, open
127
127
  await fetch("api/ai/chat/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requestId: job.id }) }).catch(() => {});
128
128
  };
129
129
  const work = createProgressBubble(state.ai.available
130
- ? `Enviando para o LLM (${state.ai.textModel})…`
130
+ ? (state.ai.textModel ? `Enviando para o LLM (${state.ai.textModel})…` : "Enviando para a IA…")
131
131
  : "Verificando se a IA está no ar…");
132
132
  dom.chatSend.disabled = false;
133
133
  dom.chatSend.title = 'Parar processamento'; dom.chatSend.setAttribute('aria-label','Parar');
@@ -748,11 +748,15 @@
748
748
  <div class="modal-dialog-body api-envs-body">
749
749
  <p class="muted api-envs-intro">Endereços, tokens e segredos ficam na sua máquina, nunca no deck (o deck pode ir para o GitHub).
750
750
  Nos slides, <code>{{base}}</code> vira o <code>vars.base</code> do ambiente em uso; segredos entram como <code>{{secret.nome}}</code>.</p>
751
+ <div class="api-envs-tabs" role="tablist"><button type="button" role="tab" data-envs-tab="form" class="active">Por campos</button><button type="button" role="tab" data-envs-tab="text">Como texto (avançado)</button></div>
752
+ <div id="api-envs-form" class="api-envs-form"></div>
753
+ <div id="api-envs-textmode" hidden>
751
754
  <p class="muted api-envs-safety">O YAML é validado antes de salvar. Uma cópia da versão anterior fica ao lado do arquivo como <code>.bak</code>.</p>
752
755
  <div class="napkin-label">Ambiente em uso <span class="muted">— o selo no slide também troca</span></div>
753
756
  <div id="api-envs-list" class="api-envs-list"></div>
754
757
  <div class="napkin-label">Arquivo <code id="api-envs-file" class="muted"></code></div>
755
758
  <textarea id="api-envs-text" class="form-control mono" rows="20" spellcheck="false" aria-label="Conteúdo do ambientes.yaml"></textarea>
759
+ </div>
756
760
  <div id="api-envs-status" class="sf-hint api-envs-status"></div>
757
761
  </div>
758
762
  <div class="modal-dialog-footer">
@@ -843,6 +847,7 @@
843
847
  <script src="ui-icons.js"></script>
844
848
  <script src="update-notice.js"></script>
845
849
  <script src="ai-settings.js"></script>
850
+ <script src="api-envs-form.js"></script>
846
851
  <script src="calc-fields.js"></script>
847
852
  <script src="art-preview.js"></script>
848
853
  <script src="api-collections.js"></script>
@@ -28,6 +28,11 @@ body.lib { display: grid; grid-template-columns: 218px minmax(0, 1fr); grid-temp
28
28
  .lib-search { display: flex; align-items: center; gap: 9px; width: 228px; height: 37px; color: var(--text-3); padding: 0 11px; background: transparent; border: 1px solid var(--ui-line); border-radius: 8px; }
29
29
  .lib-search input { background: none; color: var(--text); border: 0; outline: 0; width: 100%; min-width: 0; font-size: 11px; }
30
30
  .lib-search kbd { border: 1px solid var(--ui-line); border-radius: 3px; font-size: 10px; padding: 0 4px; }
31
+ .ai-banner { display: flex; align-items: center; gap: 12px; margin: 10px 24px 0; padding: 10px 14px; border-radius: 10px; background: #fef3c7; color: #78350f; border: 1px solid #f59e0b; font-size: 13px; }
32
+ .ai-banner[hidden] { display: none; }
33
+ .ai-banner span { flex: 1; min-width: 0; overflow-wrap: anywhere; } /* o motivo pode trazer um caminho longo */
34
+ .ai-banner button { flex: none; }
35
+ .ai-banner .ic { width: 18px; height: 18px; flex: none; }
31
36
  .lib-btn { display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 38px; padding: 0 15px; border-radius: 8px; border: 1px solid transparent; font-size: 12px; font-weight: 600; text-decoration: none; transition: background .15s, transform .15s; }
32
37
  .lib-btn.primary { background: var(--brand-fill); color: var(--on-brand); box-shadow: 0 3px 8px #634ce916; }
33
38
  .lib-btn.primary:hover { background: var(--brand-fill-hover); transform: translateY(-1px); }
@@ -28,6 +28,7 @@
28
28
  <button class="lib-icon-btn" id="btn-theme" title="Alternar tema claro e escuro" aria-label="Alternar tema claro e escuro"><i class="ic" data-ic="sun-moon"></i></button>
29
29
  <div class="lib-user" id="user" hidden><div class="lib-avatar" id="avatar"></div><span class="uname" id="uname"></span></div>
30
30
  </header>
31
+ <div class="ai-banner" id="ai-banner" role="status" hidden><i class="ic" data-ic="sparkles"></i><span>A IA ainda não está configurada. Configure para gerar apresentações e conversar no chat (o resto já funciona).</span><button type="button" class="lib-btn primary" id="ai-banner-btn">Configurar IA</button></div>
31
32
  <div class="lib-body">
32
33
  <aside class="lib-side">
33
34
  <nav id="side" aria-label="Biblioteca"></nav>
@@ -59,8 +59,15 @@
59
59
  render();
60
60
  }
61
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();
62
+ // e, enquanto a IA não estiver configurada, a faixa "Configure a IA" (some ao salvar)
63
+ const aiSaved = () => { $("#ai-banner").hidden = true; };
64
+ api("api/ia").then(({ editable, configured, problem }) => {
65
+ $("#btn-ai").hidden = !editable; $("#ai-banner").hidden = !editable || configured;
66
+ if (problem) $("#ai-banner span").textContent = `A IA não está configurada. ${problem}`; // o arquivo existe mas não serve
67
+ hydrate($("#ai-banner"));
68
+ }).catch(() => {});
69
+ $("#btn-ai").onclick = () => window.SagaAISettings.open(aiSaved);
70
+ $("#ai-banner-btn").onclick = () => window.SagaAISettings.open(aiSaved);
64
71
  const openEditor = (id, present) => { location.href = `editor?deck=${encodeURIComponent(id)}${present ? "&present=1" : ""}`; };
65
72
 
66
73
  // ---------------------------------------------------------------- barra lateral
@@ -812,6 +812,29 @@ textarea.form-control.napkin-input { min-height: 220px; resize: vertical; font-f
812
812
  .bs-option:disabled { opacity: .5; }
813
813
  .ai-msg.bs .ai-avatar { background: #8b5cf6; }
814
814
  .bs-tag { font-size: 11px; color: var(--text-3); margin-bottom: 4px; }
815
+ /* Ambientes por campos (api-envs-form.js) */
816
+ .api-envs-tabs { display: flex; gap: 6px; margin: 4px 0 10px; }
817
+ .api-envs-tabs button { border: 1px solid var(--border, rgba(127,127,127,.3)); background: transparent; color: inherit; border-radius: 999px; padding: 4px 12px; cursor: pointer; font-size: 12px; }
818
+ .api-envs-tabs button.active { background: var(--selected); border-color: var(--brand); color: var(--brand); }
819
+ .api-envs-form { display: flex; flex-direction: column; gap: 10px; }
820
+ .aef-intro p { margin: 0 0 6px; }
821
+ .aef-env { border: 1px solid var(--border, rgba(127,127,127,.3)); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; }
822
+ .aef-env label { font-size: 12px; font-weight: 600; margin-top: 6px; }
823
+ .aef-env label small { font-weight: 400; color: var(--text-3); }
824
+ .aef-env input, .aef-env select { width: 100%; box-sizing: border-box; padding: 5px 8px; border: 1px solid var(--border, rgba(127,127,127,.3)); border-radius: 6px; background: var(--bg, transparent); color: inherit; font: inherit; font-size: 13px; }
825
+ .aef-head { display: flex; gap: 8px; align-items: center; }
826
+ .aef-head input[data-k="envname"] { font-weight: 600; max-width: 180px; }
827
+ .aef-head .aef-cur { display: flex; align-items: center; gap: 4px; margin: 0; font-weight: 400; white-space: nowrap; }
828
+ .aef-head .aef-cur input { width: auto; }
829
+ .aef-head [data-test] { margin-left: auto; }
830
+ .aef-row, .aef-auth { display: flex; gap: 6px; align-items: center; }
831
+ .aef-auth { flex-wrap: wrap; margin-top: 4px; }
832
+ .aef-auth input { flex: 1 1 180px; }
833
+ .aef-env .btn-link { align-self: flex-start; background: none; border: 0; color: var(--brand); cursor: pointer; padding: 2px 0; font-size: 12px; }
834
+ .aef-note { font-size: 12px; margin: 6px 0 0; }
835
+ .aef-result { font-size: 12px; min-height: 1em; }
836
+ .aef-result.ok { color: #15803d; } .aef-result.err { color: var(--danger, #b91c1c); }
837
+
815
838
  /* leitura crítica: um cartão por ponto, ligado ao slide, com Aplicar e Ignorar */
816
839
  .crit-list { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
817
840
  .crit-item { border: 1px solid var(--border, rgba(127,127,127,.3)); border-radius: 10px; padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; }
@@ -900,7 +900,9 @@ export function createStudioServer(deckPath = null, opts = {}) {
900
900
  const cfg = llmConfig();
901
901
  const available = await llmAvailable({ force: url.searchParams.has("refresh") });
902
902
  res.writeHead(200, { "Content-Type": "application/json" });
903
- res.end(JSON.stringify({ available, configured: llmConfigured(cfg), source: cfg.source, url: cfg.url, textModel: cfg.textModel, imageModel: cfg.imageModel }));
903
+ // no servidor, só se está ligada: provedor e modelos são de quem administra (src/studio/ia-routes.js)
904
+ if (opts.multiuser) { res.end(JSON.stringify({ available, configured: llmConfigured(cfg), server: true })); return; }
905
+ res.end(JSON.stringify({ available, configured: llmConfigured(cfg), problem: cfg.problem || "", source: cfg.source, url: cfg.url, textModel: cfg.textModel, imageModel: cfg.imageModel }));
904
906
  return;
905
907
  }
906
908
 
@@ -6,7 +6,7 @@ export function generateScaffold({
6
6
  title = "Minha Apresentação",
7
7
  theme = "prata",
8
8
  author = "Seu Nome",
9
- type = "keynote",
9
+ type = "lancamento",
10
10
  } = {}) {
11
11
  const normType = String(type).toLowerCase();
12
12
 
@@ -243,7 +243,7 @@ export function generateScaffold({
243
243
  };
244
244
  }
245
245
 
246
- // Padrão: Keynote Clean / Apple style
246
+ // Padrão: lançamento, limpo e com muito respiro (tema prata)
247
247
  return {
248
248
  title,
249
249
  author,
@@ -252,7 +252,7 @@ export function generateScaffold({
252
252
  slides: [
253
253
  {
254
254
  layout: "cover",
255
- kicker: "Keynote de Lançamento",
255
+ kicker: "Lançamento",
256
256
  title: "Design com ==Propósito==.",
257
257
  subtitle: "A harmonia entre simplicidade, respiro e precisão",
258
258
  author,
@@ -355,14 +355,14 @@ export function generateScaffold({
355
355
  text: "O padrão definitivo para criadores exigentes.",
356
356
  rating: 5,
357
357
  ratingLabel: "5.0 Pro",
358
- tags: ["Keynote", "Prata"],
358
+ tags: ["Lançamento", "Prata"],
359
359
  },
360
360
  ],
361
361
  notes: "> O tom escuro cria um momento teatral no palco.",
362
362
  },
363
363
  {
364
364
  layout: "end",
365
- title: "Pense Diferente.",
365
+ title: "Simples assim.",
366
366
  subtitle: "Obrigado.",
367
367
  contacts: ["apresentacao@empresa.com", "suporte@empresa.com"],
368
368
  notes: "> Conclusão elegante e contato.",
package/src/themes.js CHANGED
@@ -270,7 +270,7 @@ export const THEMES = {
270
270
  },
271
271
 
272
272
  prata: {
273
- label: "Prata — Keynote clean & espacial: prata acetinado, muito respiro, tipografia SF / Inter limpa e elegante",
273
+ label: "Prata — limpo e espacial: prata acetinado, muito respiro, tipografia do sistema / Inter limpa e elegante",
274
274
  colors: {
275
275
  paper: "F5F5F7", ink: "1D1D1F", accent: "0071E3", alert: "FF3B30",
276
276
  muted: "86868B", line: "D2D2D7", surface: "FFFFFF", surfaceDark: "161617",
@@ -293,10 +293,10 @@ export const THEMES = {
293
293
  },
294
294
  radius: 20, deco: "silver",
295
295
  },
296
- // Alumínio: a cara dos lançamentos de iPhone e iPad de 2020-2021 (prata metálico, alumínio escovado, títulos
297
- // cromados, grafite no escuro). O prata segue fiel ao branco da Apple de hoje.
296
+ // Alumínio: a cara de lançamento de aparelho premium (prata metálico, alumínio escovado, títulos
297
+ // cromados, grafite no escuro). O prata segue o branco limpo.
298
298
  aluminio: {
299
- label: "Alumínio — lançamento de iPhone e iPad (2020): prata metálico, títulos cromados, alumínio escovado e grafite",
299
+ label: "Alumínio — lançamento de aparelho premium: prata metálico, títulos cromados, alumínio escovado e grafite",
300
300
  colors: {
301
301
  paper: "E4E5E9", ink: "1C1C1E", accent: "0066CC", alert: "D70015",
302
302
  muted: "5E5E63", line: "C3C5CA", surface: "F4F5F7", surfaceDark: "2C2C2E",
@@ -305,7 +305,7 @@ export const THEMES = {
305
305
  tones: {
306
306
  light: { bg: "paper", fg: "ink", muted: "muted", line: "line", surface: "surface", hi: "D1D3D8", em: "accent", onHi: "ink" },
307
307
  dark: { bg: "1C1C1E", fg: "F5F5F7", muted: "A1A1A6", line: "3A3A3C", surface: "surfaceDark", hi: "48484A", em: "2997FF", onHi: "FFFFFF" },
308
- // azul-pacífico do iPhone 12 Pro, metálico (a pele põe o brilho)
308
+ // azul-pacífico metálico (a pele põe o brilho)
309
309
  accent: { bg: "1D3A50", fg: "FFFFFF", muted: "C5D6E3", line: "3F6580", surface: "2A4C64", hi: "FFFFFF", em: "8FC1E3", onHi: "1D3A50" },
310
310
  alert: { bg: "alert", fg: "FFFFFF", muted: "FFD1D4", line: "F0525F", surface: "B30012", hi: "FFFFFF", em: "FFFFFF", onHi: "alert" },
311
311
  },
@@ -1,10 +1,10 @@
1
1
  title: Inovação & Design
2
- author: Keynote Clean
2
+ author: Estúdio Prata
3
3
  theme: prata
4
4
  duration: 12
5
5
  slides:
6
6
  - layout: cover
7
- kicker: Apresentação Especial · Keynote
7
+ kicker: Apresentação Especial · Lançamento
8
8
  title: Simplicidade com ==Impacto==.
9
9
  subtitle: A estética do respiro, elegância e precisão visual
10
10
  author: Design Team
@@ -79,12 +79,12 @@ slides:
79
79
  title: "Padrão de Mercado"
80
80
  text: "A referência definitiva para profissionais sérios."
81
81
  rating: 5
82
- ratingLabel: "5.0 Keynote"
83
- tags: ["Keynote", "Prata", "Apple Clean"]
82
+ ratingLabel: "5.0 Pro"
83
+ tags: ["Lançamento", "Prata", "Clean"]
84
84
 
85
85
  - layout: end
86
- title: Pense Diferente.
86
+ title: Simples assim.
87
87
  subtitle: Obrigado.
88
88
  contacts:
89
- - apple.com/keynote
89
+ - design.org
90
90
  - contato@design.org