sagadeck 1.11.1 → 1.11.3

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
@@ -267,6 +267,8 @@ embutido em iframe de outra origem.
267
267
 
268
268
  **Comandos da IA (para testar APIs de verdade):** no chat do Studio, a IA pode pedir para rodar um trecho de JavaScript, Python, PowerShell ou shell — por exemplo, testar a API cuja documentação você colou, ajustar o contrato e só então montar o slide. Nada roda sem você ver o código e clicar em **Executar** (ou em *Executar e liberar os próximos*, que vale até recarregar o Studio ou trocar de apresentação). O comando roda na pasta da apresentação e recebe as variáveis do ambiente ativo (`SAGA_VAR_*`, `SAGA_SECRET_*`, `SAGA_TOKEN`); a IA só vê os nomes, e a saída volta para ela com os segredos mascarados. No servidor multiusuário, só quem estiver em `--agentes=usuario1,usuario2` (ou `SAGADECK_AGENTES`) tem comandos, e eles rodam **na máquina do servidor**; os demais usam o resto normalmente.
269
269
 
270
+ **Enviar cópia para outra pessoa (servidor multiusuário):** quem administra (`--admins=usuario1` ou `SAGADECK_ADMINS`; sem lista, os de `--agentes`) tem, no menu do cartão da biblioteca, **Enviar cópia para…**: a apresentação chega como nova na biblioteca da pessoa escolhida, com "(versão revisada)" no título, sem substituir nada dela. Vão imagens, `contexto/` e o resto da pasta; ficam de fora a conversa com a IA, as versões e os avisos da criação.
271
+
270
272
  **Vários usuários (servidor):** `sagadeck studio --multiuser --library=/srv/sagadeck` dá uma biblioteca por pessoa
271
273
  (`usuarios/<nome>/`), identificada pelo cabeçalho `X-Sagadeck-User` (ou `--user-header=...`) que o proxy de login
272
274
  (nginx) coloca. Sem o cabeçalho, nada é servido. Por confiar nesse cabeçalho, só roda escutando em `127.0.0.1`,
package/bin/sagadeck.js CHANGED
@@ -67,6 +67,7 @@ const HELP = `sagadeck — YAML -> apresentação (HTML animado + PowerPoint edi
67
67
  --host=0.0.0.0 abre para a rede (padrão: só esta máquina)
68
68
  --multiuser: uma biblioteca por usuário, atrás de um proxy que envia X-Sagadeck-User
69
69
  --agentes=naru,ana: no multiusuário, quem pode deixar a IA rodar comandos (padrão: ninguém)
70
+ --admins=naru: no multiusuário, quem envia cópias para a biblioteca dos outros (padrão: os de --agentes)
70
71
  sagadeck ensaio-api [--port=3000] ensaio do slide "api": deck de exemplo contra uma API de mentira (sem VPN)
71
72
  o deck fica na biblioteca (Sem tópico/Ensaio APIs de IA ao vivo); se já existir, reabre
72
73
  sagadeck pack <deck.yaml> [saida.sagadeck] a apresentação inteira num arquivo (YAML + imagens, CSS, widgets)
@@ -406,7 +407,8 @@ async function main() {
406
407
  process.exit(1);
407
408
  }
408
409
  const agentUsers = typeof flags.agentes === "string" ? flags.agentes.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
409
- const server = createStudioServer(deckFile, { port, host, library, multiuser, userHeader: flags["user-header"], agentUsers });
410
+ const adminUsers = typeof flags.admins === "string" ? flags.admins.split(",").map((x) => x.trim()).filter(Boolean) : undefined;
411
+ const server = createStudioServer(deckFile, { port, host, library, multiuser, userHeader: flags["user-header"], agentUsers, adminUsers });
410
412
  server.on("error", (e) => {
411
413
  if (e.code !== "EADDRINUSE") throw e;
412
414
  console.error(`A porta ${port} está ocupada por outro programa. Use --port=OUTRA.`);
package/docs/ANDAMENTO.md CHANGED
@@ -8,7 +8,73 @@ começar. Trabalho em curso vai para o GitHub (commit + push) a cada etapa, nunc
8
8
 
9
9
  ---
10
10
 
11
- ## 1.11.1 — 23/10/2026 (branch `claude/correcoes-1.11.1`)
11
+ ## 1.11.3 — 23/10/2026 (branch `claude/versao-1.11.3`)
12
+
13
+ Junta o envio de cópia para outra pessoa (#217), o Corrigir tudo (#218) e os testes ao vivo a cada 3 versões (#216).
14
+ Primeira versão múltipla de 3: `node scripts/ai-live-ci.mjs --na-release` rodou antes de publicar, 27 de 27 de primeira
15
+ (16 min). Suíte completa: 1.159 testes, uma falha intermitente (teste de fórmulas esperava 900 ms fixos pelo
16
+ salvamento; passou 3 de 3 isolado; agora espera o arquivo salvo). No Oracle, a Narumi entra em `--admins` do
17
+ sagadeck e do sagadeck-dev para ver o Enviar cópia para….
18
+
19
+ ## Corrigir tudo com IA no painel Conferência — 23/10/2026 (branch `claude/jovial-hellman-4b4379`)
20
+
21
+ Na aula de hidráulica a criação deixou 24 avisos; corrigir um por um pelo painel dava trabalho, e na prática a IA
22
+ resolveu tudo quando recebeu um pedido único ("corrija os avisos da criação"). O cartão da biblioteca já mostrava
23
+ "N a conferir"; faltava o atalho.
24
+ - Revisar › Conferência ganhou **Corrigir tudo com IA** no cabeçalho: um pedido só ao chat com todas as pendências que
25
+ ainda valem (sem as ignoradas e sem os avisos de "o slide mudou"), agrupadas por slide ("Slide 2:", "Deck todo:"),
26
+ cada uma com o `fix` do item. O servidor monta (`conferenceBatch` em `src/studio/conference.js`, rota
27
+ `POST /api/conferencia/corrigir-tudo`) e marca como `enviado` de uma vez (`decideItems`) o que não se confere
28
+ sozinho; contas, contas do material e slides `derivado` vão com `contas: [n]` e saem do painel pela conferência
29
+ depois da resposta, como no botão por item.
30
+ - O botão fica desabilitado enquanto a IA responde; o painel se atualiza no fim do pedido (`sagadeck:chat-done`).
31
+ Testes: `ai.test.js` (agrupamento, aviso que mudou fora, contas, enviados, decisão em lote) e `studio-ai.test.js`
32
+ (três pendências, clique, um pedido só ao modelo com as três, `contas: [2]`, botão desabilitado durante a resposta,
33
+ deck salvo corrigido, painel vazio, zero erros JS). O teste do Studio falha sem o botão. `npm test`: 1.153 testes,
34
+ 1.122 aprovados, 31 condicionais pulados, zero falhas.
35
+
36
+ ## Enviar cópia para outra pessoa — 23/10/2026 (branch `claude/cranky-bell-62ec04`)
37
+
38
+ A versão revisada da aula da Mary foi posta na biblioteca dela à mão, por SSH como root (copiar a pasta, trocar o
39
+ título, `chown`). Agora quem administra o servidor multiusuário faz isso pelo navegador: menu do cartão › **Enviar cópia
40
+ para…** › escolhe a pessoa. Chega como apresentação NOVA, no tópico de mesmo nome, com "(versão revisada)" no título
41
+ (sem dobrar o sufixo) e nome livre (nunca por cima da dela). Vai a pasta inteira (imagens, `contexto/`, `geracao.json`);
42
+ ficam de fora `.sagadeck/conversa.json`, `.sagadeck/versoes/`, `.sagadeck/avaliacao-geracao.json`, `.sagadeck/lixeira/`
43
+ e a conversa antiga `<deck>.conversa.json` (`sendDeckTo` em `src/library.js`; `copyRetry` ganhou `skip`).
44
+ - Quem administra: `--admins=…` / `SAGADECK_ADMINS`; sem lista, os de `--agentes` (já mandam na máquina). Rotas
45
+ `GET /api/library/users` e `POST /api/library/decks/send` respondem 403 para os outros; destino só pasta existente de
46
+ `usuarios/` (nada de `../`, nem a própria). O servidor registra no log quem enviou o quê para quem.
47
+ - O id do portal não diz quem é: a biblioteca grava o username/e-mail do `/whoami` em `usuarios/<id>/.perfil.json` ao
48
+ abrir (`POST /api/library/me`); a lista mostra nome, e-mail, id, quantas apresentações e a mais recente.
49
+ - Arquivos criados pelo próprio processo do Studio: o dono já é o usuário do serviço, sem `chown`.
50
+ - Testes: `library.test.js` (cópia completa, exclusões, sufixo, nunca sobrescreve) e `library-ui.test.js` (admin envia
51
+ pela tela duas vezes; Mary não vê o item e leva 403; destino inválido dá 400; zero erros de JavaScript).
52
+ - Falta: no Oracle, conferir se a Narumi está em `--agentes` ou ligar `--admins` no serviço depois do deploy.
53
+
54
+ ## Testes com o modelo de verdade a cada 3 versões — 23/10/2026 (branch `claude/testes-ao-vivo`)
55
+
56
+ Os testes ao vivo (`ai-live.test.js`) não rodam na CI comum, e dois deles falhavam havia tempo sem ninguém ver. Decisão
57
+ da Narumi: rodar a cada release custa demais e agendado roda à toa; **a cada versão múltipla de 3** (1.11.3, 1.11.6,
58
+ 1.12.0…), antes de publicar, na máquina de quem publica (com a IA de `~/.sagadeck/ia.json`), e o que falhar duas vezes
59
+ se corrige antes de publicar. Regra no `CLAUDE.md`.
60
+ - `scripts/ai-live-ci.mjs`: roda a bateria, repete uma vez o que falhar (instável ≠ quebrado), resume em Markdown;
61
+ `--na-release` pula versões que não são múltiplas de 3 e diz qual é a próxima; `AI_LIVE_FILTRO` roda só um grupo.
62
+ - `.github/workflows/ai-live.yml`: só pelo botão (Run workflow, com filtro opcional); usa o segredo
63
+ `SAGADECK_IA_JSON` (conteúdo de um ia.json) e, sem ele, avisa e termina verde. O segredo não foi cadastrado: chave é
64
+ da Narumi (`gh secret set SAGADECK_IA_JSON < ~/.sagadeck/ia.json`, se ela quiser o botão funcionando).
65
+ - Testes em `ai-live-ci.test.js`; o roteiro rodou de verdade com filtro (contas e refinamento): 5 de 5.
66
+
67
+ 1.11.2 — 23/10/2026 (branch `claude/ritmo-da-geracao`)
68
+
69
+ Rodada completa dos testes ao vivo (`SAGADECK_LIVE=1`) depois da 1.11.1: 24 de 27. As três falhas foram comparadas com o
70
+ código de antes (`3c18855`): "refinamento" é variação do modelo (2 de 3 nos dois); "deck variado" e "sem falar de
71
+ imagens → nenhuma" já falhavam. A revisão de ritmo que existia na criação saiu de propósito em `d5b052a` (a criação
72
+ usa o caminho do chat, uma chamada, conferido em `ai.test.js`); então o tom virou regra dura do prompt (ANTI-FEIJÃO),
73
+ e o deck variado passou 3 de 3 ao vivo. O teste de imagens esperava zero sem pedido, mas o prompt manda a IA decidir
74
+ ("não falou de imagem → VOCÊ decide", conferido em `studio-ai.test.js`): passou a exigir poucas (até 1/3), 2 de 2.
75
+ Também em produção: o cartão da aula original da Mary mostra "24 a conferir"; a versão revisada, zero.
76
+
77
+ 1.11.1 — 23/10/2026 (branch `claude/correcoes-1.11.1`)
12
78
 
13
79
  - Gravação (`deckText`): reemite com e sem quebra em 80 colunas e fica a versão que preserva mais linhas do arquivo.
14
80
  Mudar o título de uma cópia na biblioteca tinha regravado 558 linhas (conteúdo igual). Teste em `deck-file.test.js`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sagadeck",
3
- "version": "1.11.1",
3
+ "version": "1.11.3",
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": {
package/src/ai/deck-ai.js CHANGED
@@ -67,7 +67,7 @@ Siga ESTRITAMENTE a referência abaixo: use só layouts, elementos, campos e fig
67
67
  - Prefira figuras geradas (icon, picto, diagram, chart) a listas de bullets. Ícones são do Lucide, nomes em inglês kebab-case (ex.: rocket, shield-check, trending-up).
68
68
  - Ensinar como algo FUNCIONA (algoritmo, método, conta, receita técnica): o mecanismo inteiro, passo a passo, e não só "o que é". Um exemplo concreto com números atravessa a aula (as mesmas entradas do começo ao fim), cada passo mostra a conta feita na tela (\`solution\`, \`steps\`, \`table\`, \`algo\`; no \`solution\`, o número de cada passo sai de \`fn\`, calculado pelo sagadeck a partir dos \`givens\`, e a resposta cita \`{{nome}}\`: nunca escreva à mão um resultado que uma fórmula dá), uma simulação deixa mexer (\`calc\` ou \`science\` com controles: muda a entrada, vê o resultado) e, se ele aprende ou se ajusta, mostre uma rodada desse ajuste com os números. Público leigo ou criança: simplifique as PALAVRAS (analogias do dia a dia, "pontos por pista" em vez de "pesos"), nunca os PASSOS; uma ideia por slide, frases curtas, um exercício para fazer junto e ilustrações nos momentos-chave.
69
69
  - Exercício cuja resposta o material NÃO traz: a resolução é conta sua, então o slide leva \`provenance: derivado\` com \`provenanceNote\` dizendo que a resposta foi calculada pela IA. Antes de escrever a resposta, confira a ordem de grandeza por um segundo caminho (outra fórmula, uma razão simples, uma estimativa: "se a perda é de 0,02 m por metro, 3 m de folga dão uns 150 m de tubo"); a frase de conferência que vai na tela ou nas notes sai dessa conferência, nunca da própria resposta. O sagadeck refaz as contas depois (Conferindo as contas) e devolve o que não bater. Gráfico de um balanço só compara grandezas da mesma natureza (o que se tem × o que se gasta), na mesma unidade.
70
- - ANTI-FEIJÃO-COM-ARROZ: varie os layouts de verdade; capa (cover) no início e encerramento (end) no fim quando fizer sentido. Regras duras: nunca 2 slides seguidos com o mesmo layout (salvo série intencional); em 6+ slides use 5+ layouts diferentes com 1 slide de impacto (headline, number, statement, full, image, question); no máximo 45% do deck em listas/cartões (cards, list, steps, stats, bento, agenda). Conteúdo manda no layout, não o contrário (ver o mapa em "Layouts válidos").
70
+ - ANTI-FEIJÃO-COM-ARROZ: varie os layouts de verdade; capa (cover) no início e encerramento (end) no fim quando fizer sentido. Regras duras: nunca 2 slides seguidos com o mesmo layout (salvo série intencional); em 6+ slides use 5+ layouts diferentes com 1 slide de impacto (headline, number, statement, full, image, question); no máximo 45% do deck em listas/cartões (cards, list, steps, stats, bento, agenda). Varie também o TOM visual (regra dura): em 6+ slides, nem todos \`tone: light\` — pelo menos 1 slide com \`tone: dark\` ou \`accent\` num momento-chave (abertura de seção, número de impacto, conclusão), com moderação; material de consulta pode ficar todo claro. Conteúdo manda no layout, não o contrário (ver o mapa em "Layouts válidos").
71
71
  - Slide denso (documentação, referência, números): feche com 1 takeaway em ==destaque== e use o elemento \`aviso\` (tipos: \`importante\`, \`atencao\`, \`dica\`, \`perigo\`) para o que não pode passar batido; grife ==palavras-chave== no texto corrido em vez de encher de negrito.
72
72
  - Escreva no idioma do pedido do usuário.
73
73
  - Layouts válidos (\`layout:\`): ${Object.keys(LAYOUTS).join(", ")}. Conteúdo → layout (use o que comunica, não o mais fácil): processo/fluxo/arquitetura/UML: layout \`diagram\` (Mermaid); itens em volta de uma ideia: layout \`infographic\`; gráfico: \`chart\` (fórmula/curva/3D: \`science\`); datas: \`calendar\` (grade) ou \`timeline\` (marcos); página densa estilo revista: \`poster\`; jornada+problema+solução+painel: \`onepage\`; status semanal: \`status\`; definição: \`definition\`; citação: \`quote\`; número gigante: \`number\`; KPIs: \`stats\`; A×B: \`compare\`; 2×2: \`matrix\`; pergunta à plateia: \`question\`/\`poll\`; código: \`code\`, guiado: \`codewalk\`; screenshot com foco: \`spotlight\`; API ao vivo: \`api\`; conta/calculadora: \`solution\`/\`calc\`; algoritmo animado: \`algo\`; redes/rotas: \`graphlab\`; código executável pelo aluno: \`codelab\`; experimento: \`playground\`; decisão: \`decisionlab\`; duelo/terminais/turnos: \`duel\`/\`terminals\`/\`turns\`; carrossel: \`carousel\`; funil/pirâmide/agenda: \`funnel\`/\`pyramid\`/\`agenda\`; grade/consulta: \`bento\`/\`mosaic\`/\`ribbon\`/\`dossier\`; caminhos com volta ao mapa: \`hub\`+\`goto\`/\`back\`/\`next\`; manchete: \`headline\`; imagem total: \`full\`/\`image\`. Diagramas simples (\`diagram: loop/flow/venn…\`) e gráficos também existem como ELEMENTOS, dentro de figure/content/side.
package/src/fs-retry.js CHANGED
@@ -32,12 +32,15 @@ export function renameRetry(from, to, { delays = RENAME_DELAYS } = {}) {
32
32
 
33
33
  // arquivo ou pasta inteira (com imagens): duplicar uma apresentação é o caso — no Windows o antivírus segura o
34
34
  // arquivo recém-criado por um instante e a cópia falhava com EPERM (era o flake do CI no "mover (pasta inteira…)").
35
- export function copyRetry(from, to, { delays = RENAME_DELAYS } = {}) {
35
+ // skip(rel): o que fica de fora (rel = caminho relativo à pasta copiada, com "/")
36
+ export function copyRetry(from, to, { delays = RENAME_DELAYS, skip, rel = "" } = {}) {
36
37
  const st = fs.statSync(from);
37
38
  if (!st.isDirectory()) return retry(delays, () => fs.copyFileSync(from, to));
38
39
  fs.mkdirSync(to, { recursive: true });
39
40
  for (const e of fs.readdirSync(from, { withFileTypes: true })) {
40
- copyRetry(path.join(from, e.name), path.join(to, e.name), { delays });
41
+ const sub = rel ? `${rel}/${e.name}` : e.name;
42
+ if (skip?.(sub)) continue;
43
+ copyRetry(path.join(from, e.name), path.join(to, e.name), { delays, skip, rel: sub });
41
44
  }
42
45
  }
43
46
 
package/src/library.js CHANGED
@@ -135,6 +135,10 @@ export function safeName(s, fallback = "Sem título") {
135
135
  return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(clean) || !clean ? fallback : clean;
136
136
  }
137
137
 
138
+ // o que não vai junto ao enviar uma cópia para outra pessoa (sendDeckTo): conversa, versões, avisos da criação e o
139
+ // que foi apagado no projeto. A conversa antiga (<deck>.conversa.json, ao lado do .yaml) também fica.
140
+ const SEND_SKIP = new Set([".sagadeck/conversa.json", ".sagadeck/versoes", ".sagadeck/avaliacao-geracao.json", ".sagadeck/lixeira"]);
141
+
138
142
  function uniquePath(dir, name, ext = "") {
139
143
  let p = path.join(dir, name + ext);
140
144
  for (let n = 2; fs.existsSync(p); n++) p = path.join(dir, `${name} (${n})${ext}`);
@@ -318,6 +322,31 @@ export function openLibrary(root, { trashDays } = {}) {
318
322
  return idOf(yaml);
319
323
  }
320
324
 
325
+ // Envia uma cópia para OUTRA biblioteca (no servidor, quem administra manda a versão revisada para a biblioteca de
326
+ // alguém): sempre apresentação nova, no tópico de mesmo nome, com nome livre (nunca por cima da dela). Vai a pasta
327
+ // inteira (imagens, contexto/, widgets); fica de fora o que é da conversa de quem fez a cópia: o chat, as versões
328
+ // e o relatório de avisos da criação (a outra pessoa recomeça a conferência do zero).
329
+ function sendDeckTo(id, dest, { suffix = "versão revisada" } = {}) {
330
+ const { file, unit, info } = unitOf(id);
331
+ if (path.resolve(dest.root) === root) throw new Error("a biblioteca de destino é esta mesma: use Duplicar");
332
+ const tail = suffix ? ` (${suffix})` : "";
333
+ const title = tail && !info.title.endsWith(tail) ? info.title + tail : info.title;
334
+ const topicDirOf = dest.resolveId(dest.createTopic(info.topic || "Sem tópico"));
335
+ const ext = unit === file ? path.extname(file) : "";
336
+ const to = uniquePath(topicDirOf, safeName(title), ext);
337
+ copyRetry(unit, to, { skip: (rel) => SEND_SKIP.has(rel) || /^[^/]+\.conversa\.json$/i.test(rel) });
338
+ let yaml = unit === file ? to : path.join(to, path.basename(file));
339
+ if (unit !== file && path.basename(file).replace(/\.ya?ml$/i, "") === path.basename(unit)) {
340
+ const renamed = path.join(to, path.basename(to) + path.extname(file));
341
+ renameRetry(yaml, renamed);
342
+ yaml = renamed;
343
+ }
344
+ const spec = YAML.parse(fs.readFileSync(yaml, "utf8")) || {};
345
+ spec.title = title;
346
+ writeDeckFile(yaml, spec); // só o título muda no arquivo
347
+ return { id: dest.idOf(yaml), title };
348
+ }
349
+
321
350
  // ---- geração em andamento (invisível): a IA trabalha numa pasta oculta do tópico e a apresentação
322
351
  // só aparece na biblioteca quando pronta (publicada com o título final, num renomear atômico). Nada a meio
323
352
  // fazer fica visível — nem pode ser apagado sem querer no meio da geração. Interrompida: vai para a lixeira
@@ -533,7 +562,7 @@ export function openLibrary(root, { trashDays } = {}) {
533
562
  importOffice,
534
563
  root, list, resolveId, idOf,
535
564
  createTopic, updateTopic, deleteTopic,
536
- createDeck, moveDeck, renameDeck, duplicateDeck,
565
+ createDeck, moveDeck, renameDeck, duplicateDeck, sendDeckTo,
537
566
  createStagingDeck, publishStagedDeck, discardStaging, recoverStaging,
538
567
  trashDeck, restoreDeck, purgeDeck, purgeOld, listTrash, emptyTrash,
539
568
  importPackage,
@@ -1,9 +1,10 @@
1
- // Rotas do painel Revisar › Conferência (src/studio/conference.js): a lista, a decisão por item e o Conferir de novo.
1
+ // Rotas do painel Revisar › Conferência (src/studio/conference.js): a lista, a decisão por item, o Corrigir tudo e o
2
+ // Conferir de novo.
2
3
  import path from "node:path";
3
4
  import { storedDocumentMaterials } from "../ai/document-materials.js";
4
5
  import { llmAvailable } from "../ai/llm.js";
5
6
  import * as Project from "./project.js";
6
- import { conferenceItems, decideItem, recheckDeck, loadGenReport } from "./conference.js";
7
+ import { conferenceItems, conferenceBatch, decideItem, decideItems, recheckDeck, loadGenReport } from "./conference.js";
7
8
 
8
9
  const send = (res, data, status = 200) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); };
9
10
 
@@ -26,6 +27,17 @@ export async function conferenceRoutes({ req, res, pathname, W, readJSON, isBund
26
27
  catch (e) { send(res, { error: e.message }, 400); }
27
28
  return true;
28
29
  }
30
+ // Corrigir tudo com IA: monta o pedido único e marca como enviado o que não se confere sozinho; o Studio manda ao chat
31
+ if (pathname === "/api/conferencia/corrigir-tudo" && req.method === "POST") {
32
+ try {
33
+ if (!dir) throw new Error("A apresentação precisa estar salva na biblioteca.");
34
+ const batch = conferenceBatch(conferenceItems(loadGenReport(dir), W.spec, { dir }));
35
+ if (!batch) throw new Error("Nada pendente para corrigir.");
36
+ decideItems(dir, batch.enviados, "enviado");
37
+ send(res, { ok: true, pedido: batch.text, contas: batch.contas, count: batch.count, ...list() });
38
+ } catch (e) { send(res, { error: e.message }, 400); }
39
+ return true;
40
+ }
29
41
  if (pathname === "/api/conferencia/conferir" && req.method === "POST") {
30
42
  if (!dir) { send(res, { error: "A apresentação precisa estar salva na biblioteca." }, 400); return true; }
31
43
  const notes = [];
@@ -99,15 +99,37 @@ export function conferencePrompt(items) {
99
99
  return `PENDÊNCIAS DA CONFERÊNCIA AUTOMÁTICA (painel Revisar › Conferência): o que as checagens acharam e a pessoa ainda não resolveu nem ignorou. Avisos antigos da conversa que não estão nesta lista foram resolvidos ou ignorados pela pessoa: não volte a eles. Mexa nestes só se o pedido tiver a ver.\n${list.slice(0, 40).map((it) => `- ${it.slide ? `slide ${it.slide}` : "deck"} · ${it.label}: ${it.text}`).join("\n")}`;
100
100
  }
101
101
 
102
+ // Corrigir tudo com IA (botão do painel): um pedido só ao chat com todas as pendências que ainda valem, por slide.
103
+ // Na aula de hidráulica a criação deixou 24 avisos: um a um dava trabalho, e num pedido só a IA resolveu tudo.
104
+ // Aviso de slide que mudou fica de fora (talvez já esteja resolvido: a pessoa olha antes). contas: os slides que o
105
+ // Studio confere depois da resposta; enviados: o que sai do painel ao ir para o chat (o resto, a conferência decide).
106
+ export function conferenceBatch(items) {
107
+ const list = (items || []).filter((it) => !it.stale);
108
+ if (!list.length) return null;
109
+ const groups = new Map();
110
+ for (const it of list) { const k = it.slide || 0; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(it); }
111
+ const order = [...groups.keys()].sort((a, b) => (a || 1e9) - (b || 1e9));
112
+ const text = `Corrija ${list.length === 1 ? "esta pendência" : `de uma vez estas ${list.length} pendências`} da conferência automática (painel Revisar › Conferência). Cada item diz o que fazer; resolva ${list.length === 1 ? "na" : "todos na mesma"} resposta e não mexa no resto do deck.\n\n${order.map((k) => `${k ? `Slide ${k}` : "Deck todo"}:\n${groups.get(k).map((it) => `- ${it.fix}`).join("\n")}`).join("\n\n")}`;
113
+ const contas = [...new Set(list.flatMap((it) => it.contas || []))].sort((a, b) => a - b);
114
+ const enviados = list.filter((it) => it.kind !== "conta" && it.kind !== "material").map((it) => it.id);
115
+ return { text, contas, enviados, count: list.length };
116
+ }
117
+
102
118
  // A pessoa decidiu sobre um item: ignorado ("está certo assim") ou enviado (o pedido de correção foi ao chat)
103
119
  export function decideItem(dir, id, status) {
120
+ return decideItems(dir, [id], status)[0];
121
+ }
122
+
123
+ // Vários de uma vez (Corrigir tudo): uma gravação só do relatório
124
+ export function decideItems(dir, ids, status) {
104
125
  if (!dir) throw new Error("A apresentação precisa estar salva na biblioteca.");
105
126
  if (!["ignorado", "enviado"].includes(status)) throw new Error("status: ignorado ou enviado");
106
- if (!/^[a-z]+:[^\s]{1,200}$/.test(String(id || ""))) throw new Error("item da conferência inválido");
127
+ if (!ids.every((id) => /^[a-z]+:[^\s]{1,200}$/.test(String(id || "")))) throw new Error("item da conferência inválido");
107
128
  const report = loadGenReport(dir) || { createdAt: new Date().toISOString(), avisado: true };
108
- report.decisoes = { ...(report.decisoes || {}), [id]: { status, em: new Date().toISOString() } };
109
- writeGenReport(dir, report);
110
- return report.decisoes[id];
129
+ const em = new Date().toISOString();
130
+ report.decisoes = { ...(report.decisoes || {}), ...Object.fromEntries(ids.map((id) => [id, { status, em }])) };
131
+ if (ids.length) writeGenReport(dir, report);
132
+ return ids.map((id) => report.decisoes[id]);
111
133
  }
112
134
 
113
135
  // O chat conferiu as contas de alguns slides (pedido da pessoa, do painel ou edição que mexeu nos números): o resultado
@@ -10,15 +10,46 @@ import { createExperienceDeck } from "../experiences.js";
10
10
  import { demoDeck } from "./demo-decks.js";
11
11
  import { DEMO_FILES } from "../api-demo.js";
12
12
  import { developerStarter } from "./developer-starters.js";
13
+ import { openLibrary, safeName } from "../library.js";
13
14
 
14
- export async function libraryRoutes({ req, res, pathname, url, W, opts, readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS }) {
15
+ // Bibliotecas das outras pessoas do servidor (<raiz>/usuarios/<usuário>), para quem administra escolher o destino de
16
+ // "Enviar cópia para…". O nome vem do .perfil.json que a biblioteca de cada um grava ao abrir (o id do portal não diz
17
+ // nada); sem ele, o id, quantas apresentações tem e a mais recente ajudam a reconhecer.
18
+ const PROFILE = ".perfil.json";
19
+ function peerLibraries(usersRoot, self) {
20
+ let dirs = [];
21
+ try { dirs = fs.readdirSync(usersRoot, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".") && d.name !== self).map((d) => d.name); } catch {}
22
+ return dirs.map((id) => {
23
+ const dir = path.join(usersRoot, id);
24
+ let profile = {};
25
+ try { profile = JSON.parse(fs.readFileSync(path.join(dir, PROFILE), "utf8")); } catch {}
26
+ let decks = [];
27
+ try { decks = openLibrary(dir).list().decks; } catch {}
28
+ const recent = decks.sort((a, b) => b.edited - a.edited)[0];
29
+ return { id, name: profile.name || "", email: profile.email || "", decks: decks.length, recent: recent?.title || "" };
30
+ }).sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id, "pt-BR"));
31
+ }
32
+ function peerLibrary(usersRoot, id, self) {
33
+ const name = String(id || "");
34
+ const dir = path.join(usersRoot, name);
35
+ if (!name || name !== safeName(name, "") || name.startsWith(".") || path.dirname(dir) !== path.resolve(usersRoot) || !fs.existsSync(dir)) throw new Error("usuário não encontrado");
36
+ if (name === self) throw new Error("esta é a sua biblioteca: use Duplicar");
37
+ return openLibrary(dir);
38
+ }
39
+
40
+ export async function libraryRoutes({ req, res, pathname, url, W, opts, admin, usersRoot, readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS }) {
15
41
  const L = W.library;
16
42
  const ok = (obj = {}) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...obj })); };
17
43
  const fail = (e, code = 400) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message || String(e) })); };
18
44
  try {
19
45
  if (pathname === "/api/library" && req.method === "GET") {
20
46
  const openId = W.file && W.file.startsWith(L.root + path.sep) ? L.idOf(W.file) : null;
21
- return ok({ ...L.list(), user: W.user, multiuser: !!opts.multiuser, open: openId });
47
+ return ok({ ...L.list(), user: W.user, multiuser: !!opts.multiuser, admin, open: openId });
48
+ }
49
+ // quem administra escolhe para quem enviar a cópia (só no multiusuário)
50
+ if (pathname === "/api/library/users" && req.method === "GET") {
51
+ if (!admin) return fail(new Error("Só quem administra o servidor envia cópias para outras pessoas."), 403);
52
+ return ok({ users: peerLibraries(usersRoot, path.basename(L.root)) });
22
53
  }
23
54
  if (pathname === "/api/library/gallery-cover" && req.method === "GET") {
24
55
  const key = galleryKey(url.searchParams.get("key")), entry = galleryEntry(key);
@@ -71,6 +102,24 @@ export async function libraryRoutes({ req, res, pathname, url, W, opts, readJSON
71
102
  buildHTML(spec); // Valida antes de criar o arquivo na biblioteca.
72
103
  return ok({ id: L.createDeck(b.topic || "", spec) });
73
104
  }
105
+ // o nome do portal (username/e-mail do /whoami), para quem administra reconhecer a biblioteca de cada um
106
+ case "/api/library/me": {
107
+ if (!opts.multiuser) return ok();
108
+ const clip = (v) => String(v || "").trim().slice(0, 120);
109
+ const profile = { name: clip(b.name), email: clip(b.email) };
110
+ const file = path.join(L.root, PROFILE);
111
+ let old = null;
112
+ try { old = fs.readFileSync(file, "utf8"); } catch {}
113
+ if (old !== JSON.stringify(profile)) fs.writeFileSync(file, JSON.stringify(profile));
114
+ return ok();
115
+ }
116
+ // Enviar cópia para…: apresentação nova na biblioteca da outra pessoa, nunca por cima da dela (src/library.js)
117
+ case "/api/library/decks/send": {
118
+ if (!admin) return fail(new Error("Só quem administra o servidor envia cópias para outras pessoas."), 403);
119
+ const sent = L.sendDeckTo(b.id, peerLibrary(usersRoot, b.user, path.basename(L.root)));
120
+ console.log(`[Studio] ${W.user} enviou "${sent.title}" para a biblioteca de ${b.user} (${sent.id})`);
121
+ return ok(sent);
122
+ }
74
123
  case "/api/library/topics": return ok({ id: L.createTopic(b.name, b.color) });
75
124
  case "/api/library/topics/update": return ok({ id: L.updateTopic(b.id, b) });
76
125
  case "/api/library/topics/delete": L.deleteTopic(b.id); return ok();
@@ -1,7 +1,8 @@
1
1
  // Painel Revisar › Conferência: tudo o que as checagens automáticas acharam no deck aberto (contas, números sem base
2
2
  // no material, avisos da revisão visual, figuras do material sem slide, slides que a IA calculou ou incluiu), por
3
- // slide, com Ir ao slide, Corrigir com IA (um pedido pontual ao chat) e Ignorar (gravado no relatório). A lista vem
4
- // do servidor (src/studio/conference.js); o app passa o que o painel usa (create(ctx)).
3
+ // slide, com Ir ao slide, Corrigir com IA (um pedido pontual ao chat), Ignorar (gravado no relatório) e Corrigir tudo
4
+ // com IA (todas as pendências num pedido só). A lista vem do servidor (src/studio/conference.js); o app passa o que o
5
+ // painel usa (create(ctx)).
5
6
  (function () {
6
7
  const KIND_ICON = { conta: "calculator", material: "file-text", numero: "hash", visual: "scan-eye", figura: "image-off", derivado: "calculator", proprio: "sparkles" };
7
8
  function create(ctx) {
@@ -48,13 +49,25 @@
48
49
  if (it.kind !== "conta" && it.kind !== "material") decide(it.id, "enviado");
49
50
  askChat(it.fix, it.contas ? { contas: it.contas } : {});
50
51
  }
52
+ // Corrigir tudo com IA: um pedido só ao chat com as pendências que ainda valem, por slide (o servidor monta o
53
+ // pedido e marca como enviado o que não se confere sozinho; as contas vão com contas: [n] e são conferidas depois)
54
+ async function fixAll(btn, dlg) {
55
+ if (chatBusy()) { showToast("Espere a IA terminar o pedido atual.", 3000); return; }
56
+ btn.disabled = true;
57
+ try {
58
+ const j = await json(await fetch("api/conferencia/corrigir-tudo", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }));
59
+ take(j);
60
+ dlg.close();
61
+ askChat(j.pedido, j.contas?.length ? { contas: j.contas } : {});
62
+ } catch (e) { showToast(e.message); btn.disabled = false; }
63
+ }
51
64
  function open() {
52
65
  let dlg = document.getElementById("conference-dialog");
53
66
  const fill = () => {
54
67
  const groups = new Map();
55
68
  for (const it of items) { const k = it.slide || 0; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(it); }
56
69
  const order = [...groups.keys()].sort((a, b) => (a || 1e9) - (b || 1e9));
57
- dlg.innerHTML = `<header><i class="ic" data-ic="list-checks"></i><b>Conferência</b><span class="rv-count">${items.length}</span><span class="grow"></span>${salvo ? `<button type="button" class="btn btn-sm" data-recheck title="Refaz no deck atual a conferência das contas, a checagem dos números e das figuras do material"><i class="ic" data-ic="${checking ? "loader-circle" : "refresh-cw"}"></i> ${checking ? "Conferindo…" : "Conferir de novo"}</button>` : ""}<button type="button" class="btn btn-sm" data-close title="Fechar"><i class="ic" data-ic="x"></i></button></header>
70
+ dlg.innerHTML = `<header><i class="ic" data-ic="list-checks"></i><b>Conferência</b><span class="rv-count">${items.length}</span><span class="grow"></span>${items.some((it) => !it.stale) ? `<button type="button" class="btn btn-primary btn-sm" data-fix-all${chatBusy() ? " disabled" : ""} title="Manda ao chat, num pedido só, todas as pendências (menos os avisos de slides que já mudaram)"><i class="ic" data-ic="wand-sparkles"></i> Corrigir tudo com IA</button>` : ""}${salvo ? `<button type="button" class="btn btn-sm" data-recheck title="Refaz no deck atual a conferência das contas, a checagem dos números e das figuras do material"><i class="ic" data-ic="${checking ? "loader-circle" : "refresh-cw"}"></i> ${checking ? "Conferindo…" : "Conferir de novo"}</button>` : ""}<button type="button" class="btn btn-sm" data-close title="Fechar"><i class="ic" data-ic="x"></i></button></header>
58
71
  <div class="rv-list conf-list">${items.length ? order.map((k) => `<section class="conf-group" data-slide="${k}"><div class="conf-group-head">${k ? `<b class="rv-num">${k}</b><span class="conf-slide-title"></span><button type="button" class="btn btn-sm" data-go="${k}">Ir ao slide</button>` : "<b>Deck todo</b>"}</div>${groups.get(k).map((it) => `<div class="conf-item conf-${it.kind}" data-id="${it.id}"><span class="conf-kind"><i class="ic" data-ic="${KIND_ICON[it.kind] || "circle-alert"}"></i><span></span></span><span class="conf-text"></span><span class="conf-actions"><button type="button" class="btn btn-primary btn-sm" data-fix><i class="ic" data-ic="wand-sparkles"></i> Corrigir com IA</button><button type="button" class="btn btn-sm" data-ignore title="Está certo assim: sai do painel e dos próximos pedidos à IA"><i class="ic" data-ic="eye-off"></i> Ignorar</button></span></div>`).join("")}</section>`).join("")
59
72
  : `<div class="rv-empty">${salvo ? "Nada pendente: as checagens automáticas não deixaram avisos neste deck." : "A conferência vale para apresentações salvas na biblioteca."}</div>`}</div>
60
73
  ${conferidoEm ? `<div class="conf-foot">Conferido em ${new Date(conferidoEm).toLocaleString("pt-BR")}</div>` : ""}`;
@@ -71,6 +84,7 @@
71
84
  row.querySelector("[data-ignore]").onclick = () => decide(it.id, "ignorado");
72
85
  });
73
86
  dlg.querySelector("[data-recheck]")?.addEventListener("click", (e) => recheck(e.currentTarget));
87
+ dlg.querySelector("[data-fix-all]")?.addEventListener("click", (e) => fixAll(e.currentTarget, dlg));
74
88
  dlg.querySelector("[data-close]").onclick = () => dlg.close();
75
89
  hydrateIcons(dlg);
76
90
  };
@@ -241,6 +241,13 @@ body.lib { display: grid; grid-template-columns: 218px minmax(0, 1fr); grid-temp
241
241
 
242
242
  /* Nova: diálogo largo (IA em uma tela; vitrine "Modelo pronto") */
243
243
  .ldlg.wide { width: min(760px, calc(100vw - 32px)); }
244
+ /* Enviar cópia para… (quem administra): uma linha por pessoa do servidor */
245
+ .ldlg .hint { color: var(--text-2); font-size: 12px; line-height: 1.6; margin: 0 0 12px; }
246
+ .send-users { display: grid; gap: 6px; max-height: 320px; overflow-y: auto; }
247
+ .ldlg .send-user { display: flex; gap: 10px; align-items: flex-start; margin: 0; padding: 10px 12px; border: 1px solid var(--ui-line); border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--text); }
248
+ .ldlg .send-user:has(input:checked) { border-color: var(--brand); }
249
+ .send-user input { margin-top: 3px; accent-color: var(--brand); }
250
+ .send-user small { display: block; color: var(--text-3); font-size: 11px; margin-top: 2px; overflow-wrap: anywhere; }
244
251
  .ldlg select { width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--ui-line-strong); background: var(--chrome); color: var(--text); font: inherit; font-size: 13px; }
245
252
  .ldlg option { background: var(--chrome); color: var(--text); }
246
253
  .dlg-two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 16px; }
@@ -12,7 +12,7 @@
12
12
  let data = { topics: [], decks: [], trash: [], root: "" };
13
13
  let view = new URLSearchParams(location.search).get("topic") || store.get("libView", "recentes");
14
14
  let sortBy = store.get("libSort", "editada");
15
- let dialogSession = 0;
15
+ let dialogSession = 0, profileSaved = false;
16
16
  let dialogKeyboard = null, dialogOrigin = null;
17
17
 
18
18
  function hydrate(root = document) {
@@ -49,6 +49,8 @@
49
49
  const r = await fetch("/whoami");
50
50
  const w = r.ok ? await r.json() : null;
51
51
  if (w && w.logged_in) name = w.username || w.email || name;
52
+ // grava o nome do portal na biblioteca (uma vez por página): quem administra reconhece a pessoa em "Enviar cópia para…"
53
+ if (w && w.logged_in && !profileSaved) { profileSaved = true; api("api/library/me", { name: w.username || "", email: w.email || "" }).catch(() => {}); }
52
54
  } catch {}
53
55
  }
54
56
  if (name) {
@@ -237,6 +239,7 @@
237
239
  <div class="sep"></div>
238
240
  <button class="mi" data-a="rename">Renomear</button>
239
241
  <button class="mi" data-a="dup">${ic("copy")}Duplicar</button>
242
+ ${data.admin ? `<button class="mi" data-a="send">${ic("send")}Enviar cópia para…<small>outra pessoa</small></button>` : ""}
240
243
  ${others.map((t) => `<button class="mi" data-move="${esc(t.id)}"><span class="dot" style="background:${esc(t.color)}"></span>Mover para ${esc(t.name)}</button>`).join("")}
241
244
  <div class="sep"></div>
242
245
  <button class="mi" data-dl="tudo">${ic("download")}Baixar tudo<small>PowerPoint, PDF e roteiro</small></button>
@@ -254,6 +257,7 @@
254
257
  if (a === "open" || a === "present") return openEditor(id, a === "present");
255
258
  if (a === "rename") return nameDialog("Renomear apresentação", d.title, (title) => act(() => api("api/library/decks/rename", { id, title }), "Renomeada"));
256
259
  if (a === "dup") return act(() => api("api/library/decks/duplicate", { id }), "Cópia criada");
260
+ if (a === "send") return sendDialog(d);
257
261
  if (a === "trash") return act(() => api("api/library/decks/trash", { id }), `Na lixeira — dá para restaurar por ${data.trashDays === 1 ? "um dia" : `${data.trashDays || 30} dias`}`);
258
262
  });
259
263
  m.querySelectorAll("[data-move]").forEach((b) => b.onclick = () => { closeMenus(); act(() => api("api/library/decks/move", { id, topic: b.dataset.move }), `Movida para ${topicOf(b.dataset.move).name}`); });
@@ -391,6 +395,31 @@
391
395
  box.querySelector("#dlg-name").onkeydown = (e) => { if (e.key === "Enter") go(); };
392
396
  });
393
397
  }
398
+ // Enviar cópia para… (só quem administra o servidor): a apresentação vai como NOVA para a biblioteca da outra pessoa,
399
+ // com "(versão revisada)" no título; a dela não é tocada. Sem a conversa, as versões e os avisos da criação.
400
+ function sendDialog(d) {
401
+ dialog(`<h3>Enviar cópia para…</h3>
402
+ <p class="hint">"${esc(d.title)}" chega como apresentação nova, com "(versão revisada)" no título. Nada do que a pessoa já tem é substituído. A conversa com a IA, as versões e os avisos da criação não vão junto.</p>
403
+ <div class="send-users" id="send-users"><p class="hint">Carregando…</p></div>
404
+ <div class="row"><button class="lib-btn ghost" data-cancel>Cancelar</button><button class="lib-btn primary" id="dlg-ok" disabled>Enviar</button></div>`, async (box, close, isCurrent) => {
405
+ let chosen = null;
406
+ const list = box.querySelector("#send-users"), okBtn = box.querySelector("#dlg-ok");
407
+ try {
408
+ const { users } = await api("api/library/users");
409
+ if (!isCurrent()) return;
410
+ list.innerHTML = users.length ? users.map((u) => `<label class="send-user"><input type="radio" name="send-user" value="${esc(u.id)}">
411
+ <span><b>${esc(u.name || u.id)}</b>${u.email ? ` <small>${esc(u.email)}</small>` : ""}
412
+ <small>${u.name ? `${esc(u.id)} · ` : ""}${plural(u.decks)}${u.recent ? ` · mais recente: ${esc(u.recent)}` : ""}</small></span></label>`).join("")
413
+ : `<p class="hint">Ninguém mais tem biblioteca neste servidor ainda.</p>`;
414
+ list.querySelectorAll("input").forEach((r) => r.onchange = () => { chosen = users.find((u) => u.id === r.value); okBtn.disabled = false; });
415
+ } catch (e) { list.innerHTML = `<p class="hint">Não deu para listar as pessoas: ${esc(e.message)}</p>`; }
416
+ okBtn.onclick = () => {
417
+ if (!chosen) return;
418
+ close();
419
+ act(() => api("api/library/decks/send", { id: d.id, user: chosen.id }), `Enviada para ${chosen.name || chosen.id} como apresentação nova`);
420
+ };
421
+ });
422
+ }
394
423
  function topicDialog(t) {
395
424
  let color = t?.color || COLORS[data.topics.length % COLORS.length];
396
425
  dialog(`<h3>${t ? "Tópico" : "Novo tópico"}</h3><label>Nome</label><input type="text" id="dlg-name" value="${esc(t?.name || "")}" placeholder="Ex.: Palestras, Trabalho, Aulas">
@@ -190,6 +190,11 @@ export function createStudioServer(deckPath = null, opts = {}) {
190
190
  // servidor multiusuário, só quem está em --agentes / SAGADECK_AGENTES (o comando roda NA MÁQUINA DO SERVIDOR); os
191
191
  // outros usam tudo (gerar slides, conversar, imagens), só sem comandos, e a IA deles nem sabe que existem.
192
192
  const AGENTS = new Set([].concat(opts.agentUsers ?? String(process.env.SAGADECK_AGENTES || "").split(",")).map((u) => String(u).trim()).filter(Boolean));
193
+ // Quem administra o servidor multiusuário (--admins / SAGADECK_ADMINS; sem lista, os de --agentes, que já mandam
194
+ // na máquina do servidor): envia cópia de uma apresentação para a biblioteca de outra pessoa (Enviar cópia para…).
195
+ const adminList = [].concat(opts.adminUsers ?? String(process.env.SAGADECK_ADMINS || "").split(",")).map((u) => String(u).trim()).filter(Boolean);
196
+ const ADMINS = adminList.length ? new Set(adminList) : AGENTS;
197
+ const isAdmin = (W) => !!opts.multiuser && !!W?.user && ADMINS.has(W.user);
193
198
  function commandsAllowed(req, W) {
194
199
  if (!W) return false;
195
200
  return opts.multiuser ? !!W.user && AGENTS.has(W.user) : !apiBlocked(req);
@@ -1235,7 +1240,7 @@ Responda só com JSON: {"colunas": [{"nome": "…", "tipo": "tempo|categoria|num
1235
1240
  if (download && req.method === 'GET') return exports.download(owner, download[1], res);
1236
1241
  return json(405, { error: 'Método ou endereço de exportação não suportado.' });
1237
1242
  }
1238
- if (pathname.startsWith("/api/library")) return libraryRoutes({ req, res, pathname, url, W, opts, readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS });
1243
+ if (pathname.startsWith("/api/library")) return libraryRoutes({ req, res, pathname, url, W, opts, admin: isAdmin(W), usersRoot: path.join(libraryRoot, "usuarios"), readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS });
1239
1244
 
1240
1245
  // Baixar o deck aberto: .sagadeck (YAML + imagens, CSS, widgets), PowerPoint, PDF ou roteiro
1241
1246
  const exportKind = (pathname.match(/^\/api\/export\/(sagadeck|pptx|pdf|roteiro|tudo|estudo|estudo-html)$/) || [])[1];