sagadeck 1.7.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +55 -4
  2. package/SKILL.md +10 -3
  3. package/bin/sagadeck.js +23 -0
  4. package/docs/ANDAMENTO.md +483 -1
  5. package/docs/PLANO-MELHORIAS.md +180 -0
  6. package/docs/REFERENCIA.md +184 -105
  7. package/package.json +34 -2
  8. package/src/ai/commands.js +2 -2
  9. package/src/ai/deck-ai.js +14 -5
  10. package/src/ai/ia-check.js +127 -0
  11. package/src/ai/ia-config.js +2 -2
  12. package/src/ai/llm.js +5 -1
  13. package/src/ai/map-analysis-run.js +119 -0
  14. package/src/ai/map-data.js +221 -0
  15. package/src/ai/quality.js +1 -1
  16. package/src/ai/video-generation.js +70 -25
  17. package/src/build.js +51 -15
  18. package/src/elements.js +39 -1
  19. package/src/export/browser.js +23 -1
  20. package/src/export/pptx.js +9 -3
  21. package/src/export/shots.js +10 -3
  22. package/src/fiscal/normalize.js +19 -0
  23. package/src/fs-retry.js +37 -2
  24. package/src/import/office.js +5 -4
  25. package/src/layouts.js +114 -99
  26. package/src/lessons.js +1 -1
  27. package/src/library.js +7 -7
  28. package/src/map-analysis.js +109 -0
  29. package/src/map-config.js +89 -0
  30. package/src/map-routing.js +82 -0
  31. package/src/map-services.js +60 -0
  32. package/src/map.js +274 -0
  33. package/src/mcp/server.js +17 -0
  34. package/src/motion.js +20 -6
  35. package/src/purpose.js +1 -1
  36. package/src/runtime/base.css +105 -329
  37. package/src/runtime/fit.js +3 -2
  38. package/src/runtime/fonts/amatic-sc.css +3 -0
  39. package/src/runtime/fonts/bebas-neue.css +2 -0
  40. package/src/runtime/fonts/cabin-sketch.css +2 -0
  41. package/src/runtime/fonts/fraunces.css +3 -0
  42. package/src/runtime/fonts/gelasio.css +3 -0
  43. package/src/runtime/fonts/ibm-plex-mono.css +4 -0
  44. package/src/runtime/fonts/index.json +10 -1
  45. package/src/runtime/fonts/itim.css +2 -0
  46. package/src/runtime/fonts/montserrat.css +2 -0
  47. package/src/runtime/fonts/space-grotesk.css +2 -0
  48. package/src/runtime/map-background.js +91 -0
  49. package/src/runtime/map.js +266 -0
  50. package/src/runtime/runtime.js +81 -100
  51. package/src/runtime/skins/caderno.css +33 -0
  52. package/src/runtime/skins/cartaz.css +27 -0
  53. package/src/runtime/skins/infografico.css +29 -0
  54. package/src/runtime/skins/lousa.css +22 -0
  55. package/src/runtime/skins/musgo.css +42 -0
  56. package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
  57. package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
  58. package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
  59. package/src/runtime/vendor/geo-contornos.json +1 -0
  60. package/src/runtime/vendor/leaflet.css +661 -0
  61. package/src/runtime/vendor/leaflet.js +6 -0
  62. package/src/runtime/vendor/maplibre-gl.css +1 -0
  63. package/src/runtime/vendor/maplibre-gl.js +825 -0
  64. package/src/runtime/vendor/maplibre-leaflet.js +234 -0
  65. package/src/runtime/vendor/maplibre-worker.js +10 -0
  66. package/src/studio/ai-chat-route.js +20 -0
  67. package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
  68. package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
  69. package/src/studio/command-approval.js +2 -3
  70. package/src/studio/demo-decks.js +6 -2
  71. package/src/studio/export-jobs.js +86 -0
  72. package/src/studio/exporting.js +36 -18
  73. package/src/studio/feature-demos.js +144 -0
  74. package/src/studio/geo-routes.js +138 -0
  75. package/src/studio/ia-routes.js +9 -0
  76. package/src/studio/layout-samples.js +78 -16
  77. package/src/studio/library-routes.js +0 -9
  78. package/src/studio/project.js +2 -1
  79. package/src/studio/public/ai-settings.js +6 -3
  80. package/src/studio/public/app.js +46 -52
  81. package/src/studio/public/explorer.js +23 -8
  82. package/src/studio/public/export-status.js +154 -0
  83. package/src/studio/public/index.html +17 -10
  84. package/src/studio/public/library.css +12 -3
  85. package/src/studio/public/library.html +3 -0
  86. package/src/studio/public/library.js +39 -12
  87. package/src/studio/public/map-data.js +158 -0
  88. package/src/studio/public/map-editor.js +676 -0
  89. package/src/studio/public/map-fields.js +21 -0
  90. package/src/studio/public/map-settings.js +86 -0
  91. package/src/studio/public/slide-form.js +19 -16
  92. package/src/studio/public/style.css +58 -0
  93. package/src/studio/public/ui-icons.js +1 -1
  94. package/src/studio/public/viewers.js +2 -1
  95. package/src/studio/public/visual-editor.js +1 -1
  96. package/src/studio/server.js +34 -9
  97. package/src/studio/snapshot.js +8 -4
  98. package/src/studio/template-collections.js +1 -1
  99. package/src/studio/tour.js +158 -0
  100. package/src/themes.js +154 -0
  101. package/templates/cenario-e-ciencia.yaml +1 -21
  102. package/templates/ensaio-api.yaml +3 -3
  103. package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
  104. package/templates/cenario/imagens/cidade.svg +0 -30
  105. package/templates/cenario/imagens/pessoa.svg +0 -15
@@ -9,6 +9,7 @@ import { writeRecording } from "../api-client.js";
9
9
  import { editDeck, coverDocumentVisuals, checkNumbers } from "../ai/deck-ai.js";
10
10
  import { reviewExperience } from "../ai/quality.js";
11
11
  import { maybeResearch } from "../research/chat-research.js";
12
+ import { mapDataPrompt, runMapData, mapDataInstruction } from "../ai/map-data.js";
12
13
  import { chatErrorResult } from "./errors.js";
13
14
  import { cancellable, respond } from "./ai-response.js";
14
15
  import { prepareDocumentMaterials, takeMaterials, storedDocumentMaterials } from "../ai/document-materials.js";
@@ -164,6 +165,8 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
164
165
  runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
165
166
  reviewCheck: (deck, indices) => reviewExperience(deck, indices, { snapshot: slideSnapshots, onProgress: emit, signal, briefing:prompt }),
166
167
  styles: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? { list: taskWorkspace.library.listStyles(), current: taskWorkspace.spec?.style?.name || null } : null,
168
+ // dados para o mapa (bloco mapa:): só com a pasta da apresentação, que é onde os arquivos ficam
169
+ mapData: P ? mapDataPrompt() : null,
167
170
  ...extra,
168
171
  });
169
172
  result = await editDeck(editOpts());
@@ -192,6 +195,23 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
192
195
  result.actions = [...before.actions, ...(result.actions || [])];
193
196
  }
194
197
  if (result.look?.length) result = { ...result, look: undefined, talk: true, reply: `${result.reply}\n(Já mostrei as imagens duas vezes; me diga o que procurar nelas.)` };
198
+ // A IA pediu dados para o mapa (mapa: [ações]): o Studio busca (OpenStreetMap, web, coordenadas), grava no
199
+ // projeto com a fonte de cada linha e chama de novo com o que gravou; até 2 rodadas. Endereços da pessoa só saem
200
+ // com o sim dela: sem ele, a resposta pergunta e nada é enviado (src/ai/map-data.js).
201
+ for (let round = 1; result.geo?.length && P && round <= 2; round++) {
202
+ const before = result;
203
+ emit({ phase: "step", text: "Buscando os dados para o mapa…" });
204
+ const r = await runMapData(before.geo, { P, onProgress: (t) => emit({ phase: "step", text: t }) });
205
+ signal.throwIfAborted();
206
+ if (r.perguntas.length && !r.feitos.length && !r.falhas.length) { result = { ...before, geo: undefined, talk: true, reply: r.perguntas.join("\n\n"), options: ["Pode enviar", "Não, deixa"] }; break; }
207
+ result = await editDeck(editOpts({
208
+ history: [...history, { role: "user", text: prompt }, { role: "assistant", text: before.reply }],
209
+ instruction: `${mapDataInstruction(r)}${r.perguntas.length ? `\nAinda falta o sim da pessoa para: ${r.perguntas.join(" ")} Pergunte na resposta.` : ""}\nContinue o pedido original: ${prompt}`,
210
+ }));
211
+ signal.throwIfAborted();
212
+ result.actions = [...(before.actions || []), ...r.feitos.map((f) => `Mapa: gravei ${f.arquivo} (${f.linhas} ${f.tipo === "contorno" ? "contorno" : "itens"}${f.fonte ? `; fonte: ${f.fonte}` : ""})`), ...r.falhas.map((x) => `Mapa: não deu (${x})`), ...(result.actions || [])];
213
+ }
214
+ if (result.geo?.length) result = { ...result, geo: undefined, talk: true, reply: P ? `${result.reply}\n(Já busquei os dados duas vezes; me diga o que ainda falta.)` : "Os dados do mapa ficam na pasta da apresentação: abra-a pela biblioteca e peça de novo." };
195
215
  // documento anexado NESTA mensagem e a resposta montou/reescreveu a maior parte do deck (criar a apresentação
196
216
  // do paper pelo chat): a mesma conferência de cobertura da geração. Pedido pontual ("use a figura 3 no slide
197
217
  // 5") não dispara: mexe em poucos slides.
@@ -26,10 +26,9 @@ export async function estimateSubmitCost(command, { commandEnv, W }) {
26
26
  try {
27
27
  const req = typeof command.code === "object" && command.code !== null ? command.code : JSON.parse(String(command.code || "{}"));
28
28
  if (String(command.language || "").toLowerCase() !== "video" || req?.action !== "submit") return null;
29
- const { estimateVideoCost } = await import("../ai/video-generation.js");
29
+ const { estimateVideoCost, videoSetup } = await import("../ai/video-generation.js");
30
30
  const { env } = await commandEnv(W).catch(() => ({ env: {} }));
31
- const key = env.SAGADECK_VIDEO_KEY || env.OPENROUTER_API_KEY || process.env.SAGADECK_VIDEO_KEY || process.env.OPENROUTER_API_KEY;
32
- return await estimateVideoCost({ model: req.model, duration: req.duration, resolution: req.resolution }, { key });
31
+ return await estimateVideoCost({ model: req.model, duration: req.duration, resolution: req.resolution }, { setup: videoSetup({ env: { ...process.env, ...env } }) });
33
32
  } catch { return null; }
34
33
  }
35
34
 
@@ -3,15 +3,19 @@ import { portalDemo } from '../portal-scene.js';
3
3
  import { COLLECTION_NAMES, COLLECTION_ASSETS, collectionDeck } from './template-collections.js';
4
4
  import { LAYOUT_SAMPLES } from './layout-samples.js';
5
5
  import { explorationDemo } from './exploration-demo.js';
6
- export function demoAssets(kind) { return kind === 'lavanda' || kind === 'novidades' ? ['lavanda-cover.jpg'] : COLLECTION_ASSETS[kind] || []; }
6
+ import { tourDeck, TOUR_TITLE } from './tour.js';
7
+ import { featureDemo, FEATURE_DEMO_NAMES } from './feature-demos.js';
8
+ export function demoAssets(kind) { return kind === 'lavanda' || kind === 'novidades' ? ['lavanda-cover.jpg'] : kind === 'conheca' || kind === 'mapas' ? ['tour-mapa-botao.jpg', 'tour-mapa-editor.jpg'] : COLLECTION_ASSETS[kind] || []; }
7
9
  // arquivos do projeto que o modelo traz (fora de imagens/): a planilha e as anotações da demo de novidades
8
10
  export function demoProjectFiles(kind) { return kind === 'novidades' ? ['contexto/vendas.csv', 'contexto/leia-me.md'] : []; }
9
11
  const item=(title,text,extra={})=>({title,text,...extra});
10
12
  const route='flowchart LR\n A([Pedido]):::hi --> B{Dados válidos?}\n B -->|sim| C[Executar tarefa]\n B -.->|não| D[Informar erro]:::vazado\n C ==> E[Guardar resultado]:::em';
11
- export const DEMO_NAMES={cinematica:"Demo — mundos que se transformam",explorar:'Demo — explore uma ideia',lavanda:'Estúdio lavanda',executivo:'Relatório executivo',workshop:'Workshop visual',compacto:'Demo — material de consulta',avancado:'Demo — recursos avançados',diagramas:'Demo — diagramas vivos',novidades:'Demo — novidades',hidraulica:'Aula de hidráulica',algoritmos:'Aula de algoritmos',...COLLECTION_NAMES};
13
+ export const DEMO_NAMES={conheca:TOUR_TITLE,...FEATURE_DEMO_NAMES,cinematica:"Demo — mundos que se transformam",explorar:'Demo — explore uma ideia',lavanda:'Estúdio lavanda',executivo:'Relatório executivo',workshop:'Workshop visual',compacto:'Demo — material de consulta',avancado:'Demo — recursos avançados',diagramas:'Demo — diagramas vivos',novidades:'Demo — novidades',hidraulica:'Aula de hidráulica',algoritmos:'Aula de algoritmos',...COLLECTION_NAMES};
12
14
  export function demoDeck(kind) {
15
+ if(kind==='conheca')return tourDeck();
13
16
  if(kind==='cinematica')return portalDemo();
14
17
  if(kind==='explorar')return explorationDemo();
18
+ const feature=featureDemo(kind);if(feature)return feature;
15
19
  if(Object.hasOwn(COLLECTION_NAMES,kind))return collectionDeck(kind);
16
20
  if(!DEMO_NAMES[kind])throw Error('Modelo não encontrado.');
17
21
  // cada demo com a sua cara (tema, tom e figura da capa): na vitrine, as capas não podem parecer a mesma
@@ -0,0 +1,86 @@
1
+ // Gerações pertencem à pessoa, não à aba. O arquivo fica fora da biblioteca/nuvem por até 24 h,
2
+ // enquanto este Studio estiver ligado. Uma geração por vez limita o uso de Chrome e memória.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { generateExport, exportHeaders } from './exporting.js';
8
+ import { removeRetry } from '../fs-retry.js';
9
+
10
+ export const EXPORT_KINDS = ['sagadeck', 'pptx', 'pdf', 'roteiro', 'tudo', 'estudo', 'estudo-html'];
11
+ const TTL = 24 * 3600_000;
12
+ export function createExportJobs({ generate = generateExport, now = () => Date.now(), ttl = TTL } = {}) {
13
+ const jobs = new Map(), queue = [];
14
+ let active = null, closed = false, dir;
15
+ const visible = j => ({ id: j.id, kind: j.kind, name: j.name, title: j.title, status: j.status,
16
+ progress: j.progress, createdAt: j.createdAt, finishedAt: j.finishedAt || null,
17
+ expiresAt: j.finishedAt ? j.finishedAt + ttl : null, error: j.error || null, warnings: j.warnings || [] });
18
+ const remove = j => { if (j.path) fs.rmSync(j.path, { force: true }); jobs.delete(j.id); };
19
+ function prune() {
20
+ for (const j of jobs.values()) if (j.finishedAt && now() - j.finishedAt >= ttl) remove(j);
21
+ }
22
+ const timer = setInterval(prune, 60_000); timer.unref();
23
+ async function run(j) {
24
+ j.status = 'running';
25
+ try {
26
+ const result = await generate(j.kind, j.spec, j.name, { ...j.options, onProgress: p => { j.progress = { ...j.progress, ...p }; } });
27
+ if (closed) return;
28
+ dir ||= fs.mkdtempSync(path.join(os.tmpdir(), 'sagadeck-export-jobs-'));
29
+ j.path = path.join(dir, j.id);
30
+ fs.writeFileSync(j.path, result.body, { mode: 0o600 });
31
+ j.headers = exportHeaders(result);
32
+ j.name = result.file;
33
+ j.warnings = [...(result.warnings || []), ...(result.missing || []).map(file => `Arquivo não encontrado: ${file}`)];
34
+ j.progress = { ...j.progress, phase: 'ready', completed: j.progress.total };
35
+ j.status = 'ready';
36
+ } catch (e) {
37
+ j.status = 'failed'; j.error = e.message || String(e);
38
+ } finally {
39
+ j.finishedAt = now(); delete j.spec; delete j.options;
40
+ // Mantém as dez exportações recentes da pessoa; não guarda arquivos sem limite.
41
+ const done = [...jobs.values()].filter(x => x.owner === j.owner && x.finishedAt).sort((a, b) => b.finishedAt - a.finishedAt);
42
+ for (const old of done.slice(10)) remove(old);
43
+ }
44
+ }
45
+ function drain() {
46
+ if (closed || active || !queue.length) return;
47
+ const j = queue.shift();
48
+ active = run(j).finally(() => { active = null; setImmediate(drain); });
49
+ }
50
+ return {
51
+ create(owner, kind, spec, name, options = {}) {
52
+ if (closed) throw new Error('O Studio está encerrando. Tente novamente depois de reiniciar.');
53
+ if (!EXPORT_KINDS.includes(kind)) throw new Error('Formato de exportação inválido.');
54
+ prune();
55
+ const key = crypto.createHash('sha256').update(JSON.stringify([kind, spec, name, options])).digest('hex');
56
+ const pending = [...jobs.values()].filter(j => !j.finishedAt);
57
+ const same = pending.find(j => j.owner === owner && j.key === key);
58
+ if (same) return visible(same);
59
+ if (pending.filter(j => j.owner === owner).length >= 4 || pending.length >= 32) throw new Error('Há muitas exportações em andamento. Espere uma delas terminar.');
60
+ const j = { id: crypto.randomUUID(), owner, key, kind, spec: structuredClone(spec), name, title: spec.title || name,
61
+ options: { ...options }, status: 'queued', createdAt: now(), progress: { phase: 'queued', completed: 0, total: spec.slides?.length || 0 } };
62
+ jobs.set(j.id, j); queue.push(j); setImmediate(drain);
63
+ return visible(j);
64
+ },
65
+ list(owner) { prune(); return [...jobs.values()].filter(j => j.owner === owner).sort((a, b) => b.createdAt - a.createdAt).map(visible); },
66
+ download(owner, id, res) {
67
+ prune();
68
+ const j = jobs.get(id);
69
+ const code = !j || j.owner !== owner ? 404 : j.status !== 'ready' ? 409 : 200;
70
+ if (code !== 200) {
71
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
72
+ res.end(JSON.stringify({ error: code === 404 ? 'Exportação não encontrada ou expirada. Gere novamente.' : j.error || 'A exportação ainda não terminou.' }));
73
+ return;
74
+ }
75
+ res.writeHead(200, { ...j.headers, 'Content-Length': fs.statSync(j.path).size });
76
+ const stream = fs.createReadStream(j.path);
77
+ stream.on('error', () => res.destroy());
78
+ res.on('close', () => stream.destroy()); stream.pipe(res);
79
+ },
80
+ async close() {
81
+ closed = true; clearInterval(timer); queue.length = 0;
82
+ await active;
83
+ jobs.clear(); if (dir) removeRetry(dir);
84
+ },
85
+ };
86
+ }
@@ -1,4 +1,5 @@
1
1
  import { expandApiCollections } from '../api-collections.js';
2
+ import { removeRetry } from '../fs-retry.js';
2
3
  import {meetingSpec} from '../meeting.js';
3
4
  // Exportar a apresentação aberta ou da biblioteca: .sagadeck, PowerPoint, PDF, roteiro, material de estudo e "Baixar
4
5
  // tudo". PPTX/PDF/roteiro/estudo usam o Chrome invisível (os mesmos exportadores do CLI), numa pasta temporária.
@@ -21,14 +22,12 @@ export function sendHtml(res,spec,audience){
21
22
 
22
23
  // Gera e envia um arquivo da apresentação. PPTX/PDF/roteiro usam o Chrome invisível (os mesmos
23
24
  // exportadores de "sagadeck pptx | pdf | roteiro"), numa pasta temporária.
24
- export async function sendExport(res, kind, spec, name, { notes = true, inline = false, audience, macFonts = false } = {}) {
25
+ export async function generateExport(kind, spec, name, { notes = true, audience, macFonts = false, onProgress = () => {} } = {}) {
25
26
  spec=meetingSpec(spec,audience);
26
- const cd = (file) => `attachment; filename="${slugify(file.replace(/\.\w+$/, ""))}${path.extname(file)}"; filename*=UTF-8''${encodeURIComponent(file)}`;
27
+ onProgress({ phase: 'preparing', completed: 0, total: spec.slides?.length || 0 });
27
28
  if (kind === "sagadeck") {
28
29
  const { zip, missing } = await packDeck(spec, { baseDir: spec._dir || process.cwd(), name, generator: "sagadeck studio" });
29
- res.writeHead(200, { "Content-Type": MIME, "Content-Disposition": cd(name + EXTENSION), "X-Sagadeck-Missing": encodeURIComponent(JSON.stringify(missing)) });
30
- res.end(zip);
31
- return;
30
+ return { body: zip, file: name + EXTENSION, mime: MIME, missing, warnings: [] };
32
31
  }
33
32
  const kinds = {
34
33
  pptx: { file: `${name}.pptx`, mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation" },
@@ -41,6 +40,7 @@ export async function sendExport(res, kind, spec, name, { notes = true, inline =
41
40
  tudo: { file: `${name}.zip`, mime: "application/zip" },
42
41
  };
43
42
  const k = kinds[kind];
43
+ if (!k) throw new Error('Formato de exportação inválido.');
44
44
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `sagadeck-${kind}-`));
45
45
  try {
46
46
  if (["pptx","pdf","tudo"].includes(kind)) spec=expandApiCollections(spec);
@@ -49,27 +49,31 @@ export async function sendExport(res, kind, spec, name, { notes = true, inline =
49
49
  fs.writeFileSync(htmlFile, r.html);
50
50
  const errors = [];
51
51
  const make = async (what) => {
52
+ const progress = event => onProgress({ ...event, format: what });
53
+ progress({ phase: 'opening', completed: 0, total: r.slidesMeta.length });
52
54
  const out = path.join(tmp, `saida-${what}`);
53
55
  if (what === "pptx") {
54
56
  const { exportPptx } = await import("../export/pptx.js");
55
- errors.push(...(await exportPptx(htmlFile, out, { theme: r.theme, meta: { ...r.meta, slides: r.slidesMeta }, notes, fontFallback: macFonts ? "mac" : undefined })).errors);
57
+ errors.push(...(await exportPptx(htmlFile, out, { theme: r.theme, meta: { ...r.meta, slides: r.slidesMeta }, notes, fontFallback: macFonts ? "mac" : undefined, onProgress: progress })).errors);
56
58
  } else if (what === "pdf") {
57
59
  const { pdf } = await import("../export/shots.js");
58
60
  // Preferências › Exportação: tema escuro com par claro (manual-noite → manual) sai claro no PDF, bom para imprimir
59
61
  const light = loadPreferences().exportacao.pdfClaro === true ? lightVariant(spec) : null;
60
62
  let file = htmlFile;
61
63
  if (light) { file = path.join(tmp, "deck-claro.html"); fs.writeFileSync(file, buildHTML(light).html); }
62
- await pdf(file, out);
64
+ await pdf(file, out, { onProgress: progress });
63
65
  } else if (what === "estudo" || what === "estudo-html") {
64
66
  const { shots } = await import("../export/shots.js");
65
67
  const { estudoHTML, estudoPDF } = await import("../export/estudo.js");
66
- const { files } = await shots(htmlFile, path.join(tmp, "fotos"), { scale: what === "estudo" ? 0.6 : 0.75, jpeg: true });
68
+ const { files } = await shots(htmlFile, path.join(tmp, "fotos"), { scale: what === "estudo" ? 0.6 : 0.75, jpeg: true, onProgress: progress });
69
+ progress({ phase: 'packing', completed: files.length, total: files.length });
67
70
  const html = estudoHTML({ title: r.meta.title, author: r.meta.author, date: spec.date, slidesMeta: r.slidesMeta, shotFiles: files });
68
71
  if (what === "estudo-html") fs.writeFileSync(out, html); else await estudoPDF(html, out);
69
72
  } else {
70
73
  const { shots } = await import("../export/shots.js");
71
74
  const { roteiroPDF } = await import("../export/roteiro.js");
72
- const { files } = await shots(htmlFile, path.join(tmp, "miniaturas"), { scale: 0.5, jpeg: true });
75
+ const { files } = await shots(htmlFile, path.join(tmp, "miniaturas"), { scale: 0.5, jpeg: true, onProgress: progress });
76
+ progress({ phase: 'packing', completed: files.length, total: files.length });
73
77
  await roteiroPDF({ slidesMeta: r.slidesMeta, shotFiles: files, outFile: out, title: r.meta.title, author: r.meta.author, duration: spec.duration });
74
78
  }
75
79
  return fs.readFileSync(out);
@@ -78,20 +82,34 @@ export async function sendExport(res, kind, spec, name, { notes = true, inline =
78
82
  if (kind === "tudo") {
79
83
  const zip = new JSZip();
80
84
  for (const what of ["pptx", "pdf", "roteiro"]) zip.file(kinds[what].file, await make(what));
85
+ onProgress({ phase: 'packing', format: 'tudo', completed: r.slidesMeta.length, total: r.slidesMeta.length });
81
86
  body = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
82
87
  } else body = await make(kind);
83
- res.writeHead(200, {
84
- "Content-Type": k.mime, "Content-Disposition": inline ? "inline" : cd(k.file),
85
- // só o que afeta o arquivo (falha ao exportar, arquivo não encontrado); o fiscal de conteúdo fica no Revisar
86
- "X-Sagadeck-Warnings": encodeURIComponent(JSON.stringify([...(r.warnings || []).filter((w) => /não encontrado/.test(w)), ...errors].slice(0, 20))),
87
- });
88
- res.end(body);
88
+ // só o que afeta o arquivo; o fiscal de conteúdo fica no Revisar
89
+ return { body, file: k.file, mime: k.mime, warnings: [...(r.warnings || []).filter(w => /não encontrado/.test(w)), ...errors].slice(0, 20) };
90
+ } finally {
91
+ removeRetry(tmp);
92
+ }
93
+ }
94
+
95
+ export function exportHeaders(result, inline = false) {
96
+ const cd = file => `attachment; filename="${slugify(file.replace(/\.\w+$/, ''))}${path.extname(file)}"; filename*=UTF-8''${encodeURIComponent(file)}`;
97
+ return { 'Content-Type': result.mime, 'Content-Disposition': inline ? 'inline' : cd(result.file),
98
+ 'Cache-Control': 'private, no-store',
99
+ 'X-Sagadeck-Warnings': encodeURIComponent(JSON.stringify(result.warnings || [])),
100
+ ...(result.missing ? { 'X-Sagadeck-Missing': encodeURIComponent(JSON.stringify(result.missing)) } : {}) };
101
+ }
102
+
103
+ // Links de compartilhamento e CLI seguem recebendo o arquivo na própria resposta.
104
+ export async function sendExport(res, kind, spec, name, options = {}) {
105
+ try {
106
+ const result = await generateExport(kind, spec, name, options);
107
+ res.writeHead(200, exportHeaders(result, options.inline));
108
+ res.end(result.body);
89
109
  } catch (e) {
90
110
  console.error(`[Studio] exportação ${kind} falhou:`, e.message);
91
- res.writeHead(500, { "Content-Type": "application/json" });
111
+ res.writeHead(500, { 'Content-Type': 'application/json' });
92
112
  res.end(JSON.stringify({ error: e.message }));
93
- } finally {
94
- fs.rmSync(tmp, { recursive: true, force: true });
95
113
  }
96
114
  }
97
115
 
@@ -0,0 +1,144 @@
1
+ // Demos de um assunto só (mapas, programação, APIs com curl): curtas, para mostrar um recurso a quem vai usá-lo, sem
2
+ // passar pelo Conheça o SagaDeck inteiro. Reaproveitam os exemplos de layout (layout-samples.js) e as demos de mapa do
3
+ // tour; cada slide diz o que é e a frase para pedir à IA (no rodapé e nas notas). Abrem em prévia, como os modelos.
4
+ import { LAYOUT_INFO, LAYOUT_SAMPLES, LAYOUT_ASK } from "./layout-samples.js";
5
+ import { mapDemos } from "./tour.js";
6
+
7
+ export const FEATURE_DEMO_NAMES = { mapas: "Demo — mapas", programacao: "Demo — programação", apis: "Demo — APIs com curl" };
8
+
9
+ const asked = (ask) => `Peça à IA: “${ask}”`;
10
+ const told = (label, desc, ask) => ({ source: `**${label}**: ${desc}. ${asked(ask)}`, notes: [`${label}: ${desc}.`, asked(ask)].join("\n\n") });
11
+ // o exemplo do layout, com o nome do recurso e a frase para a IA; extra troca o conteúdo quando o exemplo é genérico
12
+ const sample = (name, extra = {}) => {
13
+ const [label, desc] = LAYOUT_INFO[name] || [name, ""];
14
+ return { ...structuredClone(LAYOUT_SAMPLES[name]), layout: name, ...told(label, desc, LAYOUT_ASK[name] || ""), ...extra };
15
+ };
16
+ const cover = (title, subtitle, figure) => ({ layout: "cover", kicker: "SAGADECK · DEMO", title, subtitle, figure });
17
+
18
+ // ---- Mapas: mostrar, calcular, editar
19
+ export function mapasDemo() {
20
+ const [bolhas, regioes, linhas, rota, raio, faixa, comIA, ondeEdita, telaMapa, noStudio] = mapDemos();
21
+ return {
22
+ title: FEATURE_DEMO_NAMES.mapas, theme: "oceano", duration: 15, maxWords: 140, slides: [
23
+ cover("Mapas ==ao vivo==", "Pontos, linhas e áreas sobre o mapa de verdade, com as contas feitas pelo código", { icon: "map", size: 320 }),
24
+ { layout: "statement", kicker: "a ideia", text: "A ==planilha== é a fonte; o mapa é a vista.",
25
+ notes: "Os pontos vêm de uma planilha do projeto (contexto/*.csv), as linhas e áreas de GeoJSON, GPX ou KML. A mesma planilha serve tabela e gráfico. Nada de mapa chapado em imagem: ele é ao vivo." },
26
+ { layout: "section", number: "01", kicker: "mostrar", title: "Dados no ==mapa==", subtitle: "pontos por categoria, bolhas, regiões por valor, linhas e áreas" },
27
+ sample("map"), bolhas, regioes, linhas,
28
+ { layout: "section", number: "02", kicker: "calcular", title: "As ==contas==", subtitle: "rota pelas ruas, alcance, raio e faixa: calculados, nunca desenhados à mão" },
29
+ rota, raio, faixa,
30
+ { layout: "section", number: "03", kicker: "editar", title: "Com a IA e no ==Studio==", subtitle: "a IA busca os dados; o Editar mapa mexe em cada ponto" },
31
+ comIA, ondeEdita, telaMapa, noStudio,
32
+ { layout: "end", title: "Agora, o seu ==mapa==.", subtitle: "Peça à IA: “Ponha no mapa os endereços da minha planilha”." },
33
+ ],
34
+ };
35
+ }
36
+
37
+ // ---- Programação: mostrar código, rodar ao vivo, Git a dois, explicar a arquitetura
38
+ const PEDIDOS = [
39
+ "def total_do_pedido(itens, cupom=None):",
40
+ " subtotal = sum(i.preco * i.qtd for i in itens)",
41
+ " if cupom and cupom.valido():",
42
+ " subtotal -= cupom.desconto(subtotal)",
43
+ " frete = 0 if subtotal >= 200 else 19.90",
44
+ " return round(subtotal + frete, 2)",
45
+ ].join("\n");
46
+ export function programacaoDemo() {
47
+ return {
48
+ title: FEATURE_DEMO_NAMES.programacao, theme: "musgo", duration: 15, maxWords: 140, slides: [
49
+ cover("Código no ==palco==", "Do trecho destacado ao algoritmo rodando, com a plateia acompanhando linha a linha", { motion: { type: "console", caption: "projeto · terminal", scenes: [
50
+ { cmd: "pytest -q", out: ["42 passed in 1.84s"] },
51
+ { cmd: "git log --oneline -3", out: ["a1f9c2e calcula o frete", "77b03d4 aplica o cupom", "3c0e1aa primeira versão"] },
52
+ { cmd: "python -m pedidos --demo", out: ["total: R$ 187,40", "frete: R$ 19,90"] },
53
+ ] } }),
54
+ { layout: "section", number: "01", kicker: "mostrar", title: "Código ==legível== de longe", subtitle: "destaque, linha a linha e material de consulta" },
55
+ sample("code", { title: "Destaque o que ==importa==", filename: "pedidos.py", language: "python", code: PEDIDOS, highlight: [3, 4],
56
+ note: "O cupom só vale se for válido: é aqui que o desconto entra." }),
57
+ sample("codewalk"),
58
+ sample("dossier"),
59
+ { layout: "section", number: "02", kicker: "rodar", title: "Executar na ==frente== de todos", subtitle: "a plateia muda o código e vê o resultado" },
60
+ sample("codelab"),
61
+ sample("algo"),
62
+ { layout: "section", number: "03", kicker: "colaborar", title: "Git e ==revisão==", subtitle: "duas pessoas, um clique por jogada" },
63
+ sample("terminals"),
64
+ sample("duel"),
65
+ sample("turns"),
66
+ { layout: "section", number: "04", kicker: "explicar", title: "A ==arquitetura==", subtitle: "fluxos, sequências e redes que dá para explorar" },
67
+ sample("diagram", { title: "Do pedido ao ==pagamento==", mermaid: "sequenceDiagram\n participant App\n participant API\n participant Pagamentos\n App->>API: POST /pedidos\n API->>Pagamentos: cobra o total\n Pagamentos-->>API: aprovado\n API-->>App: 201 Created" }),
68
+ sample("graphlab"),
69
+ { layout: "end", title: "Agora, o seu ==código==.", subtitle: "Peça à IA: “Explique este arquivo linha a linha, com a saída de cada passo”." },
70
+ ],
71
+ };
72
+ }
73
+
74
+ // ---- APIs com curl: anatomia de um curl, executar de verdade (no ambiente ENSAIO, a API de mentira que o Studio
75
+ // sobe sozinho), polling, streaming, as opções que mais ajudam e o cuidado com o token
76
+ const CURL = [
77
+ 'curl -X POST "https://api.exemplo.com/v1/pedidos" \\',
78
+ ' -H "Authorization: Bearer $API_TOKEN" \\',
79
+ ' -H "Content-Type: application/json" \\',
80
+ ` -d '{"item": "livro", "quantidade": 2}' \\`,
81
+ " -i",
82
+ ].join("\n");
83
+ const ensaio = "Roda no ambiente ENSAIO, a API de mentira que o Studio sobe sozinho: nada sai da sua máquina. Com o seu serviço, troque o endereço em Inserir › Ambientes.";
84
+ const apiSlide = (s) => {
85
+ const t = told("API ao vivo", "Requisição que executa na hora, com o curl equivalente na aba curl", LAYOUT_ASK.api || "Mostre esta chamada ao vivo com o curl");
86
+ return { layout: "api", tab: "curl", ...s, source: t.source, notes: [s.notes, ensaio, t.notes].filter(Boolean).join("\n\n") };
87
+ };
88
+ export function apisDemo() {
89
+ return {
90
+ title: FEATURE_DEMO_NAMES.apis, theme: "wine", duration: 15, maxWords: 140, slides: [
91
+ cover("APIs com ==curl==", "Uma chamada por slide, executada na frente de todos, com o curl pronto para copiar", { motion: { type: "console", caption: "curl · ao vivo", scenes: [
92
+ { cmd: `curl -X POST /v1/echo -d '{"mensagem":"Olá, API"}'`, out: ["200 OK", `{ "metodo": "POST", "recebido": { "mensagem": "Olá, API" } }`] },
93
+ { cmd: "curl /v1/echo?cidade=Recife", out: ["200 OK", `{ "consulta": { "cidade": "Recife" } }`] },
94
+ { cmd: "curl -N /v1/stream", out: ["200 OK · text/event-stream", "data: a resposta chega aos poucos..."] },
95
+ ] } }),
96
+ { layout: "statement", kicker: "a ideia", text: "O curl é a chamada de API ==mais curta== que existe.",
97
+ notes: "Todo slide de API do SagaDeck mostra o pedido, executa de verdade e traz o curl equivalente na aba curl (e também JavaScript e Python)." },
98
+ { layout: "codewalk", kicker: "anatomia", title: "Um curl em ==quatro partes==", filename: "pedido.sh", language: "bash", code: CURL,
99
+ steps: [
100
+ { title: "Verbo e endereço", text: "-X diz o que fazer (POST cria); a URL diz onde.", highlight: [1] },
101
+ { title: "Quem está pedindo", text: "O token vai no cabeçalho Authorization. Ele vem de uma variável: nunca escrito no comando.", highlight: [2] },
102
+ { title: "O que vai junto", text: "Content-Type avisa que o corpo é JSON; -d leva o corpo.", highlight: [3, 4] },
103
+ { title: "Ver a resposta inteira", text: "-i mostra o status e os cabeçalhos antes do corpo.", highlight: [5], output: "HTTP/1.1 201 Created\ncontent-type: application/json\n\n{ \"id\": \"ped-42\", \"status\": \"criado\" }" },
104
+ ], ...told("Código guiado", "Explique uma linha por vez, com destaques e saída simulada", "Explique este curl parte por parte") },
105
+ { layout: "section", number: "01", kicker: "executar", title: "Ao ==vivo==, de verdade", subtitle: "o Executar faz a chamada; a aba curl mostra o comando" },
106
+ apiSlide({ id: "post", kicker: "POST", title: "Mandar um ==corpo==", request: { method: "POST", url: "{{base}}/echo", body: { mensagem: "Olá, API", turma: "noite" } }, answer: "$.recebido",
107
+ notes: "Edite a mensagem na aba Corpo e execute de novo: a aba curl acompanha." }),
108
+ apiSlide({ id: "get", kicker: "GET", title: "Perguntar pela ==URL==", request: { method: "GET", url: "{{base}}/echo?cidade=Recife&dias=3" }, answer: "$.consulta",
109
+ notes: "GET não tem corpo: os parâmetros vão na URL, depois do ?." }),
110
+ apiSlide({ id: "token", kicker: "Token", title: "Com ==autorização==", request: { method: "POST", url: "{{base}}/sync", body: { q: "Qual é o status do pedido 42?" } }, answer: "$.choices[0].message.content",
111
+ notes: "O token sai do ambiente (o Studio pede e renova sozinho). No curl ele aparece como $API_TOKEN: o valor nunca vai para o slide." }),
112
+ { layout: "section", number: "02", kicker: "esperar", title: "Quando a resposta ==demora==", subtitle: "acompanhar até terminar, ou receber aos poucos" },
113
+ apiSlide({ id: "polling", kicker: "Polling", title: "Inicia e ==acompanha==", mode: "polling",
114
+ request: { url: "{{base}}/start", body: { workflow: "{{wf}}", input: { texto: "Resuma o contrato" } } },
115
+ polling: { id: "$.executionId", check: { url: "{{base}}/status/{{id}}" }, status: "$.status", done: ["FINISHED"], failed: ["ERROR"], interval: 0.8 },
116
+ steps: "$.responses", stepText: "$.output", notes: "O início devolve só o código da execução; a consulta repete até FINISHED. Na aba curl: o laço com sleep." }),
117
+ apiSlide({ id: "stream", kicker: "Streaming", title: "A resposta chega ==aos poucos==", mode: "stream", request: { method: "POST", url: "{{base}}/stream", body: { stream: true } },
118
+ notes: "No curl, -N desliga o buffer e o texto aparece enquanto chega." }),
119
+ { layout: "section", number: "03", kicker: "dia a dia", title: "As opções que ==salvam== o dia", subtitle: "clique em cada cartão para ver para que serve" },
120
+ // (oito cartões ocupam a área toda: aqui a frase para a IA fica só nas notas, sem a linha de rodapé)
121
+ { layout: "cards", title: "Clique para ==virar==", cols: 4, notes: told("Cartão que vira", "A frente mostra a opção; o verso explica, no clique", "Faça cartões que viram com a explicação no verso").notes, items: [
122
+ { icon: "file-text", title: "-i", text: "status e cabeçalhos", flip: "Mostra a linha de status e os cabeçalhos antes do corpo. O primeiro passo de toda investigação." },
123
+ { icon: "bug", title: "-v", text: "conversa inteira", flip: "Mostra tudo: o pedido que saiu, o certificado, o que voltou. Para quando nada faz sentido." },
124
+ { icon: "shield-alert", title: "--fail", text: "erro vira erro", flip: "Status 4xx ou 5xx faz o curl sair com erro. Essencial em script, que senão segue feliz." },
125
+ { icon: "corner-down-right", title: "-L", text: "segue o redirecionamento", flip: "Vai atrás do 301 e do 302 até o endereço final." },
126
+ { icon: "volume-x", title: "-s", text: "sem barra de progresso", flip: "Silencia o progresso; combine com -S para ainda ver os erros." },
127
+ { icon: "download", title: "-o arquivo", text: "grava a resposta", flip: "Salva o corpo num arquivo em vez de jogar na tela." },
128
+ { icon: "timer", title: "-w", text: "tempo e status", flip: "-w '%{http_code} %{time_total}s' imprime o status e quanto demorou." },
129
+ { icon: "braces", title: "| jq", text: "JSON legível", flip: "Mande a saída para o jq: indenta, colore e filtra campos, como jq '.itens[0]'." },
130
+ ] },
131
+ { layout: "statement", kicker: "cuidado", text: "O token ==nunca== vai para o slide.",
132
+ add: { aviso: { tipo: "perigo", titulo: "Segredo exposto", texto: "No deck, use {{secret.nome}}; no curl, $API_TOKEN. Os valores ficam nos Ambientes da sua máquina." } },
133
+ notes: "Os segredos ficam em ~/.sagadeck/ambientes.yaml (no Windows, cifrados para o seu usuário). O deck pode ir para outras pessoas sem vazar nada." },
134
+ { layout: "end", title: "Agora, a sua ==API==.", subtitle: "Peça à IA: “Monte um slide que chama o meu serviço e mostra o curl”." },
135
+ ],
136
+ };
137
+ }
138
+
139
+ export function featureDemo(kind) {
140
+ if (kind === "mapas") return mapasDemo();
141
+ if (kind === "programacao") return programacaoDemo();
142
+ if (kind === "apis") return apisDemo();
143
+ return null;
144
+ }
@@ -0,0 +1,138 @@
1
+ // Rotas do mapa no Studio: a tela Configurar mapa (serviços desta máquina; a chave nunca volta inteira), o estado de
2
+ // cada serviço (vai para a tela e para a IA), "Tentar de novo" e "Achar coordenadas pelo endereço" numa planilha do
3
+ // projeto. Mandar endereços da pessoa para um serviço de fora pede consentimento explícito (`confirmo`), que fica
4
+ // registrado por planilha; o resultado de cada endereço fica em cache no projeto (o Nominatim exige).
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import * as Project from "./project.js";
8
+ import { renameRetry } from "../fs-retry.js";
9
+ import { loadMapConfig, mapaFile, saveMapConfig, maskedMapConfig } from "../map-config.js";
10
+ import { serviceState, resetService, geocodeOne, geocodeInterval, ServiceBlocked, SERVICES } from "../map-services.js";
11
+ import { route, reverseGeocode, isochrone } from "../map-routing.js";
12
+
13
+ const json = (res, status, data) => { res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify(data)); };
14
+ const norm = (s) => String(s ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "");
15
+ const ADDRESS = ["endereco", "address", "logradouro", "rua", "enderecocompleto"], CITY = ["cidade", "municipio", "city", "localidade"], UF = ["uf", "estado", "state"], CEP = ["cep", "postalcode", "codigopostal"];
16
+ const LAT = ["lat", "latitude"], LON = ["lon", "lng", "long", "longitude"];
17
+ const find = (head, names) => head.findIndex((h) => names.includes(norm(h)));
18
+
19
+ const metaFile = (P, name) => path.join(path.dirname(Project.ensureMeta(P)), name); // ensureMeta devolve o arquivo da conversa, dentro de .sagadeck/
20
+ const readJSONFile = (f) => { try { return JSON.parse(fs.readFileSync(f, "utf8")) || {}; } catch { return {}; } };
21
+ const writeJSONFile = (f, v) => { const tmp = `${f}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(v, null, 1)); renameRetry(tmp, f); };
22
+
23
+ // o que procurar para uma linha: o endereço com cidade, UF e CEP quando a planilha tem essas colunas
24
+ export function addressPlan(rows, column) {
25
+ const head = (rows[0] || []).map((h) => String(h).trim());
26
+ const iAddr = column ? head.findIndex((h) => norm(h) === norm(column)) : find(head, ADDRESS);
27
+ if (iAddr < 0) return { error: `A planilha não tem coluna de endereço (procurei ${ADDRESS.join(", ")}); diga qual é.` };
28
+ const extra = [find(head, CITY), find(head, UF), find(head, CEP)].filter((i) => i >= 0 && i !== iAddr);
29
+ const iLat = find(head, LAT), iLon = find(head, LON);
30
+ const items = [];
31
+ rows.slice(1).forEach((r, n) => {
32
+ if (!r.some((c) => String(c).trim())) return;
33
+ const has = iLat >= 0 && iLon >= 0 && String(r[iLat] ?? "").trim() && String(r[iLon] ?? "").trim();
34
+ const q = [r[iAddr], ...extra.map((i) => r[i])].map((x) => String(x ?? "").trim()).filter(Boolean).join(", ");
35
+ if (!has && q) items.push({ row: n + 1, q });
36
+ });
37
+ return { head, iAddr, iLat, iLon, items, column: head[iAddr] };
38
+ }
39
+
40
+ export async function geoRoutes({ req, res, pathname, opts, readJSON, W, isBundledTemplate }) {
41
+ if (pathname === "/api/mapa" && req.method === "GET") {
42
+ if (opts.multiuser) { json(res, 200, { editable: false, services: serviceState(), labels: SERVICES }); return true; }
43
+ json(res, 200, { editable: true, file: mapaFile(), config: maskedMapConfig(loadMapConfig()), services: serviceState(), labels: SERVICES });
44
+ return true;
45
+ }
46
+ if (pathname === "/api/mapa" && req.method === "POST") {
47
+ if (opts.multiuser) { json(res, 403, { error: "No servidor, os serviços do mapa são configurados por quem administra." }); return true; }
48
+ const saved = saveMapConfig(await readJSON(req));
49
+ resetService(); // configuração nova: os serviços podem tentar de novo
50
+ json(res, 200, { ok: true, config: maskedMapConfig(saved), services: serviceState() });
51
+ return true;
52
+ }
53
+ if (pathname === "/api/mapa/tentar" && req.method === "POST") {
54
+ const b = await readJSON(req);
55
+ resetService(b.service || null);
56
+ json(res, 200, { ok: true, services: serviceState() });
57
+ return true;
58
+ }
59
+ // rota pelas ruas (editor: "Seguir as ruas"), endereço de um ponto (ponto novo) e área alcançável
60
+ if (["/api/mapa/rota", "/api/mapa/endereco", "/api/mapa/alcance"].includes(pathname) && req.method === "POST") {
61
+ const b = await readJSON(req);
62
+ try {
63
+ if (pathname === "/api/mapa/rota") json(res, 200, await route(b.pontos, { modo: b.modo }));
64
+ else if (pathname === "/api/mapa/endereco") json(res, 200, { endereco: await reverseGeocode(b.lat, b.lon) });
65
+ else json(res, 200, { geometry: await isochrone([b.lat, b.lon], b.minutos, { modo: b.modo }) });
66
+ } catch (e) { json(res, e instanceof ServiceBlocked ? 503 : 400, { error: e.message, bloqueado: e instanceof ServiceBlocked }); }
67
+ return true;
68
+ }
69
+ // Testar (Configurar mapa): uma rota curta com o que está salvo, para ver se a chave e o serviço respondem
70
+ if (pathname === "/api/mapa/testar" && req.method === "POST") {
71
+ if (opts.multiuser) { json(res, 403, { error: "No servidor, quem testa é quem administra." }); return true; }
72
+ resetService("rotas");
73
+ const t0 = Date.now();
74
+ try {
75
+ const r = await route([[-23.5489, -46.6388], [-23.5505, -46.6333]], { modo: "pe" });
76
+ json(res, 200, { ok: true, distancia: Math.round(r.distancia), minutos: Math.round(r.duracao / 60), ms: Date.now() - t0 });
77
+ } catch (e) { json(res, 200, { ok: false, error: e.message, ms: Date.now() - t0 }); }
78
+ return true;
79
+ }
80
+ if (pathname === "/api/mapa/geocode" && req.method === "POST") {
81
+ const b = await readJSON(req);
82
+ const P = W.file && !isBundledTemplate(W.file) ? Project.projectOf(W.file) : null;
83
+ if (!P) { json(res, 400, { error: "Abra uma apresentação da biblioteca para usar as planilhas do projeto." }); return true; }
84
+ let abs, sheet;
85
+ try { abs = Project.resolveIn(P, b.path); sheet = (await Project.readSheet(abs)).sheets[0]; } catch (e) { json(res, 400, { error: e.message }); return true; }
86
+ const plan = addressPlan(sheet.rows, b.column);
87
+ if (plan.error) { json(res, 400, { error: plan.error }); return true; }
88
+ const cfg = loadMapConfig();
89
+ const cacheFile = metaFile(P, "geocodificacao.json"), consentFile = metaFile(P, "mapa-consentimento.json");
90
+ const cache = readJSONFile(cacheFile), consent = readJSONFile(consentFile);
91
+ const todo = plan.items.filter((it) => !(it.q in cache));
92
+ const host = (() => { try { return new URL(cfg.geocoder.url).host; } catch { return cfg.geocoder.url; } })();
93
+ const summary = { total: plan.items.length, enviar: todo.length, cache: plan.items.length - todo.length, servico: host, segundos: Math.ceil((todo.length * geocodeInterval(cfg)) / 1000), coluna: plan.column };
94
+ // dados da pessoa saem para fora só com consentimento explícito (uma vez por planilha e serviço)
95
+ const key = `${b.path}@${host}`;
96
+ // a tela pergunta o plano antes (200); quem tentar enviar sem confirmar recebe 409 e nada sai
97
+ if (b.planejar) { json(res, 200, { precisaConfirmar: !!(todo.length && !consent[key]), ...summary }); return true; }
98
+ if (todo.length && !b.confirmo && !consent[key]) { json(res, 409, { precisaConfirmar: true, ...summary }); return true; }
99
+ if (todo.length && b.confirmo && !consent[key]) { consent[key] = new Date().toISOString(); writeJSONFile(consentFile, consent); }
100
+ // daqui em diante, o progresso vai linha a linha (ndjson)
101
+ res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-store" });
102
+ const send = (o) => res.write(JSON.stringify(o) + "\n");
103
+ let aborted = false;
104
+ req.on("close", () => { aborted = true; });
105
+ send({ type: "plano", ...summary });
106
+ const rows = sheet.rows.map((r) => [...r]);
107
+ let { iLat, iLon } = plan;
108
+ if (iLat < 0 || iLon < 0) { rows[0].push("latitude", "longitude"); iLat = rows[0].length - 2; iLon = rows[0].length - 1; }
109
+ const falhas = [];
110
+ let ok = 0, feitos = 0, bloqueado = null, last = 0;
111
+ const wait = geocodeInterval(cfg);
112
+ for (const it of plan.items) {
113
+ if (aborted) break;
114
+ let hit = cache[it.q];
115
+ if (!(it.q in cache)) {
116
+ const pause = last + wait - Date.now(); if (pause > 0) await new Promise((r) => setTimeout(r, pause));
117
+ last = Date.now();
118
+ try { hit = await geocodeOne(it.q, { cfg }); cache[it.q] = hit; }
119
+ catch (e) { if (e instanceof ServiceBlocked) { bloqueado = e.motivo; break; } hit = null; }
120
+ }
121
+ feitos++;
122
+ if (hit) { ok++; const r = rows[it.row]; while (r.length < rows[0].length) r.push(""); r[iLat] = String(hit.lat); r[iLon] = String(hit.lon); }
123
+ else falhas.push({ linha: it.row + 1, endereco: it.q });
124
+ if (feitos % 5 === 0 || feitos === plan.items.length) send({ type: "progresso", feitos, total: plan.items.length, ok });
125
+ }
126
+ writeJSONFile(cacheFile, cache);
127
+ // grava o que achou (CSV no mesmo arquivo; xlsx ganha um CSV ao lado, que é o que o mapa lê)
128
+ let out = b.path;
129
+ if (ok) {
130
+ if (/\.(csv|tsv)$/i.test(b.path)) Project.writeSheet(P, b.path, rows);
131
+ else { out = b.path.replace(/\.[^.]+$/, "") + ".csv"; Project.writeSheet(P, out, rows); }
132
+ }
133
+ send({ type: "fim", ok, falhas, bloqueado, cancelado: aborted, path: out });
134
+ res.end();
135
+ return true;
136
+ }
137
+ return false;
138
+ }
@@ -29,6 +29,15 @@ export async function iaRoutes({ req, res, pathname, opts, readJSON }) {
29
29
  json(res, 200, { config: maskedIA(loadIA()), file: iaFile(), source: cfg.source, configured: !!(cfg.url || cfg.adaptador), problem: cfg.problem || "", providers: PROVIDERS, roles: ROLES, recommended: RECOMMENDED, editable: !opts.multiuser });
30
30
  return true;
31
31
  }
32
+ // diagnóstico de todos os recursos (src/ai/ia-check.js), o mesmo do `sagadeck ia-check`: no multiusuário, só quem
33
+ // administra (diz quais modelos estão configurados)
34
+ if (pathname === "/api/ia/check" && req.method === "POST") {
35
+ if (opts.multiuser) { json(res, 403, { error: "No servidor multiusuário, o diagnóstico da IA é de quem administra: rode sagadeck ia-check no servidor." }); return true; }
36
+ const body = await readJSON(req);
37
+ const { iaCheck } = await import("../ai/ia-check.js");
38
+ json(res, 200, await iaCheck({ only: Array.isArray(body.so) ? body.so : null, rapido: !!body.rapido, videoReal: !!body.video_real }));
39
+ return true;
40
+ }
32
41
  if ((pathname === "/api/ia" || pathname === "/api/ia/test") && req.method === "POST") {
33
42
  if (opts.multiuser) { json(res, 403, { error: "No servidor multiusuário, a IA é configurada por quem administra." }); return true; }
34
43
  const body = await readJSON(req);