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 +2 -0
- package/bin/sagadeck.js +3 -1
- package/docs/ANDAMENTO.md +67 -1
- package/package.json +1 -1
- package/src/ai/deck-ai.js +1 -1
- package/src/fs-retry.js +5 -2
- package/src/library.js +30 -1
- package/src/studio/conference-routes.js +14 -2
- package/src/studio/conference.js +26 -4
- package/src/studio/library-routes.js +51 -2
- package/src/studio/public/conference-ui.js +17 -3
- package/src/studio/public/library.css +7 -0
- package/src/studio/public/library.js +30 -1
- package/src/studio/server.js +6 -1
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
|
|
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.
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = [];
|
package/src/studio/conference.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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)
|
|
4
|
-
// do servidor (src/studio/conference.js); o app passa o que o
|
|
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">
|
package/src/studio/server.js
CHANGED
|
@@ -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];
|