sagadeck 1.6.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 CHANGED
@@ -24,11 +24,14 @@ Requer **Node.js 18+** e **Chrome ou Edge** instalados (o PDF, o PNG e as confer
24
24
  ```bash
25
25
  sagadeck studio
26
26
  ```
27
- e abra **http://127.0.0.1:3517** no navegador. **Essa é a página do sagadeck**: a biblioteca das suas
28
- apresentações, o editor (estilo PowerPoint) e o chat com a IA. Não há outra interface para abrir nem para criar.
29
-
30
- Para usar a IA (gerar apresentações, conversar com o chat), clique em **Configurar IA** no alto da biblioteca (ou
31
- em **IA desligada**, no editor), escolha o provedor, cole a chave, clique em **Testar** e **Salvar**. Detalhes em
27
+ O navegador abre sozinho em **http://127.0.0.1:3517** (se não abrir, abra esse endereço). **Essa é a página do
28
+ sagadeck**: a biblioteca das suas apresentações, o editor (estilo PowerPoint) e o chat com a IA. Não há outra interface
29
+ para abrir nem para criar. Deixe o terminal aberto enquanto usa; `--sem-navegador` não abre o navegador.
30
+
31
+ Para usar a IA (gerar apresentações, conversar com o chat), clique em **Configurar IA** (a biblioteca mostra uma faixa
32
+ enquanto ela não está configurada). **Usar a recomendação** preenche o OpenRouter com modelos que funcionam; cole a
33
+ chave, clique em **Testar** e **Salvar**. Se nem a recomendação responder, o problema é a rede ou a chave. Depois,
34
+ troque pelo provedor e pelos modelos que quiser. Detalhes em
32
35
  [IA de verdade](#ia-de-verdade-llm).
33
36
 
34
37
  ## Como usar
@@ -252,8 +255,11 @@ Para ver funcionando sem configurar nada:
252
255
  O painel **Formatar** edita o endereço, o corpo em JSON, o modo (síncrono, polling, streaming, tempo real)
253
256
  e o que guardar para os próximos slides.
254
257
  - **Inserir → Ambientes:** onde ficam os endereços (`{{base}}`), tokens e segredos do *seu* serviço
255
- (dev, hom, prod). É gravado em `~/.sagadeck/ambientes.yaml`, na sua máquina, nunca no deck. O selo no
256
- slide (DEV, HOM, ENSAIO…) troca o ambiente na hora.
258
+ (dev, hom, prod). Preenche-se por campos: endereço base, variáveis, segredos (guardados cifrados) e a
259
+ autenticação (nenhuma, token fixo ou token que expira), com **Testar**; quem prefere edita o YAML na aba
260
+ "Como texto". É gravado em `~/.sagadeck/ambientes.yaml`, na sua máquina, nunca no deck. Sem ambiente seu, o
261
+ **ENSAIO** (API de mentira) já funciona. O selo no slide (DEV, HOM, ENSAIO…) troca o ambiente na hora. No
262
+ servidor multiusuário o botão não aparece (lá os slides de API não executam).
257
263
 
258
264
  Executar só funciona no Studio local; no HTML exportado e no modo multiusuário, o slide mostra a última
259
265
  resposta gravada. Todos os campos estão na [referência](docs/REFERENCIA.md#slide-api-requisição-ao-vivo).
package/SKILL.md CHANGED
@@ -12,12 +12,13 @@ O sagadeck gera apresentações profissionais a partir de um arquivo YAML, ofere
12
12
  Quando a pessoa pede para instalar o sagadeck e deixar pronto:
13
13
 
14
14
  1. `npm install -g sagadeck` (Node 18+; não precisa de Python).
15
- 2. `sagadeck studio` e diga para ela abrir **http://127.0.0.1:3517** (a primeira linha que o comando escreve é
16
- esse endereço). **O Studio é a interface do sagadeck**: biblioteca, editor e chat com IA.
15
+ 2. `sagadeck studio`: o navegador abre sozinho em **http://127.0.0.1:3517** (é também a primeira linha que o
16
+ comando escreve; `--sem-navegador` não abre). **O Studio é a interface do sagadeck**: biblioteca, editor e chat com
17
+ IA. O terminal fica aberto enquanto ela usa.
17
18
  3. **Nunca crie uma página, um servidor ou uma interface própria** para o sagadeck, nem "suba as APIs" à mão: tudo
18
19
  que a pessoa precisa está no Studio.
19
20
  4. Para a IA, mostre onde configurar: **Configurar IA** no alto da biblioteca (ou **IA desligada** no editor):
20
- provedor, chave, Testar e Salvar. A configuração fica em `~/.sagadeck/ia.json`; nunca grave a chave no código nem
21
+ **Usar a recomendação** (OpenRouter com modelos que funcionam), a chave, Testar e Salvar. A configuração fica em `~/.sagadeck/ia.json`; nunca grave a chave no código nem
21
22
  na pasta das apresentações.
22
23
  5. As apresentações moram na biblioteca (`SAGADECK_HOME`, senão Documentos\sagadeck no Windows e `~/sagadeck` fora
23
24
  dele). Não crie decks em outra pasta.
package/bin/sagadeck.js CHANGED
@@ -40,6 +40,12 @@ for (let i = 0; i < rawRest.length; i++) {
40
40
 
41
41
  const HELP = `sagadeck — YAML -> apresentação (HTML animado + PowerPoint editável + PDF + roteiro)
42
42
 
43
+ Para começar: sagadeck studio
44
+ abre o Studio no navegador (biblioteca, editor e chat com IA; http://127.0.0.1:3517).
45
+ Deixe o terminal aberto enquanto usa. A IA se configura lá, em Configurar IA.
46
+
47
+ Todos os comandos:
48
+
43
49
  sagadeck new <nome> --prompt "briefing" [--slides=10] [--duration=20] [--theme=x] deck inteiro escrito pelo LLM
44
50
  (pesquisa na web quando o pedido precisa de informação recente ou de um artigo; --sem-pesquisa desliga)
45
51
  (--duration dispensa --slides: ~1 slide a cada 1,5 min)
@@ -56,7 +62,7 @@ const HELP = `sagadeck — YAML -> apresentação (HTML animado + PowerPoint edi
56
62
  sagadeck roteiro <deck.yaml> gera <deck> - roteiro.pdf (miniaturas + notas + tempos)
57
63
  sagadeck estudo <deck.yaml> [--html] gera <deck> - material de estudo.pdf (cada slide + texto de consulta)
58
64
  sagadeck all <deck.yaml> build + check + pptx + pdf + roteiro
59
- sagadeck studio [deck.yaml|x.sagadeck] [--port=3517] [--library=PASTA] sem arquivo: abre a biblioteca; já aberto: usa o aberto
65
+ sagadeck studio [deck.yaml|x.sagadeck] [--port=3517] [--library=PASTA] [--sem-navegador] sem arquivo: abre a biblioteca (no navegador); já aberto: usa o aberto
60
66
  (PASTA padrão: SAGADECK_HOME, senão Documentossagadeck no Windows, ~/sagadeck fora dele); com arquivo: abre o editor dele
61
67
  --host=0.0.0.0 abre para a rede (padrão: só esta máquina)
62
68
  --multiuser: uma biblioteca por usuário, atrás de um proxy que envia X-Sagadeck-User
@@ -387,6 +393,7 @@ async function main() {
387
393
  else { console.error(`O Studio já está aberto em http://${shownHost}:${port} com a biblioteca ${library}, e este deck está fora dela. Use --port=OUTRA para abri-lo à parte.`); process.exit(1); }
388
394
  }
389
395
  console.log(`✓ O SagaDeck Studio ${found.version} já está aberto (processo ${found.pid}): ${url}`);
396
+ { const B = await import("../src/studio/open-browser.js"); if (B.shouldOpenBrowser({ flags, multiuser })) B.openBrowser(url); }
390
397
  break;
391
398
  }
392
399
  if (decision.action === "conflict") {
@@ -403,6 +410,9 @@ async function main() {
403
410
  server.listen(port, host, async () => {
404
411
  const { studioBanner } = await import("../src/studio/banner.js");
405
412
  for (const line of studioBanner({ version: VERSION, url: `http://${shownHost}:${port}/`, library, ai: (await import("../src/ai/llm.js")).llmConfig(), multiuser, usersDir: path.join(library, "usuarios"), isLibrary: !deckFile })) console.log(line);
413
+ // depois do endereço (a primeira linha é sempre ele): abre o navegador na página
414
+ const B = await import("../src/studio/open-browser.js");
415
+ if (B.shouldOpenBrowser({ flags, multiuser }) && B.openBrowser(`http://${shownHost}:${port}/`)) console.log(" (abrindo no navegador; --sem-navegador desliga)");
406
416
  });
407
417
  break;
408
418
  }
package/docs/ANDAMENTO.md CHANGED
@@ -12,7 +12,7 @@ começar. Trabalho em curso vai para o GitHub (commit + push) a cada etapa, nunc
12
12
 
13
13
  **Para quem pega isto numa sessão nova, sem a conversa de origem.** O projeto é o **sagadeck**:
14
14
  - Repositório GitHub `naruminho/sagadeck`, clonado em `C:\Users\narum\src\sagadeck`, branch `main`.
15
- - Publicado no npm como `sagadeck` (1.6.0: IA embutida, só npm; o PyPI parou na 1.5.0).
15
+ - Publicado no npm como `sagadeck` (1.7.0: primeiro uso guiado, Ambientes por campos; o PyPI parou na 1.5.0).
16
16
  - Ferramenta Node (ESM) que transforma apresentações escritas em YAML em HTML/PPTX.
17
17
  - Tem um editor web, o Studio (`sagadeck studio`, porta 3517).
18
18
  - Tem uma IA (via modelrelay em `http://127.0.0.1:8765/v1`) que cria e edita decks, inclusive a partir de PDFs de
@@ -108,6 +108,51 @@ os achados para os slides").
108
108
  `missed`, elas entram no inventário com a caixa exata do texto e passam pela mesma releitura do recorte
109
109
  (`transcribeEquation`) que escreve o LaTeX das outras. Saiu da lista.
110
110
 
111
+ ## Primeiro uso: navegador abre sozinho, "Para começar" e a recomendação de IA — 09/10/2026
112
+
113
+ Pergunta de Narumi: quem instala pelo npm sabe pôr no ar, sabe a URL, precisa editar arquivo para a IA? Antes: um
114
+ comando (`sagadeck studio`), mas o navegador não abria, o `sagadeck` sozinho mostrava o `studio` como 19º item de uma
115
+ lista longa, e os campos de modelo do Configurar IA vinham vazios (ninguém sabe o nome exato de um modelo).
116
+
117
+ - `sagadeck studio` **abre o navegador** na página (`src/studio/open-browser.js`), inclusive quando já está aberto;
118
+ só para quem está no terminal: nunca em serviço/teste (sem TTY), CI, multiusuário, Linux sem tela ou
119
+ `--sem-navegador` / `SAGADECK_NO_BROWSER=1`. A primeira linha continua sendo o endereço.
120
+ - `sagadeck` sozinho começa com **"Para começar: sagadeck studio"** (o que faz, o endereço, deixar o terminal aberto).
121
+ - **Configurar IA** com a recomendação (`RECOMMENDED` em `src/ai/ia-config.js`: OpenRouter, DeepSeek para texto e
122
+ visão, Gemini para imagem, `:online` para busca) e **Usar a recomendação**, que preenche provedor e modelos. Ela é
123
+ também o teste da instalação: se nem com ela a IA responde, o problema é rede ou chave. Os campos trazem exemplos e
124
+ a tela diz que imagem e busca dependem do provedor. Sem listar os modelos do provedor (decisão: endereço próprio ou
125
+ de empresa pode não ter a lista, ou ser bloqueado).
126
+ - **Faixa "Configure a IA"** na biblioteca enquanto não está configurada (só onde se pode configurar; some ao salvar).
127
+ - Testes: `test/instance.test.js` (quando abre o navegador e o comando de cada sistema; `sagadeck` sozinho),
128
+ `test/library-ui.test.js` (faixa, recomendação preenche, salvar some a faixa).
129
+ - **Testes isolados de verdade**: a suíte completa falhou só nesta máquina num teste da lixeira, que leu as
130
+ Preferências reais (lixeira em 1 dia). 41 arquivos de teste não carregavam o isolamento (só os que importavam o
131
+ `helpers.js`). Agora `test/isolate.js` (biblioteca, ambientes, Preferências, IA e registro de comandos em pasta
132
+ temporária) é carregado antes de tudo pelo `npm test` (`--import`) e importado por todo arquivo de teste;
133
+ `guards.test.js` reprova arquivo de teste que não carregue.
134
+ - A avaliação ao vivo (`test/bench-live.test.js`) não fechava o navegador da revisão dos slides: o processo ficava
135
+ pendurado (dias). Fecha no fim; `guards.test.js` exige isso de todo teste que gera pelo caminho do Studio.
136
+ ## Ambientes por campos e sem o botão no servidor — 09/10/2026
137
+
138
+ Pergunta de Narumi: quem instala do zero sabe preencher o `ambientes.yaml` (slides de API)? O lugar já era automático
139
+ (o Studio grava em `~/.sagadeck/ambientes.yaml`), mas preencher era editar YAML numa caixa de texto. E no servidor
140
+ (multiusuário) o botão abria a janela com "O serviço recusou o acesso…", parecendo defeito.
141
+
142
+ - **Por campos** (`src/studio/public/api-envs-form.js`, aba padrão): um cartão por ambiente com endereço base,
143
+ variáveis, segredos (digitado vai cifrado; em branco mantém; ou o nome de uma variável de ambiente), autenticação
144
+ (nenhuma; token fixo, que vira `Authorization: Bearer {{secret.token}}`; token que expira, client credentials) e
145
+ **Testar** (salva, chama o endereço base e pede o token). Sem ambiente próprio, a tela explica que o ENSAIO já
146
+ funciona. A aba **Como texto** é o editor de YAML de antes.
147
+ - Motor (`src/api-client.js`): `formState`/`saveForm` (o mesmo arquivo, preservando comentários e o que o
148
+ formulário não conhece: `ca`, outros cabeçalhos), `check` (o Testar), cabeçalhos do ambiente com `{{secret.…}}` e
149
+ client secret do token cifrado. Rotas `/api/http/ambientes/form` e `/api/http/ambientes/testar`.
150
+ - **No servidor o botão some** (o slide de API não executa lá; `/api/http/state` diz `live: false`).
151
+ - Testes: `test/api-client.test.js` (salvar e ler de volta sem o valor dos segredos, comentários e `ca` preservados,
152
+ token fixo no pedido, segredo mantido em branco, Testar), `test/studio.test.js` (instalação do zero pela tela, até o
153
+ Testar confirmar com o token chegando na API), `test/studio-scripts.test.js` (botão some no multiusuário; falhava
154
+ sem o módulo).
155
+
111
156
  ## IA embutida, só npm e o Studio como a página do sagadeck — 09/10/2026
112
157
 
113
158
  Pedido de Narumi, depois de um agente (Copilot) instalar o sagadeck, abrir a página do modelrelay e, sem achar a
@@ -282,7 +282,9 @@ No Studio, a aba **Variáveis** mostra o ambiente atual em uma tabela **Nome / V
282
282
  normais ali; nomes e valores são salvos no arquivo de ambientes, nunca no deck. Arraste um nome da tabela
283
283
  para um campo do slide para inserir `{{nome}}`. Segredos são listados pelo nome, mas seu valor nunca aparece.
284
284
  Os campos `save` do deck também aparecem com sua origem e disponibilidade.
285
- O editor de ambientes valida o YAML antes de habilitar **Salvar** e preserva a versão anterior em `ambientes.yaml.bak`.
285
+ O editor de ambientes abre **por campos** (endereço base, variáveis, segredos, autenticação: nenhuma, token fixo
286
+ `Authorization: Bearer {{secret.token}}` ou token que expira; **Testar** chama o endereço base e pede o token). A aba
287
+ **Como texto** valida o YAML antes de habilitar **Salvar**. Os dois preservam a versão anterior em `ambientes.yaml.bak`.
286
288
  O botão **Variáveis** (na barra do slide) é o inspect/watch da apresentação, tudo num lugar só:
287
289
  - **Ambiente**: as variáveis (criar, editar, apagar) e as **protegidas** (marque "protegida" ao criar): na tela só o nome
288
290
  e ••••; o olhinho mostra o valor por 15 segundos. Nos slides e no código gerado aparece o nome (`{{secret.chave}}`),
@@ -334,8 +336,9 @@ texto) para o modo sem Studio.
334
336
 
335
337
  Endereços, credenciais e segredos ficam **na máquina**, nunca no deck (o deck pode ir para o GitHub).
336
338
  Outro lugar: variável `SAGADECK_AMBIENTES`. O selo no slide (DEV, HOM…) troca o ambiente. No Studio,
337
- **Inserir → Ambientes** mostra o arquivo (ou um modelo comentado, se ele ainda não existe), confere o YAML
338
- antes de gravar e troca o ambiente em uso.
339
+ **Inserir → Ambientes** preenche o arquivo por campos (ou mostra o YAML na aba Como texto, com um modelo comentado
340
+ se ele ainda não existe), confere antes de gravar e troca o ambiente em uso. Os cabeçalhos do ambiente (`headers`)
341
+ aceitam `{{secret.nome}}`; o client secret do token pode ficar cifrado (`client_secret: "dpapi:…"` no Windows).
339
342
 
340
343
  ```yaml
341
344
  current: hom
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sagadeck",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Gerador de apresentações: YAML -> HTML animado + PowerPoint editável, com temas e figuras geradas na hora.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,9 +39,9 @@
39
39
  "dev": "node bin/sagadeck.js studio",
40
40
  "build:code-lab": "node scripts/bundle-code-lab.mjs",
41
41
  "test:live": "node tools/test-live.mjs",
42
- "test": "node --test --test-concurrency=1 \"test/*.test.js\"",
43
- "test:unit": "node --test test/engine.test.js",
44
- "test:ui": "node --test --test-concurrency=1 test/runtime.test.js test/studio.test.js"
42
+ "test": "node --import ./test/isolate.js --test --test-concurrency=1 \"test/*.test.js\"",
43
+ "test:unit": "node --import ./test/isolate.js --test test/engine.test.js",
44
+ "test:ui": "node --import ./test/isolate.js --test --test-concurrency=1 test/runtime.test.js test/studio.test.js"
45
45
  },
46
46
  "repository": {
47
47
  "type": "git",
@@ -13,6 +13,12 @@ export const PROVIDERS = {
13
13
  openrouter: { label: "OpenRouter", url: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY" },
14
14
  openai: { label: "OpenAI", url: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY" },
15
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
+ };
16
22
  // os papéis que o sagadeck usa; cada um aponta para um modelo do provedor
17
23
  export const ROLES = ["text", "vision", "image", "search"];
18
24
 
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);
@@ -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;
@@ -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,7 @@ 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
+ json(res, 200, { config: maskedIA(loadIA()), file: iaFile(), source: cfg.source, configured: !!(cfg.url || cfg.adaptador), providers: PROVIDERS, roles: ROLES, recommended: RECOMMENDED, editable: !opts.multiuser });
27
27
  return true;
28
28
  }
29
29
  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" };
@@ -27,13 +28,15 @@ window.SagaAISettings = (() => {
27
28
  <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
29
  ${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
30
  ${info.editable ? "" : '<p class="warn">Neste servidor, quem configura a IA é quem administra.</p>'}
31
+ ${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
32
  <label for="ai-cfg-provider">Provedor</label>
31
33
  <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
34
  <label for="ai-cfg-url">Endereço da API</label>
33
35
  <input id="ai-cfg-url" data-f="url" placeholder="https://…/v1" spellcheck="false">
34
36
  <label for="ai-cfg-key">Chave</label>
35
37
  <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>
38
+ <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>
39
+ <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
40
  <div data-status role="status"></div>
38
41
  <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
42
  const sel = dialog.querySelector('[data-f="provider"]'), url = dialog.querySelector('[data-f="url"]');
@@ -44,6 +47,13 @@ window.SagaAISettings = (() => {
44
47
  sel.onchange = sync;
45
48
  if (!info.editable) dialog.querySelectorAll("input,select,[data-save],[data-test]").forEach((el) => (el.disabled = true));
46
49
  dialog.querySelector("[data-close]").onclick = () => dialog.close();
50
+ const rec = dialog.querySelector("[data-rec]");
51
+ if (rec) rec.onclick = () => {
52
+ sel.value = info.recommended.provider; sync();
53
+ for (const [r, m] of Object.entries(info.recommended.models)) { const el = dialog.querySelector(`[data-model="${r}"]`); if (el) el.value = m; }
54
+ 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.";
55
+ dialog.querySelector('[data-f="key"]').focus();
56
+ };
47
57
  dialog.querySelector("[data-test]").onclick = () => submit("api/ia/test");
48
58
  dialog.querySelector("[data-save]").onclick = () => submit("api/ia");
49
59
  }
@@ -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
+ })();
@@ -3430,6 +3430,7 @@ ${ta.value}`;
3430
3430
  renderApiEnvChips(st);
3431
3431
  if (!j.exists) apiEnvsStatus("Este arquivo ainda não existe. Validando o modelo antes de habilitar Salvar…");
3432
3432
  void validateApiEnvs();
3433
+ await window.SagaApiEnvsForm.open(); // por campos é o padrão (api-envs-form.js); o texto fica na outra aba
3433
3434
  }
3434
3435
  function closeApiEnvs() { apiEnvsModal().classList.add("hidden"); }
3435
3436
  async function saveApiEnvs() {
@@ -3915,7 +3916,7 @@ ${ta.value}`;
3915
3916
  document.getElementById("btn-api-envs").addEventListener("click", openApiEnvs);
3916
3917
  document.getElementById("btn-close-api-envs").addEventListener("click", closeApiEnvs);
3917
3918
  document.getElementById("btn-api-envs-cancel").addEventListener("click", closeApiEnvs);
3918
- document.getElementById("btn-api-envs-save").addEventListener("click", saveApiEnvs);
3919
+ document.getElementById("btn-api-envs-save").addEventListener("click", async () => { if (!window.SagaApiEnvsForm.active()) return saveApiEnvs(); if (await window.SagaApiEnvsForm.save()) await refreshStudioVars(); });
3919
3920
  document.getElementById("btn-api-vars-studio").addEventListener("click", () => openPane("vars"));
3920
3921
  document.getElementById("api-envs-text").addEventListener("input", scheduleApiEnvValidation);
3921
3922
  // grade de variáveis: grava ao sair da célula (change) ou no Enter; Esc desfaz; a linha em branco do fim cria
@@ -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,10 @@ 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; }
34
+ .ai-banner .ic { width: 18px; height: 18px; flex: none; }
31
35
  .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
36
  .lib-btn.primary { background: var(--brand-fill); color: var(--on-brand); box-shadow: 0 3px 8px #634ce916; }
33
37
  .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,11 @@
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 }) => { $("#btn-ai").hidden = !editable; $("#ai-banner").hidden = !editable || configured; hydrate($("#ai-banner")); }).catch(() => {});
65
+ $("#btn-ai").onclick = () => window.SagaAISettings.open(aiSaved);
66
+ $("#ai-banner-btn").onclick = () => window.SagaAISettings.open(aiSaved);
64
67
  const openEditor = (id, present) => { location.href = `editor?deck=${encodeURIComponent(id)}${present ? "&present=1" : ""}`; };
65
68
 
66
69
  // ---------------------------------------------------------------- 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; }