sagadeck 1.11.2 → 1.11.4
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 +85 -8
- package/package.json +1 -1
- package/src/build.js +2 -1
- package/src/export/browser.js +17 -5
- package/src/export/recent-html.js +24 -0
- 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/exporting.js +3 -2
- 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,85 @@ 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.4: CI mais rápida, implantação por roteiro, diário em dia — 23/10/2026 (branch `claude/melhorias-infra`)
|
|
12
|
+
|
|
13
|
+
- **Antivírus do Windows segurava a abertura do deck** (exportação, fotos, PDF, PPTX e conferência): o Node lia o
|
|
14
|
+
HTML recém-gravado só para ver se tinha mapa, e no Windows essa primeira leitura de um HTML grande fica parada 15 a
|
|
15
|
+
20 s (o Chrome abre na hora). Agora quem grava usa `writeDeckHTML` (`src/export/recent-html.js`: build, exportação
|
|
16
|
+
do Studio) e a abertura usa o texto da memória; sem ele, quem lê é o Chrome, e o Node só relê se houver mapa (o
|
|
17
|
+
mapa abre por endereço local, servido da memória). `lengths.test.js` caiu de 84 s para 18 s nesta máquina; cada
|
|
18
|
+
exportação no Windows de quem usa deixa de esperar. Teste: `test/open-deck.test.js` (conta as leituras do arquivo
|
|
19
|
+
pelo Node; falha com o código antigo).
|
|
20
|
+
- **CI em 3 partes por sistema**, ao mesmo tempo: `scripts/test-parte.mjs` reparte os arquivos pelo peso (os lentos
|
|
21
|
+
numa tabela lá dentro; dividir pela ordem dos arquivos dava 22 min × 13 min). Os checks exigidos na main continuam
|
|
22
|
+
`test (ubuntu-latest, 24.x)` e `test (windows-latest, 24.x)`: o job `test` de cada sistema confere pela API do run
|
|
23
|
+
que as 3 partes dele passaram. Antes: 26 min no Ubuntu e 37 no Windows por PR. Teste: `test/test-parte.test.js`
|
|
24
|
+
(todo arquivo em exatamente uma parte, partes equilibradas, pesos só de arquivos que existem).
|
|
25
|
+
- **Implantação no Oracle por roteiro**: `node scripts/implantar-oracle.mjs` (dev primeiro, estável só se o dev subiu
|
|
26
|
+
na versão certa; git como root; npm ci só quando o lock muda além do número da versão; confere `/api/instance`).
|
|
27
|
+
Hoje à mão o git sem sudo falhou calado e o npm ci rodou sem precisar. Teste: `test/implantar-oracle.test.js` roda o
|
|
28
|
+
roteiro de verdade com git real e sudo/systemctl/curl/npm de mentira (no Linux; conferido no próprio Oracle).
|
|
29
|
+
- `SAGADECK_ADMINS=305cc6c7416383c8` (a Narumi) em `/etc/sagadeck.env` no Oracle: vale para os dois serviços.
|
|
30
|
+
- Diário: as pendências "Falta: PR…" do Painel Conferência (#204), do Pedido da criação (#203), da Conferência das
|
|
31
|
+
contas (#201, 1.11.0) e da Frente E (Frente F) viraram "Concluído".
|
|
32
|
+
|
|
33
|
+
## 1.11.3 — 23/10/2026 (branch `claude/versao-1.11.3`)
|
|
34
|
+
|
|
35
|
+
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).
|
|
36
|
+
Primeira versão múltipla de 3: `node scripts/ai-live-ci.mjs --na-release` rodou antes de publicar, 27 de 27 de primeira
|
|
37
|
+
(16 min). Suíte completa: 1.159 testes, uma falha intermitente (teste de fórmulas esperava 900 ms fixos pelo
|
|
38
|
+
salvamento; passou 3 de 3 isolado; agora espera o arquivo salvo). No Oracle, a Narumi entra em `--admins` do
|
|
39
|
+
sagadeck e do sagadeck-dev para ver o Enviar cópia para….
|
|
40
|
+
|
|
41
|
+
## Corrigir tudo com IA no painel Conferência — 23/10/2026 (branch `claude/jovial-hellman-4b4379`)
|
|
42
|
+
|
|
43
|
+
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
|
|
44
|
+
resolveu tudo quando recebeu um pedido único ("corrija os avisos da criação"). O cartão da biblioteca já mostrava
|
|
45
|
+
"N a conferir"; faltava o atalho.
|
|
46
|
+
- Revisar › Conferência ganhou **Corrigir tudo com IA** no cabeçalho: um pedido só ao chat com todas as pendências que
|
|
47
|
+
ainda valem (sem as ignoradas e sem os avisos de "o slide mudou"), agrupadas por slide ("Slide 2:", "Deck todo:"),
|
|
48
|
+
cada uma com o `fix` do item. O servidor monta (`conferenceBatch` em `src/studio/conference.js`, rota
|
|
49
|
+
`POST /api/conferencia/corrigir-tudo`) e marca como `enviado` de uma vez (`decideItems`) o que não se confere
|
|
50
|
+
sozinho; contas, contas do material e slides `derivado` vão com `contas: [n]` e saem do painel pela conferência
|
|
51
|
+
depois da resposta, como no botão por item.
|
|
52
|
+
- O botão fica desabilitado enquanto a IA responde; o painel se atualiza no fim do pedido (`sagadeck:chat-done`).
|
|
53
|
+
Testes: `ai.test.js` (agrupamento, aviso que mudou fora, contas, enviados, decisão em lote) e `studio-ai.test.js`
|
|
54
|
+
(três pendências, clique, um pedido só ao modelo com as três, `contas: [2]`, botão desabilitado durante a resposta,
|
|
55
|
+
deck salvo corrigido, painel vazio, zero erros JS). O teste do Studio falha sem o botão. `npm test`: 1.153 testes,
|
|
56
|
+
1.122 aprovados, 31 condicionais pulados, zero falhas.
|
|
57
|
+
|
|
58
|
+
## Enviar cópia para outra pessoa — 23/10/2026 (branch `claude/cranky-bell-62ec04`)
|
|
59
|
+
|
|
60
|
+
A versão revisada da aula da Mary foi posta na biblioteca dela à mão, por SSH como root (copiar a pasta, trocar o
|
|
61
|
+
título, `chown`). Agora quem administra o servidor multiusuário faz isso pelo navegador: menu do cartão › **Enviar cópia
|
|
62
|
+
para…** › escolhe a pessoa. Chega como apresentação NOVA, no tópico de mesmo nome, com "(versão revisada)" no título
|
|
63
|
+
(sem dobrar o sufixo) e nome livre (nunca por cima da dela). Vai a pasta inteira (imagens, `contexto/`, `geracao.json`);
|
|
64
|
+
ficam de fora `.sagadeck/conversa.json`, `.sagadeck/versoes/`, `.sagadeck/avaliacao-geracao.json`, `.sagadeck/lixeira/`
|
|
65
|
+
e a conversa antiga `<deck>.conversa.json` (`sendDeckTo` em `src/library.js`; `copyRetry` ganhou `skip`).
|
|
66
|
+
- Quem administra: `--admins=…` / `SAGADECK_ADMINS`; sem lista, os de `--agentes` (já mandam na máquina). Rotas
|
|
67
|
+
`GET /api/library/users` e `POST /api/library/decks/send` respondem 403 para os outros; destino só pasta existente de
|
|
68
|
+
`usuarios/` (nada de `../`, nem a própria). O servidor registra no log quem enviou o quê para quem.
|
|
69
|
+
- O id do portal não diz quem é: a biblioteca grava o username/e-mail do `/whoami` em `usuarios/<id>/.perfil.json` ao
|
|
70
|
+
abrir (`POST /api/library/me`); a lista mostra nome, e-mail, id, quantas apresentações e a mais recente.
|
|
71
|
+
- Arquivos criados pelo próprio processo do Studio: o dono já é o usuário do serviço, sem `chown`.
|
|
72
|
+
- Testes: `library.test.js` (cópia completa, exclusões, sufixo, nunca sobrescreve) e `library-ui.test.js` (admin envia
|
|
73
|
+
pela tela duas vezes; Mary não vê o item e leva 403; destino inválido dá 400; zero erros de JavaScript).
|
|
74
|
+
- Falta: no Oracle, conferir se a Narumi está em `--agentes` ou ligar `--admins` no serviço depois do deploy.
|
|
75
|
+
|
|
76
|
+
## Testes com o modelo de verdade a cada 3 versões — 23/10/2026 (branch `claude/testes-ao-vivo`)
|
|
77
|
+
|
|
78
|
+
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
|
|
79
|
+
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,
|
|
80
|
+
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
|
|
81
|
+
se corrige antes de publicar. Regra no `CLAUDE.md`.
|
|
82
|
+
- `scripts/ai-live-ci.mjs`: roda a bateria, repete uma vez o que falhar (instável ≠ quebrado), resume em Markdown;
|
|
83
|
+
`--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.
|
|
84
|
+
- `.github/workflows/ai-live.yml`: só pelo botão (Run workflow, com filtro opcional); usa o segredo
|
|
85
|
+
`SAGADECK_IA_JSON` (conteúdo de um ia.json) e, sem ele, avisa e termina verde. O segredo não foi cadastrado: chave é
|
|
86
|
+
da Narumi (`gh secret set SAGADECK_IA_JSON < ~/.sagadeck/ia.json`, se ela quiser o botão funcionando).
|
|
87
|
+
- Testes em `ai-live-ci.test.js`; o roteiro rodou de verdade com filtro (contas e refinamento): 5 de 5.
|
|
88
|
+
|
|
89
|
+
1.11.2 — 23/10/2026 (branch `claude/ritmo-da-geracao`)
|
|
12
90
|
|
|
13
91
|
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
|
|
14
92
|
código de antes (`3c18855`): "refinamento" é variação do modelo (2 de 3 nos dois); "deck variado" e "sem falar de
|
|
@@ -160,7 +238,7 @@ Testes: `ai.test.js` (itens, ignorar, recordMath, Conferir de novo com e sem IA)
|
|
|
160
238
|
usado como a pessoa usaria: selo, Conferir de novo, Ignorar, Corrigir com IA numa conta e num slide da IA,
|
|
161
239
|
conferindo deck salvo, relatório e o que foi ao modelo; zero erros JS). Ícones Lucide novos: list-checks,
|
|
162
240
|
calculator, hash, image-off. Suíte completa: `npm test`, 1.116 testes, 1.087 aprovados, 29 condicionais pulados, zero falhas.
|
|
163
|
-
|
|
241
|
+
Concluído: entrou no PR #204 (depois do #201) e saiu na 1.11.0.
|
|
164
242
|
|
|
165
243
|
## Pedido da criação gravado — 22/10/2026 (branch `claude/pedido-da-criacao-0d34bc`, sobre `claude/conferir-contas`)
|
|
166
244
|
|
|
@@ -178,7 +256,7 @@ pasta inteira; o pacote `.sagadeck` agora leva `.sagadeck/geracao.json`.
|
|
|
178
256
|
Testes: `storage.test.js` (biblioteca com anexo; segredos e `anteriores`), `library-ui.test.js` (pedido guiado
|
|
179
257
|
digitado; só com anexo grava o texto padrão marcado, duplicar e o pacote levam o arquivo, zero erros JS),
|
|
180
258
|
`studio-ai.test.js` (Deck com IA do editor e criação pelo chat com docx). Conferido que falham sem a gravação.
|
|
181
|
-
|
|
259
|
+
Concluído: entrou no PR #203 (depois do #201) e saiu na 1.11.0.
|
|
182
260
|
|
|
183
261
|
## Modelo para conferir — 22/10/2026 (branch `claude/modelo-por-etapa-d74f41`, parte de `claude/conferir-contas`)
|
|
184
262
|
|
|
@@ -236,9 +314,8 @@ erros que ninguém tinha visto (velocidades da tabela do slide 26). A correção
|
|
|
236
314
|
corrigir o material" da pessoa vale sobre a regra da autoria.
|
|
237
315
|
Suíte completa: `npm test`, 1.108 testes, 1.079 aprovados, 29 condicionais pulados, zero falhas. Studio local na
|
|
238
316
|
3517 rodando `c65772e` a partir desta worktree, para validação dela.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
ela autorizou caprichar), corrija os avisos da criação e melhore a aula. A correção é da IA, não à mão.
|
|
317
|
+
Concluído: entrou no PR #201 e saiu na 1.11.0 (Oracle e npm); a IA do sagadeck corrigiu o deck da conta da Mary pelo
|
|
318
|
+
chat, e a versão revisada foi para a pasta dela como outra apresentação.
|
|
242
319
|
|
|
243
320
|
## UX para programação e APIs com cURL — 12/10/2026 (branch `codex/ux-dev-slides`, versão 1.10.0)
|
|
244
321
|
|
|
@@ -2186,8 +2263,8 @@ raciocínio, a mesma conferência sai em 1–2 s e acha o que importa (título r
|
|
|
2186
2263
|
"a partir de um arquivo" só com o anexo), `studio-themes.test.js` (prévia sem gravar, clique aplica),
|
|
2187
2264
|
`studio.test.js` (Baixar tudo: zip com pptx com notas, pdf e roteiro), `storage.test.js` (gerado na biblioteca).
|
|
2188
2265
|
|
|
2189
|
-
|
|
2190
|
-
|
|
2266
|
+
Concluído depois, na Frente F (28/09/2026, logo acima): modo simples e fontes embutidas, sem o Google. O modo
|
|
2267
|
+
simples saiu mais tarde (a faixa mostra tudo).
|
|
2191
2268
|
|
|
2192
2269
|
## Frente D (preguiçoso): trocar o tema muda o arranjo dos slides de conteúdo — 28/09/2026
|
|
2193
2270
|
|
package/package.json
CHANGED
package/src/build.js
CHANGED
|
@@ -19,6 +19,7 @@ import { esc, notesHTML, plain, md, INLINE_MATH } from "./markup.js";
|
|
|
19
19
|
import { normalizeSpec } from "./fiscal/normalize.js";
|
|
20
20
|
import { readRecordings } from "./api-client.js";
|
|
21
21
|
import { explorationStates } from './exploration.js';
|
|
22
|
+
import { writeDeckHTML } from "./export/recent-html.js";
|
|
22
23
|
import { calcModel } from './lessons.js';
|
|
23
24
|
import { loadMapConfig, mapaFile, tilesForDeck } from "./map-config.js";
|
|
24
25
|
|
|
@@ -257,7 +258,7 @@ export function buildFile(file, outFile) {
|
|
|
257
258
|
const spec = loadSpec(file);
|
|
258
259
|
const r = buildHTML(spec);
|
|
259
260
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
260
|
-
|
|
261
|
+
writeDeckHTML(outFile, r.html); // a conferência e a exportação abrem em seguida sem reler (export/recent-html.js)
|
|
261
262
|
return { ...r, spec, outFile };
|
|
262
263
|
}
|
|
263
264
|
|
package/src/export/browser.js
CHANGED
|
@@ -4,6 +4,8 @@ import http from "node:http";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { chromium } from "playwright-core";
|
|
7
|
+
import { recentHTML } from "./recent-html.js";
|
|
8
|
+
export { writeDeckHTML } from "./recent-html.js";
|
|
7
9
|
|
|
8
10
|
const CANDIDATES = [
|
|
9
11
|
process.env.SAGADECK_BROWSER,
|
|
@@ -24,10 +26,12 @@ export function findBrowser() {
|
|
|
24
26
|
|
|
25
27
|
// Deck com mapa abre por um endereço local (http://127.0.0.1), não como arquivo: o servidor de tiles do OpenStreetMap
|
|
26
28
|
// pede que o navegador diga de onde vem o pedido, e página aberta do disco não diz. Só lê a pasta do deck.
|
|
27
|
-
|
|
29
|
+
// main: { "deck.html": texto } serve o deck da memória (sem o Node reler o arquivo; veja writeDeckHTML)
|
|
30
|
+
async function serveDir(dir, main = {}) {
|
|
28
31
|
const server = http.createServer((req, res) => {
|
|
29
32
|
const rel = decodeURIComponent(new URL(req.url, "http://x").pathname).replace(/^\/+/, "");
|
|
30
33
|
const file = path.resolve(dir, rel);
|
|
34
|
+
if (req.method === "GET" && Object.hasOwn(main, rel)) { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); return res.end(main[rel]); }
|
|
31
35
|
if (req.method !== "GET" || !file.startsWith(path.resolve(dir)) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); return res.end(); }
|
|
32
36
|
res.writeHead(200, { "Content-Type": file.endsWith(".html") ? "text/html; charset=utf-8" : "application/octet-stream" });
|
|
33
37
|
fs.createReadStream(file).pipe(res);
|
|
@@ -41,15 +45,23 @@ export const settleSlide = (page) => page.evaluate(() => window.SagaMap?.settled
|
|
|
41
45
|
|
|
42
46
|
export async function openDeck(htmlFile, { scale = 1 } = {}) {
|
|
43
47
|
const browser = await chromium.launch({ executablePath: findBrowser() });
|
|
44
|
-
const hasMap = fs.readFileSync(htmlFile, "utf8").includes('class="map-box');
|
|
45
|
-
const server = hasMap ? await serveDir(path.dirname(htmlFile)) : null;
|
|
46
|
-
if (server) { const close = browser.close.bind(browser); browser.close = async () => { await close(); server.close(); }; }
|
|
47
48
|
try {
|
|
48
49
|
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: scale });
|
|
49
50
|
const errors = [];
|
|
50
51
|
page.on("pageerror", (e) => errors.push(e.message));
|
|
51
52
|
page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
|
|
52
|
-
|
|
53
|
+
const fileURL = pathToFileURL(htmlFile).href + "?export=1";
|
|
54
|
+
// tem mapa? Pelo texto na memória; sem ele, quem lê o arquivo é o Chrome, e o Node só relê se houver mapa
|
|
55
|
+
let html = recentHTML(htmlFile), hasMap;
|
|
56
|
+
if (html != null) hasMap = html.includes('class="map-box');
|
|
57
|
+
else { await page.goto(fileURL); hasMap = await page.evaluate(() => !!document.querySelector(".map-box")); }
|
|
58
|
+
if (hasMap) {
|
|
59
|
+
const name = path.basename(htmlFile);
|
|
60
|
+
const server = await serveDir(path.dirname(htmlFile), { [name]: html ?? fs.readFileSync(htmlFile) });
|
|
61
|
+
const close = browser.close.bind(browser); browser.close = async () => { await close(); server.close(); };
|
|
62
|
+
errors.length = 0;
|
|
63
|
+
await page.goto(`http://127.0.0.1:${server.address().port}/${encodeURIComponent(name)}?export=1`);
|
|
64
|
+
} else if (html != null) await page.goto(fileURL);
|
|
53
65
|
await page.waitForFunction(() => window.sagadeck && window.sagadeck.cur >= 0 && document.fonts.status === "loaded");
|
|
54
66
|
// a janela com o tamanho do slide (deck.aspect: 4:3 → 1920 × 1440): foto, PDF e PPTX sem faixa nem corte
|
|
55
67
|
const size = await page.evaluate(() => window.sagadeck.size || { w: 1920, h: 1080 });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// HTML que este processo acabou de gravar (build, exportação, conferência). No Windows, o antivírus segura por 15 a
|
|
2
|
+
// 20 s a primeira leitura pelo Node de um HTML grande recém-gravado (o Chrome abre na hora): a abertura no Chrome
|
|
3
|
+
// (browser.js: openDeck) usa o texto da memória em vez de reler o arquivo. Vale só enquanto o arquivo no disco é o
|
|
4
|
+
// mesmo (tamanho e data); guarda só os últimos (o Studio fica aberto o dia todo). Módulo à parte, sem o Playwright,
|
|
5
|
+
// para o build gravar por aqui sem carregar o navegador.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const RECENT = new Map(); // caminho -> { html, size, mtimeMs }
|
|
10
|
+
|
|
11
|
+
export function writeDeckHTML(file, html) {
|
|
12
|
+
fs.writeFileSync(file, html);
|
|
13
|
+
const key = path.resolve(file), st = fs.statSync(file);
|
|
14
|
+
RECENT.delete(key);
|
|
15
|
+
RECENT.set(key, { html, size: st.size, mtimeMs: st.mtimeMs });
|
|
16
|
+
while (RECENT.size > 4) RECENT.delete(RECENT.keys().next().value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function recentHTML(file) {
|
|
20
|
+
const r = RECENT.get(path.resolve(file));
|
|
21
|
+
if (!r) return null;
|
|
22
|
+
const st = fs.statSync(file, { throwIfNoEntry: false });
|
|
23
|
+
return st && st.size === r.size && st.mtimeMs === r.mtimeMs ? r.html : null;
|
|
24
|
+
}
|
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
|
package/src/studio/exporting.js
CHANGED
|
@@ -9,6 +9,7 @@ import os from "node:os";
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import JSZip from "jszip";
|
|
11
11
|
import { buildHTML } from "../build.js";
|
|
12
|
+
import { writeDeckHTML } from "../export/recent-html.js";
|
|
12
13
|
import { THEMES } from "../themes.js";
|
|
13
14
|
import { loadPreferences } from "../preferences.js";
|
|
14
15
|
import { packDeck, EXTENSION, MIME } from "../package.js";
|
|
@@ -46,7 +47,7 @@ export async function generateExport(kind, spec, name, { notes = true, audience,
|
|
|
46
47
|
if (["pptx","pdf","tudo"].includes(kind)) spec=expandApiCollections(spec);
|
|
47
48
|
const r = buildHTML(spec);
|
|
48
49
|
const htmlFile = path.join(tmp, "deck.html");
|
|
49
|
-
|
|
50
|
+
writeDeckHTML(htmlFile, r.html);
|
|
50
51
|
const errors = [];
|
|
51
52
|
const make = async (what) => {
|
|
52
53
|
const progress = event => onProgress({ ...event, format: what });
|
|
@@ -60,7 +61,7 @@ export async function generateExport(kind, spec, name, { notes = true, audience,
|
|
|
60
61
|
// Preferências › Exportação: tema escuro com par claro (manual-noite → manual) sai claro no PDF, bom para imprimir
|
|
61
62
|
const light = loadPreferences().exportacao.pdfClaro === true ? lightVariant(spec) : null;
|
|
62
63
|
let file = htmlFile;
|
|
63
|
-
if (light) { file = path.join(tmp, "deck-claro.html");
|
|
64
|
+
if (light) { file = path.join(tmp, "deck-claro.html"); writeDeckHTML(file, buildHTML(light).html); }
|
|
64
65
|
await pdf(file, out, { onProgress: progress });
|
|
65
66
|
} else if (what === "estudo" || what === "estudo-html") {
|
|
66
67
|
const { shots } = await import("../export/shots.js");
|
|
@@ -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];
|